mirror of
https://github.com/hanzoai/js-sdk.git
synced 2026-10-30 15:02:31 +00:00
Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.
Where this SDK was already right, the other two moved: `put` lets the store
decide whether a document exists, `Filter.order` and `links(project)` are real
capabilities the routes serve, `Import` carries what the handler answers, and
`Fact` keeps the server's own three. Where it was not:
- search speaks kinds, not doctype addresses. `/v1/search` matches `doctypes`
against kb.page, kb.memory and kb.source EXACTLY and silently drops anything
else (knowledge/subsystem.go, sanitizeDocTypes), so `{kinds: ['page']}` — the
word the contract uses — narrowed the query to nothing at all, and `Hit.kind`
came back in a different vocabulary than `Doc.kind`. The mapping is `kb.ts`'s
`doctype` and `kind`, applied at the boundary in both directions; a doctype
from another corpus passes through untouched.
- A write body is field data and nothing else. The framework validates against
DocType.Fields and drops what it does not declare, and `name` is not a field
of any of the three: a page is named from `slug`, a memory and a source are
hash-named. Every member of Doc but `kind` is now optional, so a caller writes
what it has instead of passing empty strings for `project` and `url` — which
were going out on the wire as empty fields the store dropped anyway.
- import(format, data, project). Two values and an option are not an aggregate,
so kb.Source is gone — which also leaves one Source in this package, graph's.
- policy_denied and entitlement_required do not exist. Neither string appears
anywhere in hanzoai/cloud, so a 403 read as denied on one of them would hand
an unauthenticated caller a cure for a refusal nobody made. The refusal set is
the two codes cloud emits.
- audit.list reports the server's own count. A per-page count under a `request`
filter is not "what the filter matched across every page"; the caller now gets
the count the server matched for the filter the server applied.
- The fourth outcome is Fault. RFC 9457's problem detail is a body a 402 carries
and this client reads as a `denied` arm, so naming the outcome with no
decision in it `Problem` gave one word two meanings. `Error` is taken — by the
global here, by Go's builtin, by Python's hierarchy. `Fault` collides with
nothing in any of the three.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- A client with no credential raises a Fault carrying no code. `no_credential`
is not a code any server sends, and a code is what a caller branches on.
HIP-0044 is amended where the code was right and the document was not.
tsc, tsc -p tsconfig.esm.json, smoke, 30 node tests and the examples project all
clean.
Co-authored-by: Hanzo Dev <[email protected]>
386 lines
14 KiB
JavaScript
386 lines
14 KiB
JavaScript
// What each capability puts on the wire, and what it makes of what comes back.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
|
|
import { TOKEN, client, hanzoai, json, terms, wire } from './wire.mjs';
|
|
|
|
/** Every test here starts from a minted token. */
|
|
const answering = (...script) => wire([{ status: 200, body: TOKEN }, ...script]);
|
|
|
|
test('budget.left reports what remains, and reports nothing where nothing bounds it', async (t) => {
|
|
const bounded = answering({
|
|
status: 200,
|
|
body: { plan: 'pro', limit: 20, used: 3, spent: false, window: 'day', resets: 1_788_000_000 },
|
|
});
|
|
t.after(bounded.restore);
|
|
|
|
const a = await client().budget.left();
|
|
assert.equal(bounded[1].url, 'https://api.hanzo.ai/v1/allowance');
|
|
assert.equal(a.left, 17);
|
|
assert.equal(a.window, 'day');
|
|
assert.deepEqual(a.resets, new Date(1_788_000_000_000));
|
|
|
|
const unbounded = answering({ status: 200, body: { plan: 'scale', limit: 0, used: 412, window: '' } });
|
|
t.after(unbounded.restore);
|
|
|
|
const b = await client().budget.left();
|
|
// Unbounded has no remainder and no period. Zero would read as spent.
|
|
assert.equal(b.left, undefined);
|
|
assert.equal(b.resets, undefined);
|
|
assert.equal(b.limit, 0);
|
|
});
|
|
|
|
test('budget.balance is integer minor units', async (t) => {
|
|
const sent = answering({
|
|
status: 200,
|
|
body: { balance: 2_046_200, holds: 1_200, available: 2_045_000, account: 'acme' },
|
|
});
|
|
t.after(sent.restore);
|
|
|
|
const a = await client().budget.balance();
|
|
assert.deepEqual(a.available, { minor: 2_045_000, currency: 'USD' });
|
|
// The wire says `holds`; `held` is an arm of an answer, so this is `reserved`.
|
|
assert.deepEqual(a.reserved, { minor: 1_200, currency: 'USD' });
|
|
assert.equal(a.account, 'acme');
|
|
});
|
|
|
|
test('budget.plan fails to locked, never to an error', async (t) => {
|
|
const sent = answering({ status: 200, body: { tier: '', apps: { studio: true, admin: false } } });
|
|
t.after(sent.restore);
|
|
|
|
const a = await client().budget.plan();
|
|
assert.equal(a.tier, '');
|
|
assert.deepEqual(a.apps, { studio: true, admin: false });
|
|
});
|
|
|
|
test('budget.spent reads the charged ledger', async (t) => {
|
|
const sent = answering({
|
|
status: 200,
|
|
body: {
|
|
user: 'acme',
|
|
count: 2,
|
|
usage: [
|
|
{ transactionId: 'txn_1', amount: 41, metadata: { model: 'zen5' }, createdAt: '2026-09-09T18:00:00Z' },
|
|
{ transactionId: 'txn_2', amount: 7, metadata: { model: 'zen5-mini' }, createdAt: '2026-09-09T18:01:00Z' },
|
|
],
|
|
},
|
|
});
|
|
t.after(sent.restore);
|
|
|
|
const a = await client().budget.spent({ since: new Date('2026-09-09T00:00:00Z'), product: 'inference' });
|
|
assert.deepEqual(terms(sent[1]), { start: '2026-09-09T00:00:00.000Z', product: 'inference' });
|
|
assert.equal(a.total, 2);
|
|
assert.deepEqual(a.items[0], {
|
|
id: 'txn_1',
|
|
at: new Date('2026-09-09T18:00:00Z'),
|
|
model: 'zen5',
|
|
amount: { minor: 41, currency: 'USD' },
|
|
});
|
|
});
|
|
|
|
test('policy.check asks in the order a person says it', async (t) => {
|
|
const sent = answering({
|
|
status: 200,
|
|
body: { allow: false, subject: 'usr_7', verb: 'write', path: 'acme/graph' },
|
|
});
|
|
t.after(sent.restore);
|
|
|
|
const d = await client().policy.check('usr_7', 'write', 'acme/graph');
|
|
assert.equal(sent[1].url, 'https://api.hanzo.ai/v1/authz/check');
|
|
// The caller says (sub, act, obj); the route reads {subject, verb, path}.
|
|
assert.deepEqual(json(sent[1]), { subject: 'usr_7', verb: 'write', path: 'acme/graph' });
|
|
assert.equal(d.allow, false);
|
|
assert.equal(d.sub, 'usr_7');
|
|
assert.equal(d.act, 'write');
|
|
assert.equal(d.obj, 'acme/graph');
|
|
});
|
|
|
|
const row = (n, request) => ({
|
|
seq: n,
|
|
sub: 'usr_7',
|
|
email: '[email protected]',
|
|
org: 'acme',
|
|
action: 'graph.assert',
|
|
resource: 'graph',
|
|
resourceId: 'acme',
|
|
method: 'POST',
|
|
path: '/v1/graph',
|
|
result: 'success',
|
|
status: 200,
|
|
time: '2026-09-10T21:00:0' + (n % 10) + 'Z',
|
|
requestId: request,
|
|
sourceIp: '203.0.113.4',
|
|
userAgent: 'hanzoai/8.5',
|
|
});
|
|
|
|
test('audit renames six wire fields and moves nothing else', async (t) => {
|
|
const sent = answering({ status: 200, body: { data: [row(1, 'req-a')], total: 1 } });
|
|
t.after(sent.restore);
|
|
|
|
const p = await client().audit.list({ actor: 'usr_7', since: new Date('2026-09-10T00:00:00Z'), size: 50 });
|
|
assert.deepEqual(terms(sent[1]), {
|
|
sub: 'usr_7',
|
|
since: '2026-09-10T00:00:00.000Z',
|
|
pageSize: '50',
|
|
});
|
|
const [e] = p.items;
|
|
assert.equal(e.actor, 'usr_7');
|
|
assert.equal(e.id, 'acme');
|
|
assert.equal(e.request, 'req-a');
|
|
assert.equal(e.ip, '203.0.113.4');
|
|
assert.equal(e.agent, 'hanzoai/8.5');
|
|
assert.deepEqual(e.at, new Date('2026-09-10T21:00:01Z'));
|
|
});
|
|
|
|
test('audit.all walks to the total and stops', async (t) => {
|
|
const sent = answering(
|
|
{ status: 200, body: { data: [row(1, 'req-a'), row(2, 'req-b')], total: 3 } },
|
|
{ status: 200, body: { data: [row(3, 'req-a')], total: 3 } },
|
|
);
|
|
t.after(sent.restore);
|
|
|
|
const seen = [];
|
|
for await (const e of client().audit.all({ size: 2 })) seen.push(e.seq);
|
|
assert.deepEqual(seen, [1, 2, 3]);
|
|
assert.deepEqual(terms(sent[1]), { pageSize: '2', p: '1' });
|
|
assert.deepEqual(terms(sent[2]), { pageSize: '2', p: '2' });
|
|
});
|
|
|
|
test('audit narrows by request id client-side, because the route does not', async (t) => {
|
|
const sent = answering(
|
|
{ status: 200, body: { data: [row(1, 'req-a'), row(2, 'req-b')], total: 3 } },
|
|
{ status: 200, body: { data: [row(3, 'req-a')], total: 3 } },
|
|
);
|
|
t.after(sent.restore);
|
|
|
|
const seen = [];
|
|
for await (const e of client().audit.all({ size: 2, request: 'req-a' })) seen.push(e.seq);
|
|
assert.deepEqual(seen, [1, 3]);
|
|
// The term never reaches the query — the route has no such filter.
|
|
assert.equal('requestId' in terms(sent[1]), false);
|
|
assert.equal('request' in terms(sent[1]), false);
|
|
});
|
|
|
|
test('search sends the kinds as this route spells them', async (t) => {
|
|
const sent = answering({
|
|
status: 200,
|
|
body: {
|
|
status: 'ok',
|
|
hits: [{ id: 'runbook', corpus: 'kb', doctype: 'kb.page' }, { id: 'f.go:12', corpus: 'code', doctype: 'code' }],
|
|
backends: [],
|
|
},
|
|
});
|
|
t.after(sent.restore);
|
|
|
|
// `page` is the word a caller says — the same word `kb.Doc.kind` takes — and
|
|
// `kb.page` is the address the route filters on. It drops anything that is
|
|
// not one of the three, so a query narrowed to `page` would come back
|
|
// narrowed to nothing at all.
|
|
const a = await client().search.find('runbook', { mode: 'semantic', kinds: ['page'], limit: 5 });
|
|
assert.deepEqual(json(sent[1]), {
|
|
query: 'runbook',
|
|
mode: 'semantic',
|
|
doctypes: ['kb.page'],
|
|
limit: 5,
|
|
});
|
|
assert.equal(a.value.items[0].kind, 'page');
|
|
// A hit out of another corpus carries that corpus's own type and keeps it.
|
|
assert.equal(a.value.items[1].kind, 'code');
|
|
});
|
|
|
|
test('kb.put replaces a name that exists and creates one that does not', async (t) => {
|
|
// A caller writes what it has: only `kind` is required.
|
|
const doc = { kind: 'page', name: 'runbook', title: 'Runbook', body: '# on call', project: 'ops' };
|
|
|
|
const replaced = answering({ status: 200, body: { name: 'runbook', title: 'Runbook', body: '# on call' } });
|
|
t.after(replaced.restore);
|
|
|
|
const a = await client().kb.put(doc);
|
|
assert.equal(replaced[1].method, 'PUT');
|
|
assert.equal(replaced[1].url, 'https://api.hanzo.ai/v1/framework/kb.page/runbook');
|
|
// The body is field data and nothing else. A page IS its slug — the doctype
|
|
// is autonamed from that field — so the name rides as `slug`, and `name` is
|
|
// not a field any of the three doctypes declares.
|
|
assert.deepEqual(json(replaced[1]), {
|
|
title: 'Runbook',
|
|
project: 'ops',
|
|
body: '# on call',
|
|
slug: 'runbook',
|
|
});
|
|
assert.equal(a.status, 'ok');
|
|
assert.equal(a.value.kind, 'page');
|
|
|
|
const created = answering(
|
|
{ status: 404, body: { status: 404, detail: 'Not Found' } },
|
|
{ status: 201, body: { name: 'runbook', title: 'Runbook', body: '# on call' } },
|
|
);
|
|
t.after(created.restore);
|
|
|
|
const b = await client().kb.put(doc);
|
|
assert.equal(created[2].method, 'POST');
|
|
assert.equal(created[2].url, 'https://api.hanzo.ai/v1/framework/kb.page');
|
|
assert.equal(b.status, 'ok');
|
|
});
|
|
|
|
test('kb.put files a memory under the field its own doctype declares', async (t) => {
|
|
const sent = answering({ status: 201, body: { name: 'm1', content: 'the incident was ours' } });
|
|
t.after(sent.restore);
|
|
|
|
const a = await client().kb.put({ kind: 'memory', title: 'incident', body: 'the incident was ours' });
|
|
assert.equal(sent[1].method, 'POST');
|
|
assert.deepEqual(json(sent[1]), { title: 'incident', content: 'the incident was ours' });
|
|
assert.equal(a.value.body, 'the incident was ours');
|
|
});
|
|
|
|
test('kb.import sends the export verbatim', async (t) => {
|
|
const sent = answering({ status: 200, body: { format: 'obsidian', imported: 3, pages: ['a', 'b', 'c'] } });
|
|
t.after(sent.restore);
|
|
|
|
const a = await client().kb.import('obsidian', 'PK', 'ops');
|
|
assert.deepEqual(terms(sent[1]), { format: 'obsidian', project: 'ops' });
|
|
assert.equal(sent[1].headers['content-type'], 'application/octet-stream');
|
|
assert.equal(sent[1].body, 'PK');
|
|
assert.equal(a.value.imported, 3);
|
|
assert.equal(a.value.format, 'obsidian');
|
|
assert.deepEqual(a.value.pages, ['a', 'b', 'c']);
|
|
});
|
|
|
|
test('kb.links describes documents, and calls a type a kind', async (t) => {
|
|
const sent = answering({
|
|
status: 200,
|
|
body: {
|
|
nodes: [{ id: 'kb.page:home', name: 'home', title: 'Home', type: 'kb.page', project: 'ops' }],
|
|
edges: [{ from: 'kb.page:runbook', to: 'kb.page:home', kind: 'parent' }],
|
|
degraded: true,
|
|
},
|
|
});
|
|
t.after(sent.restore);
|
|
|
|
const l = await client().kb.links('ops');
|
|
assert.deepEqual(terms(sent[1]), { project: 'ops' });
|
|
assert.equal(l.nodes[0].kind, 'kb.page');
|
|
assert.deepEqual(l.edges[0], { from: 'kb.page:runbook', to: 'kb.page:home', kind: 'parent' });
|
|
assert.equal(l.partial, true);
|
|
});
|
|
|
|
const fact = {
|
|
entity: 'acme',
|
|
relation: 'owner',
|
|
value: 'ops',
|
|
names: true,
|
|
at: new Date('2026-09-10T00:00:00Z'),
|
|
source: 'runbook',
|
|
evidence: 'runbook#2',
|
|
confidence: 0.9,
|
|
};
|
|
|
|
test('graph.assert states only what an asserter states', async (t) => {
|
|
const sent = answering({ status: 200, body: { recorded: 1, duplicate: 0, refused: 0, reasons: [] } });
|
|
t.after(sent.restore);
|
|
|
|
const a = await client().graph.assert([{ ...fact, id: 'never', by: 'never', knowable: new Date() }]);
|
|
assert.deepEqual(json(sent[1]), {
|
|
assertions: [
|
|
{
|
|
entity: 'acme',
|
|
relation: 'owner',
|
|
value: 'ops',
|
|
names: true,
|
|
at: '2026-09-10T00:00:00.000Z',
|
|
source: 'runbook',
|
|
evidence: 'runbook#2',
|
|
confidence: 0.9,
|
|
},
|
|
],
|
|
});
|
|
assert.equal(a.status, 'ok');
|
|
assert.equal(a.value.recorded, 1);
|
|
});
|
|
|
|
test('a batch whose counts do not add up is a fault, not an answer', async (t) => {
|
|
const sent = answering({ status: 200, body: { recorded: 1, duplicate: 0, refused: 0, reasons: [] } });
|
|
t.after(sent.restore);
|
|
|
|
await assert.rejects(() => client().graph.assert([fact, fact]), (err) => {
|
|
assert.ok(err instanceof hanzoai.answer.Fault);
|
|
assert.equal(err.code, 'short_write');
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('graph reads at an instant, and the route spells that as_of', async (t) => {
|
|
const stored = {
|
|
entity: 'acme',
|
|
relation: 'owner',
|
|
value: 'ops',
|
|
names: true,
|
|
at: '2026-09-10T00:00:00Z',
|
|
seen: '2026-09-10T00:05:00Z',
|
|
knowable: '2026-09-10T00:05:00Z',
|
|
source: 'runbook',
|
|
by: 'acme/usr_7',
|
|
id: 'sha256:9f',
|
|
};
|
|
const sent = answering(
|
|
{ status: 200, body: { assertions: [stored] } },
|
|
{ status: 200, body: { assertions: [stored] } },
|
|
{
|
|
status: 200,
|
|
body: { entity: 'acme', relation: 'owner', as_of: '2026-09-10T12:00:00Z', known: true, winner: stored, conflicts: [], contested: false },
|
|
},
|
|
{ status: 200, body: { entities: ['acme', 'ops'], depth: 1, bound: 500, truncated: false } },
|
|
{ status: 200, body: { relations: ['owner'], rule: ['knowable', 'confidence'], bound: 500 } },
|
|
);
|
|
t.after(sent.restore);
|
|
|
|
const c = client();
|
|
const at = new Date('2026-09-10T12:00:00Z');
|
|
|
|
const read = await c.graph.read({ entity: 'acme', at });
|
|
assert.deepEqual(terms(sent[1]), { entity: 'acme', as_of: '2026-09-10T12:00:00.000Z' });
|
|
assert.equal(read[0].id, 'sha256:9f');
|
|
assert.equal(read[0].by, 'acme/usr_7');
|
|
assert.deepEqual(read[0].knowable, new Date('2026-09-10T00:05:00Z'));
|
|
|
|
await c.graph.find('runbook', { relation: 'owner', limit: 5 });
|
|
assert.deepEqual(terms(sent[2]), { q: 'runbook', relation: 'owner', limit: '5' });
|
|
|
|
const settled = await c.graph.resolve('acme', 'owner', at);
|
|
assert.deepEqual(json(sent[3]), { entity: 'acme', relation: 'owner', as_of: '2026-09-10T12:00:00.000Z' });
|
|
assert.equal(settled.known, true);
|
|
assert.equal(settled.winner.value, 'ops');
|
|
assert.equal(settled.contested, false);
|
|
|
|
const walked = await c.graph.walk(['acme'], { direction: 'both', depth: 2 });
|
|
assert.deepEqual(json(sent[4]), { seeds: ['acme'], direction: 'both', depth: 2 });
|
|
assert.deepEqual(walked.entities, ['acme', 'ops']);
|
|
|
|
const vocabulary = await c.graph.vocabulary();
|
|
// The wire member is `rule`; the SDK pluralizes it because it is a list.
|
|
assert.deepEqual(vocabulary.rules, ['knowable', 'confidence']);
|
|
});
|
|
|
|
test('graph.extract records nothing', async (t) => {
|
|
const sent = answering({
|
|
status: 200,
|
|
body: { triples: [{ subject: 'acme', predicate: 'owner', object: 'ops', names: true, section: 2 }] },
|
|
});
|
|
t.after(sent.restore);
|
|
|
|
const triples = await client().graph.extract({
|
|
source: 'runbook',
|
|
subject: 'acme',
|
|
text: 'owner:: [[ops]]',
|
|
at: new Date('2026-09-10T00:00:00Z'),
|
|
});
|
|
assert.equal(sent[1].url, 'https://api.hanzo.ai/v1/graph/extract');
|
|
assert.deepEqual(json(sent[1]), {
|
|
source: 'runbook',
|
|
subject: 'acme',
|
|
text: 'owner:: [[ops]]',
|
|
at: '2026-09-10T00:00:00.000Z',
|
|
});
|
|
assert.deepEqual(triples, [{ subject: 'acme', predicate: 'owner', object: 'ops', names: true, section: 2 }]);
|
|
});
|