@intentius/chant 0.104.4 → 0.105.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/op/activities/factory.d.ts +1 -1
- package/dist/op/activities/factory.d.ts.map +1 -1
- package/dist/workspace/agent-cli.d.ts +14 -8
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/broker-protocol.d.ts +65 -3
- package/dist/workspace/broker-protocol.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts.map +1 -1
- package/dist/workspace/conformance/broker.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +9 -2
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +32 -6
- package/dist/workspace/export.d.ts.map +1 -1
- package/dist/workspace/factory-rules.d.ts +9 -4
- package/dist/workspace/factory-rules.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +1 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/write-scope.d.ts +7 -5
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/op/activities/factory.ts +10 -2
- package/src/op/factory.e2e.test.ts +16 -5
- package/src/workspace/agent-cli.ts +26 -9
- package/src/workspace/agent.schema.json +33 -21
- package/src/workspace/broker-protocol.schema.json +45 -3
- package/src/workspace/broker-protocol.test.ts +45 -0
- package/src/workspace/broker-protocol.ts +131 -5
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +3 -1
- package/src/workspace/conformance/broker.ts +22 -0
- package/src/workspace/declaration.schema.json +32 -6
- package/src/workspace/declaration.ts +29 -15
- package/src/workspace/export.ts +10 -3
- package/src/workspace/factory-rules.test.ts +6 -1
- package/src/workspace/factory-rules.ts +10 -3
- package/src/workspace/reason-codes.ts +1 -1
- package/src/workspace/write-scope.test.ts +18 -1
- package/src/workspace/write-scope.ts +15 -10
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The factory reference Op (#3406, ws-087), run through the local executor
|
|
3
3
|
* with stub hooks on a small workspace that carries the reference
|
|
4
|
-
* workspace's decision, work and
|
|
4
|
+
* workspace's decision, work, answer and contract kinds and its points, its
|
|
5
|
+
* work kind linked to the contracts, as the factory builds only an item that
|
|
6
|
+
* names a contract, an ask or an intent build (#3503): one item from
|
|
5
7
|
* pick to done, one retry after a failed build, and one ask the understand
|
|
6
8
|
* point refuses. The rules themselves are tested in
|
|
7
9
|
* ../workspace/factory-rules.test.ts.
|
|
@@ -53,10 +55,19 @@ function workspace(dir: string): void {
|
|
|
53
55
|
git(["config", "user.email", "t@chant.dev"], dir);
|
|
54
56
|
git(["config", "user.name", "T"], dir);
|
|
55
57
|
git(["config", "commit.gpgsign", "false"], dir);
|
|
56
|
-
for (const d of ["decisions", "work", "answers"]) mkdirSync(join(dir, d), { recursive: true });
|
|
58
|
+
for (const d of ["decisions", "work", "answers", "contracts"]) mkdirSync(join(dir, d), { recursive: true });
|
|
57
59
|
for (const f of ["decisions/decision.kind.mjs", "decisions/decision.schema.json", "decisions/points.json", "work/work.kind.mjs", "work/work.schema.json", "answers/answer.kind.mjs", "answers/answer.schema.json"]) {
|
|
58
60
|
cpSync(join(REF, f), join(dir, f));
|
|
59
61
|
}
|
|
62
|
+
for (const f of ["contract.kind.mjs", "contract.schema.json"]) cpSync(join(REF, "design", "contracts", f), join(dir, "contracts", f));
|
|
63
|
+
const kindFile = join(dir, "work", "work.kind.mjs");
|
|
64
|
+
const answers = 'answers: "../answers/answer.kind.mjs",';
|
|
65
|
+
if (!readFileSync(kindFile, "utf-8").includes(answers)) throw new Error("the reference work kind no longer names its answer kind as the fixture expects");
|
|
66
|
+
writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace(answers, `${answers}\n contract: { field: "contract", kind: "../contracts/contract.kind.mjs" },`));
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(dir, "contracts", "C-001-the-feature.md"),
|
|
69
|
+
`---\n${JSON.stringify({ schema: 1, id: "C-001", title: "The feature", state: "approved", criteria: [{ id: "AC-1", text: "the feature exists" }], checks: [], reviews: [], approved_by: "alice" }, null, 2)}\n---\n\n# The feature\n`,
|
|
70
|
+
);
|
|
60
71
|
writeFileSync(
|
|
61
72
|
join(dir, "chant.workspace.json"),
|
|
62
73
|
JSON.stringify({
|
|
@@ -71,8 +82,8 @@ function workspace(dir: string): void {
|
|
|
71
82
|
mkdirSync(join(dir, "hooks"), { recursive: true });
|
|
72
83
|
writeFileSync(join(dir, "hooks", "builder.cjs"), BUILDER);
|
|
73
84
|
writeFileSync(join(dir, "hooks", "check.cjs"), CHECK);
|
|
74
|
-
writeFileSync(join(dir, "work", "W-101-the-feature.md"), item("W-101", { acceptance: [{ id: "AC-1", text: "app/W-101.txt exists", verification: "unit" }] }));
|
|
75
|
-
writeFileSync(join(dir, "work", "W-102-the-retried-one.md"), item("W-102", { acceptance: [{ id: "AC-1", text: "app/W-102.txt exists", verification: "unit" }] }));
|
|
85
|
+
writeFileSync(join(dir, "work", "W-101-the-feature.md"), item("W-101", { contract: "C-001", acceptance: [{ id: "AC-1", text: "app/W-101.txt exists", verification: "unit" }] }));
|
|
86
|
+
writeFileSync(join(dir, "work", "W-102-the-retried-one.md"), item("W-102", { contract: "C-001", acceptance: [{ id: "AC-1", text: "app/W-102.txt exists", verification: "unit" }] }));
|
|
76
87
|
writeFileSync(join(dir, "work", "W-103-an-ask.md"), item("W-103", { state: "proposed", source: { ask: { said: "Make the page purple.", by: "alice", via: "hud" } } }));
|
|
77
88
|
git(["add", "-A"], dir);
|
|
78
89
|
git(["commit", "-q", "-m", "the workspace"], dir);
|
|
@@ -177,7 +188,7 @@ describe("the factory Op's seams for an orchestrator (studio#382)", () => {
|
|
|
177
188
|
// The prepare hook writes W-110 in the checkout and leaves it uncommitted; the others are taken out of the way.
|
|
178
189
|
writeFileSync(
|
|
179
190
|
join(dir, "hooks", "prepare.cjs"),
|
|
180
|
-
`require("node:fs").writeFileSync("work/W-110-prepared.md", ${JSON.stringify(item("W-110", { acceptance: [{ id: "AC-1", text: "one", verification: "unit" }, { id: "AC-2", text: "two", verification: "unit" }] }))});`,
|
|
191
|
+
`require("node:fs").writeFileSync("work/W-110-prepared.md", ${JSON.stringify(item("W-110", { contract: "C-001", acceptance: [{ id: "AC-1", text: "one", verification: "unit" }, { id: "AC-2", text: "two", verification: "unit" }] }))});`,
|
|
181
192
|
);
|
|
182
193
|
for (const id of ["W-101", "W-102"]) writeFileSync(join(dir, ".fail-" + id), "");
|
|
183
194
|
git(["add", "-A"], dir);
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* an agent session reloads from, read from the repository alone.
|
|
4
4
|
*
|
|
5
5
|
* A session is declared in the declaration's `agents` list and bound to one
|
|
6
|
-
* member. This prints the session, its
|
|
6
|
+
* member or several (ws-101). This prints the session, its members, its
|
|
7
|
+
* write scope (the files
|
|
7
8
|
* and the record kinds, with their verbs, it may write) and the spec: the
|
|
8
9
|
* `spec` block `chant workspace records --current --json` prints (#2546). An
|
|
9
10
|
* agent that resumes needs nothing else: no memory of an earlier run, no
|
|
@@ -45,6 +46,13 @@ export interface AgentKindScope {
|
|
|
45
46
|
verbs: WriteVerb[];
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** A member an agent session is bound to. */
|
|
50
|
+
export interface AgentMember {
|
|
51
|
+
name: string;
|
|
52
|
+
dir: string;
|
|
53
|
+
kind: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
interface Head {
|
|
49
57
|
$schema: string;
|
|
50
58
|
contract: number;
|
|
@@ -54,11 +62,18 @@ interface Head {
|
|
|
54
62
|
export type AgentDocument =
|
|
55
63
|
| (Head & {
|
|
56
64
|
workspace: { name: string; root: string; scopeFrom: "base" | "working-tree" };
|
|
57
|
-
agent: {
|
|
65
|
+
agent: {
|
|
66
|
+
name: string;
|
|
67
|
+
/** The first member the session is bound to, kept for readers of a one-member session. */
|
|
68
|
+
member: AgentMember;
|
|
69
|
+
/** Every member the session is bound to, in the order it names them (ws-101). */
|
|
70
|
+
members: AgentMember[];
|
|
71
|
+
principals: string[];
|
|
72
|
+
};
|
|
58
73
|
scope: {
|
|
59
|
-
/** The
|
|
74
|
+
/** The members whose files the session writes. */
|
|
60
75
|
members: string[];
|
|
61
|
-
/** Every declared kind in reach: the workspace's own
|
|
76
|
+
/** Every declared kind in reach: the workspace's own, then each member's. */
|
|
62
77
|
records: AgentKindScope[];
|
|
63
78
|
/** The paths writeScope.agent.protected keeps the session from writing, with the JSON keys each still allows (#3146). */
|
|
64
79
|
protected: { path: string; except: string[] }[];
|
|
@@ -81,13 +96,14 @@ export async function agentSession(q: { cwd: string; name: string }): Promise<Ag
|
|
|
81
96
|
const decl = source.declaration;
|
|
82
97
|
const writer = resolveWriter(decl, source.policy, { agent: q.name }, source.classes);
|
|
83
98
|
const agent = writer.agent!;
|
|
84
|
-
const
|
|
99
|
+
const bound = agent.members.map((name) => decl.members.find((m) => m.name === name)!);
|
|
100
|
+
const members: AgentMember[] = bound.map((m) => ({ name: m.name, dir: m.dir, kind: m.kind }));
|
|
85
101
|
const scope = scopeOf(decl, writer)!;
|
|
86
102
|
const rules = scope.records;
|
|
87
103
|
// The spec query, over every declared kind in the working tree.
|
|
88
104
|
const set = await queryDeclaredRecords(declaredKindFiles(q.cwd), { cwd: q.cwd, current: true });
|
|
89
105
|
const loadedName = new Map(set.kinds.map((k) => [k.declared.path, "error" in k ? null : k.kind.name]));
|
|
90
|
-
const records: AgentKindScope[] = [...decl.records, ...
|
|
106
|
+
const records: AgentKindScope[] = [...decl.records, ...bound.flatMap((m) => m.records)].map((d) => {
|
|
91
107
|
const kind = loadedName.get(d.path) ?? null;
|
|
92
108
|
const names = [kind, d.name].filter((n): n is string => n !== null);
|
|
93
109
|
const verbs = rules === null ? [...WRITE_VERBS] : WRITE_VERBS.filter((v) => names.some((n) => (rules[n] ?? []).includes(v)));
|
|
@@ -96,8 +112,8 @@ export async function agentSession(q: { cwd: string; name: string }): Promise<Ag
|
|
|
96
112
|
return {
|
|
97
113
|
...head,
|
|
98
114
|
workspace: { name: decl.name, root: source.root ?? ".", scopeFrom: source.from },
|
|
99
|
-
agent: { name: agent.name, member:
|
|
100
|
-
scope: { members:
|
|
115
|
+
agent: { name: agent.name, member: members[0], members, principals: agent.principals },
|
|
116
|
+
scope: { members: members.map((m) => m.name), records, protected: scope.protected.map((p) => ({ path: p.path, except: [...p.except] })) },
|
|
101
117
|
spec: set.spec ?? { kinds: [], records: [] },
|
|
102
118
|
reload: [`chant workspace agent ${agent.name} --json`, "chant workspace records --current --json"],
|
|
103
119
|
};
|
|
@@ -127,7 +143,8 @@ export async function runWorkspaceAgent(ctx: CommandContext): Promise<number> {
|
|
|
127
143
|
}
|
|
128
144
|
if (args.json || args.format === "json") return 0;
|
|
129
145
|
const out: string[] = [];
|
|
130
|
-
|
|
146
|
+
const bound = doc.agent.members.map((m) => `${m.name} (${m.dir})`).join(", ");
|
|
147
|
+
out.push(`agent ${doc.agent.name}, bound to ${doc.agent.members.length === 1 ? "member" : "members"} ${bound}, scope read from ${doc.workspace.scopeFrom}`);
|
|
131
148
|
for (const r of doc.scope.records) out.push(`records ${r.name ?? r.kind ?? r.path} (${r.path}): ${r.verbs.length > 0 ? r.verbs.join(", ") : "not writable"}`);
|
|
132
149
|
for (const p of doc.scope.protected) out.push(`protected ${p.path}${p.except.length > 0 ? ` (except ${p.except.join(", ")})` : ""}`);
|
|
133
150
|
out.push(`spec ${doc.spec.records.length} current records of ${doc.spec.kinds.length} spec kinds`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/agent/v1/agent.schema.json",
|
|
4
4
|
"title": "chant workspace agent output",
|
|
5
|
-
"description": "What `chant workspace agent <name> --json` prints (#2524 D20, #2548, ws-067): an agent session, the
|
|
5
|
+
"description": "What `chant workspace agent <name> --json` prints (#2524 D20, #2548, ws-067): an agent session, the members it is bound to (one, or several since ws-101), its write scope and the spec, read from the repository alone, so a session that resumes rebuilds its context from this and nothing else. The session and its scope are read from the declaration at base, as the write paths and check --changes read them; the spec from the working tree. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, and every one is in the one closed list of `reason-codes.ts`. Contract version 1; this document was added to it by #2548.",
|
|
6
6
|
"oneOf": [
|
|
7
7
|
{
|
|
8
8
|
"$ref": "#/$defs/result"
|
|
@@ -72,23 +72,16 @@
|
|
|
72
72
|
"type": "string"
|
|
73
73
|
},
|
|
74
74
|
"member": {
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"dir": {
|
|
86
|
-
"type": "string"
|
|
87
|
-
},
|
|
88
|
-
"kind": {
|
|
89
|
-
"type": "string"
|
|
90
|
-
}
|
|
91
|
-
}
|
|
75
|
+
"$ref": "#/$defs/member",
|
|
76
|
+
"description": "The first member the session is bound to, the only one for a session that names one member."
|
|
77
|
+
},
|
|
78
|
+
"members": {
|
|
79
|
+
"type": "array",
|
|
80
|
+
"minItems": 1,
|
|
81
|
+
"items": {
|
|
82
|
+
"$ref": "#/$defs/member"
|
|
83
|
+
},
|
|
84
|
+
"description": "Added in contract 1 by #3505. Every member the session is bound to, in the order its declaration names them (ws-101); the first is member."
|
|
92
85
|
},
|
|
93
86
|
"principals": {
|
|
94
87
|
"type": "array",
|
|
@@ -111,11 +104,11 @@
|
|
|
111
104
|
"items": {
|
|
112
105
|
"type": "string"
|
|
113
106
|
},
|
|
114
|
-
"description": "The
|
|
107
|
+
"description": "The members whose files the session writes: every member it is bound to, in the order its declaration names them."
|
|
115
108
|
},
|
|
116
109
|
"records": {
|
|
117
110
|
"type": "array",
|
|
118
|
-
"description": "Every declared record kind in the session's reach: the workspace's own kinds, then
|
|
111
|
+
"description": "Every declared record kind in the session's reach: the workspace's own kinds, then each member's, in the order of members.",
|
|
119
112
|
"items": {
|
|
120
113
|
"type": "object",
|
|
121
114
|
"required": [
|
|
@@ -168,7 +161,7 @@
|
|
|
168
161
|
},
|
|
169
162
|
"protected": {
|
|
170
163
|
"type": "array",
|
|
171
|
-
"description": "Added in contract 1 by #3146. The paths writeScope.agent.protected keeps the session from writing even inside its
|
|
164
|
+
"description": "Added in contract 1 by #3146. The paths writeScope.agent.protected keeps the session from writing even inside its members, each a glob from the workspace root that also covers everything under a directory it matches, with the top-level keys of a JSON file a write may still change in except. Empty when none is listed. check --changes reports a write to one with write-scope-protected.",
|
|
172
165
|
"items": {
|
|
173
166
|
"type": "object",
|
|
174
167
|
"required": [
|
|
@@ -337,6 +330,25 @@
|
|
|
337
330
|
"scope": false
|
|
338
331
|
}
|
|
339
332
|
},
|
|
333
|
+
"member": {
|
|
334
|
+
"type": "object",
|
|
335
|
+
"required": [
|
|
336
|
+
"name",
|
|
337
|
+
"dir",
|
|
338
|
+
"kind"
|
|
339
|
+
],
|
|
340
|
+
"properties": {
|
|
341
|
+
"name": {
|
|
342
|
+
"type": "string"
|
|
343
|
+
},
|
|
344
|
+
"dir": {
|
|
345
|
+
"type": "string"
|
|
346
|
+
},
|
|
347
|
+
"kind": {
|
|
348
|
+
"type": "string"
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
},
|
|
340
352
|
"asset": {
|
|
341
353
|
"type": "object",
|
|
342
354
|
"required": [
|
|
@@ -35,14 +35,16 @@
|
|
|
35
35
|
},
|
|
36
36
|
"declarationReport": {
|
|
37
37
|
"type": "object",
|
|
38
|
-
"description": "POST /api/box/declaration: the capabilities chant workspace status --json lists for the box's block, sent by the box's steward with the box's own token. At most 32 KB. Each report replaces the last.",
|
|
38
|
+
"description": "POST /api/box/declaration: the capabilities chant workspace status --json lists for the box's block, sent by the box's steward with the box's own token, and, optionally, the box's listing and this month's spend (#3508, ws-102). At most 32 KB. Each report replaces the last.",
|
|
39
39
|
"required": ["capabilities"],
|
|
40
40
|
"properties": {
|
|
41
41
|
"capabilities": {
|
|
42
42
|
"type": "array",
|
|
43
43
|
"maxItems": 20,
|
|
44
44
|
"items": { "$ref": "#/$defs/capability" }
|
|
45
|
-
}
|
|
45
|
+
},
|
|
46
|
+
"listing": { "$ref": "#/$defs/listing" },
|
|
47
|
+
"spend": { "$ref": "#/$defs/spend" }
|
|
46
48
|
}
|
|
47
49
|
},
|
|
48
50
|
"declarationKept": {
|
|
@@ -63,9 +65,49 @@
|
|
|
63
65
|
}
|
|
64
66
|
},
|
|
65
67
|
"at": { "type": "string", "minLength": 1, "description": "An ISO 8601 time." },
|
|
66
|
-
"payer": { "$ref": "#/$defs/payer" }
|
|
68
|
+
"payer": { "$ref": "#/$defs/payer" },
|
|
69
|
+
"listing": { "$ref": "#/$defs/listing", "description": "The report's listing, when the broker keeps one." },
|
|
70
|
+
"spend": { "$ref": "#/$defs/spend", "description": "The report's spend, when the broker keeps one." }
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"listing": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"description": "Optional in a report, added within version 1 by #3508 (ws-102). The box block's listing (ws-077) as chant workspace status --json prints it, less the cover: what a broker that lists boxes lists this one under. The bounds are the ones chant workspace box listing set writes with. A broker that lists no boxes ignores it.",
|
|
76
|
+
"required": ["title", "line"],
|
|
77
|
+
"properties": {
|
|
78
|
+
"published": { "type": "boolean", "description": "Whether the box is listed for people other than its owner. Absent means true." },
|
|
79
|
+
"title": { "type": "string", "maxLength": 60, "pattern": "^[^\\u0000-\\u001f\\u007f]*$" },
|
|
80
|
+
"line": { "type": "string", "maxLength": 140, "pattern": "^[^\\u0000-\\u001f\\u007f]*$" }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"spend": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"description": "Optional in a report, added within version 1 by #3508 (ws-102). What the box's run records (chant workspace runs --json, ws-076) say it spent in one calendar month, by each run's startedAt: the month's totals, and the same per principal the runs worked for. Only USD costs are summed: a run with no USD cost is counted in unpriced, never as zero. spendFromRuns in broker-protocol.ts computes it. A broker that reports no spend ignores it.",
|
|
86
|
+
"required": ["month", "usd", "runs", "unpriced", "byPrincipal"],
|
|
87
|
+
"properties": {
|
|
88
|
+
"month": { "type": "string", "pattern": "^\\d{4}-(0[1-9]|1[0-2])$", "description": "The month, YYYY-MM, in UTC." },
|
|
89
|
+
"usd": { "$ref": "#/$defs/spendUsd" },
|
|
90
|
+
"runs": { "$ref": "#/$defs/spendCount" },
|
|
91
|
+
"unpriced": { "$ref": "#/$defs/spendCount" },
|
|
92
|
+
"byPrincipal": {
|
|
93
|
+
"type": "array",
|
|
94
|
+
"maxItems": 50,
|
|
95
|
+
"description": "Per principal, as the run records' by names them (ws-080's form), at most 50; principal is null for the runs that name none.",
|
|
96
|
+
"items": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"required": ["principal", "usd", "runs", "unpriced"],
|
|
99
|
+
"properties": {
|
|
100
|
+
"principal": { "type": ["string", "null"], "minLength": 1, "maxLength": 200 },
|
|
101
|
+
"usd": { "$ref": "#/$defs/spendUsd" },
|
|
102
|
+
"runs": { "$ref": "#/$defs/spendCount" },
|
|
103
|
+
"unpriced": { "$ref": "#/$defs/spendCount" }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
67
107
|
}
|
|
68
108
|
},
|
|
109
|
+
"spendUsd": { "type": "number", "minimum": 0, "exclusiveMaximum": 1000000000, "description": "The sum of the priced runs' USD costs." },
|
|
110
|
+
"spendCount": { "type": "integer", "minimum": 0, "exclusiveMaximum": 1000000000, "description": "A count of runs: all of them under runs, those with no USD cost under unpriced." },
|
|
69
111
|
"payer": {
|
|
70
112
|
"type": "object",
|
|
71
113
|
"description": "Whose credential pays for the box's model calls (#3474, ws-098), when the broker says. shared: a credential neither the box's owner nor its visitor owns, such as a house or operator key. owner: the box owner's own. visitor: the credential a person brought for this box. principal is the payer in ws-080's form (github:<login>) when the broker knows them, else null. Optional: a broker that does not say leaves it out, and a box treats that as unknown. The same fact rides on each answer from /llm/anthropic and /decide as the chant-payer header.",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parsePayerHeader,
|
|
13
13
|
payerOf,
|
|
14
14
|
refusalMessage,
|
|
15
|
+
spendFromRuns,
|
|
15
16
|
STANDARD_CAPABILITIES,
|
|
16
17
|
} from "./broker-protocol";
|
|
17
18
|
|
|
@@ -120,3 +121,47 @@ describe("a grant (#3477)", () => {
|
|
|
120
121
|
for (const bad of ["", "has space", "line\nbreak", "x".repeat(GRANT_MAX_LENGTH + 1), 42, null]) expect(isGrantValue(bad), String(bad).slice(0, 20)).toBe(false);
|
|
121
122
|
});
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("a report's listing and spend (#3508)", () => {
|
|
126
|
+
const spend = { month: "2026-10", usd: 1.84, runs: 3, unpriced: 1, byPrincipal: [{ principal: "github:octo", usd: 1.84, runs: 2, unpriced: 0 }, { principal: null, usd: 0, runs: 1, unpriced: 1 }] };
|
|
127
|
+
|
|
128
|
+
test("are kept when well formed, with published defaulting to true, and left out when absent", () => {
|
|
129
|
+
expect(parseDeclarationReport({ capabilities: [], listing: { title: "Salad", line: "a salad app" }, spend }, "lobby")).toEqual({
|
|
130
|
+
capabilities: [],
|
|
131
|
+
listing: { published: true, title: "Salad", line: "a salad app" },
|
|
132
|
+
spend,
|
|
133
|
+
});
|
|
134
|
+
expect(parseDeclarationReport({ capabilities: [] }, "lobby")).toEqual({ capabilities: [] });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a malformed listing or spend refuses the report", () => {
|
|
138
|
+
for (const listing of [[], { title: "x".repeat(61) }, { title: "a\nb" }, { line: "x".repeat(141) }, { published: "yes" }]) {
|
|
139
|
+
expect(parseDeclarationReport({ capabilities: [], listing }, "lobby"), JSON.stringify(listing)).toHaveProperty("error");
|
|
140
|
+
}
|
|
141
|
+
for (const bad of [{ ...spend, month: "2026-13" }, { ...spend, usd: -1 }, { ...spend, runs: 1.5 }, { ...spend, byPrincipal: [{ principal: "", usd: 0, runs: 0, unpriced: 0 }] }]) {
|
|
142
|
+
expect(parseDeclarationReport({ capabilities: [], spend: bad }, "lobby"), JSON.stringify(bad)).toHaveProperty("error");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("spend sums a month's runs per principal, with a run that has no USD cost unpriced, never zero", () => {
|
|
147
|
+
const runs = [
|
|
148
|
+
{ startedAt: "2026-10-02T10:00:00Z", by: "github:octo", cost: { amount: 1.5, currency: "USD", source: "x" } },
|
|
149
|
+
{ startedAt: "2026-10-03T10:00:00Z", by: "github:octo", cost: { amount: 0.34, currency: "USD", source: "x" } },
|
|
150
|
+
{ startedAt: "2026-10-03T11:00:00Z", by: null, cost: null },
|
|
151
|
+
{ startedAt: "2026-10-04T11:00:00Z", by: "github:ada", cost: { amount: 2, currency: "EUR", source: "x" } },
|
|
152
|
+
{ startedAt: "2026-09-30T23:00:00Z", by: "github:octo", cost: { amount: 9, currency: "USD", source: "x" } },
|
|
153
|
+
];
|
|
154
|
+
expect(spendFromRuns({ runs }, "2026-10")).toEqual({
|
|
155
|
+
month: "2026-10",
|
|
156
|
+
usd: 1.84,
|
|
157
|
+
runs: 4,
|
|
158
|
+
unpriced: 2,
|
|
159
|
+
byPrincipal: [
|
|
160
|
+
{ principal: "github:octo", usd: 1.84, runs: 2, unpriced: 0 },
|
|
161
|
+
{ principal: null, usd: 0, runs: 1, unpriced: 1 },
|
|
162
|
+
{ principal: "github:ada", usd: 0, runs: 1, unpriced: 1 },
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
expect(spendFromRuns({ error: { code: "x" } }, "2026-10")).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
* decide answer ({@link BrokerPayer}). It is optional within version 1.
|
|
31
31
|
* A box names whose turn a request is with a `chant-grant` header the broker
|
|
32
32
|
* issued (#3477, ws-099); the broker then may spend that person's credential.
|
|
33
|
+
* A report may carry the box's listing and this month's spend from its run
|
|
34
|
+
* records (#3508, ws-102), {@link BoxListing} and {@link BoxSpend}; both are
|
|
35
|
+
* optional within version 1, and a broker that has no use for them ignores
|
|
36
|
+
* them.
|
|
33
37
|
*
|
|
34
38
|
* The rule every broker keeps: a request that needs scope word `w` of
|
|
35
39
|
* capability `c` is refused with a 403 unless the box's last report holds an
|
|
@@ -266,14 +270,132 @@ export interface KeptDeclaration {
|
|
|
266
270
|
capabilities: KeptCapability[];
|
|
267
271
|
at: string;
|
|
268
272
|
payer?: BrokerPayer;
|
|
273
|
+
/** The report's listing, when the broker keeps it (#3508). */
|
|
274
|
+
listing?: BoxListing;
|
|
275
|
+
/** The report's spend, when the broker keeps it (#3508). */
|
|
276
|
+
spend?: BoxSpend;
|
|
269
277
|
}
|
|
270
278
|
|
|
271
279
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
280
|
+
* The box block's listing (ws-077) as a report carries it (#3508, ws-102):
|
|
281
|
+
* what `chant workspace status --json` prints under the box member's
|
|
282
|
+
* `box.listing`, less the cover. A broker that lists boxes lists the box
|
|
283
|
+
* under it; the repository keeps it, and the broker only a cache.
|
|
275
284
|
*/
|
|
276
|
-
export
|
|
285
|
+
export interface BoxListing {
|
|
286
|
+
/** Whether people other than the owner see the box listed. Absent in a report means true. */
|
|
287
|
+
published: boolean;
|
|
288
|
+
title: string;
|
|
289
|
+
line: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The bounds on a reported listing: the ones `chant workspace box listing set` writes with. */
|
|
293
|
+
export const LISTING_LIMITS = { title: 60, line: 140 } as const;
|
|
294
|
+
|
|
295
|
+
/** One principal's share of a month's spend, or the month's totals without `principal`. */
|
|
296
|
+
export interface SpendFigures {
|
|
297
|
+
/** The sum of the priced runs' USD costs, rounded to 1/10000 of a dollar. */
|
|
298
|
+
usd: number;
|
|
299
|
+
runs: number;
|
|
300
|
+
/** Runs with no USD cost: left out of `usd`, never counted as zero. */
|
|
301
|
+
unpriced: number;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* What the box's run records (ws-076) say it spent in one month (#3508,
|
|
306
|
+
* ws-102), as a report carries it: the month's totals and the same per
|
|
307
|
+
* principal the runs worked for. {@link spendFromRuns} computes it.
|
|
308
|
+
*/
|
|
309
|
+
export interface BoxSpend extends SpendFigures {
|
|
310
|
+
/** `YYYY-MM`, in UTC, matched against each run's `startedAt`. */
|
|
311
|
+
month: string;
|
|
312
|
+
/** At most {@link SPEND_LIMITS.principals}, in the order the runs first name them; `principal` null for runs that name none. */
|
|
313
|
+
byPrincipal: (SpendFigures & { principal: string | null })[];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The bounds on a reported spend. */
|
|
317
|
+
export const SPEND_LIMITS = { principals: 50, principalLength: 200, figure: 1e9 } as const;
|
|
318
|
+
|
|
319
|
+
const SPEND_MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
|
|
320
|
+
// eslint-disable-next-line no-control-regex
|
|
321
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
322
|
+
const roundUsd = (usd: number) => Math.round(usd * 1e4) / 1e4;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A box's spend in `month` (`YYYY-MM`) from the runs `chant workspace runs
|
|
326
|
+
* --json` prints (`doc.runs`): every run whose `startedAt` falls in the
|
|
327
|
+
* month, summed per the principal in its `by`. Only a USD `cost` is summed; a
|
|
328
|
+
* run with none, or with another currency, is unpriced. Null when `doc` holds
|
|
329
|
+
* no runs list.
|
|
330
|
+
*/
|
|
331
|
+
export function spendFromRuns(doc: unknown, month: string): BoxSpend | null {
|
|
332
|
+
const runs = (doc as { runs?: unknown } | null)?.runs;
|
|
333
|
+
if (!Array.isArray(runs)) return null;
|
|
334
|
+
const total: SpendFigures = { usd: 0, runs: 0, unpriced: 0 };
|
|
335
|
+
const people = new Map<string | null, SpendFigures & { principal: string | null }>();
|
|
336
|
+
for (const r of runs as { startedAt?: unknown; by?: unknown; cost?: { currency?: unknown; amount?: unknown } | null }[]) {
|
|
337
|
+
if (!String(r?.startedAt ?? "").startsWith(month)) continue;
|
|
338
|
+
const principal = typeof r.by === "string" && r.by.length > 0 ? r.by.slice(0, SPEND_LIMITS.principalLength) : null;
|
|
339
|
+
let p = people.get(principal);
|
|
340
|
+
if (!p) {
|
|
341
|
+
p = { principal, usd: 0, runs: 0, unpriced: 0 };
|
|
342
|
+
people.set(principal, p);
|
|
343
|
+
}
|
|
344
|
+
const amount = r.cost?.amount;
|
|
345
|
+
const usd = r.cost?.currency === "USD" && typeof amount === "number" && Number.isFinite(amount) && amount >= 0 ? amount : null;
|
|
346
|
+
for (const o of [total, p]) {
|
|
347
|
+
o.runs++;
|
|
348
|
+
if (usd === null) o.unpriced++;
|
|
349
|
+
else o.usd += usd;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const byPrincipal = [...people.values()].slice(0, SPEND_LIMITS.principals).map((p) => ({ ...p, usd: roundUsd(p.usd) }));
|
|
353
|
+
return { month, usd: roundUsd(total.usd), runs: total.runs, unpriced: total.unpriced, byPrincipal };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A reported listing, checked as studio's lobby checks it: undefined when none was reported, or the reason it is refused. */
|
|
357
|
+
function parseListing(raw: unknown): { listing?: BoxListing } | { error: string } {
|
|
358
|
+
if (raw === undefined || raw === null) return {};
|
|
359
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return { error: 'The listing is { "published", "title", "line" }.' };
|
|
360
|
+
const { published, title = "", line = "" } = raw as { published?: unknown; title?: unknown; line?: unknown };
|
|
361
|
+
if (typeof title !== "string" || title.length > LISTING_LIMITS.title || CONTROL.test(title)) return { error: `The listing's title is a string of at most ${LISTING_LIMITS.title} characters with no control character.` };
|
|
362
|
+
if (typeof line !== "string" || line.length > LISTING_LIMITS.line || CONTROL.test(line)) return { error: `The listing's line is a string of at most ${LISTING_LIMITS.line} characters with no control character.` };
|
|
363
|
+
if (published !== undefined && typeof published !== "boolean") return { error: "The listing's published is true or false." };
|
|
364
|
+
return { listing: { published: published !== false, title, line } };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A reported spend, checked as studio's lobby checks it: undefined when none was reported, or the reason it is refused. */
|
|
368
|
+
function parseSpend(raw: unknown): { spend?: BoxSpend } | { error: string } {
|
|
369
|
+
if (raw === undefined || raw === null) return {};
|
|
370
|
+
const bad = { error: 'The spend is { "month", "usd", "runs", "unpriced", "byPrincipal": [{ "principal", "usd", "runs", "unpriced" }] }, from chant workspace runs --json.' };
|
|
371
|
+
const money = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v < SPEND_LIMITS.figure;
|
|
372
|
+
const count = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0 && (v as number) < SPEND_LIMITS.figure;
|
|
373
|
+
const figures = (o: { usd?: unknown; runs?: unknown; unpriced?: unknown }): SpendFigures | null =>
|
|
374
|
+
money(o.usd) && count(o.runs) && count(o.unpriced) ? { usd: roundUsd(o.usd), runs: o.runs, unpriced: o.unpriced } : null;
|
|
375
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return bad;
|
|
376
|
+
const r = raw as { month?: unknown; byPrincipal?: unknown };
|
|
377
|
+
const total = figures(raw as object);
|
|
378
|
+
if (!SPEND_MONTH.test(String(r.month)) || total === null) return bad;
|
|
379
|
+
const list = r.byPrincipal ?? [];
|
|
380
|
+
if (!Array.isArray(list) || list.length > SPEND_LIMITS.principals) return bad;
|
|
381
|
+
const byPrincipal: BoxSpend["byPrincipal"] = [];
|
|
382
|
+
for (const one of list as { principal?: unknown; usd?: unknown; runs?: unknown; unpriced?: unknown }[]) {
|
|
383
|
+
if (!one || typeof one !== "object") return bad;
|
|
384
|
+
const principal = one.principal;
|
|
385
|
+
if (principal !== null && (typeof principal !== "string" || principal.length === 0 || principal.length > SPEND_LIMITS.principalLength)) return bad;
|
|
386
|
+
const f = figures(one);
|
|
387
|
+
if (f === null) return bad;
|
|
388
|
+
byPrincipal.push({ principal, ...f });
|
|
389
|
+
}
|
|
390
|
+
return { spend: { month: r.month as string, ...total, byPrincipal } };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A report's body, checked, kept to the entries that name `broker`, with its
|
|
395
|
+
* listing and spend when it carries them (#3508); or the reason it is refused
|
|
396
|
+
* with a 400. The same rules as studio's lobby (`lobby/capabilities.mjs`).
|
|
397
|
+
*/
|
|
398
|
+
export function parseDeclarationReport(body: unknown, broker: string): { capabilities: KeptCapability[]; listing?: BoxListing; spend?: BoxSpend } | { error: string } {
|
|
277
399
|
const list = (body as { capabilities?: unknown } | null)?.capabilities;
|
|
278
400
|
if (!Array.isArray(list)) return { error: 'The body is { "capabilities": [{ "name", "broker", "scope" }] }.' };
|
|
279
401
|
if (list.length > DECLARATION_LIMITS.capabilities) return { error: `At most ${DECLARATION_LIMITS.capabilities} capabilities.` };
|
|
@@ -289,7 +411,11 @@ export function parseDeclarationReport(body: unknown, broker: string): { capabil
|
|
|
289
411
|
for (const w of scope as string[]) if (!seen.scope.includes(w)) seen.scope.push(w);
|
|
290
412
|
kept.set(c.name, seen);
|
|
291
413
|
}
|
|
292
|
-
|
|
414
|
+
const listed = parseListing((body as { listing?: unknown }).listing);
|
|
415
|
+
if ("error" in listed) return listed;
|
|
416
|
+
const spent = parseSpend((body as { spend?: unknown }).spend);
|
|
417
|
+
if ("error" in spent) return spent;
|
|
418
|
+
return { capabilities: [...kept.values()], ...listed, ...spent };
|
|
293
419
|
}
|
|
294
420
|
|
|
295
421
|
/**
|
|
@@ -108,7 +108,9 @@ export async function startReferenceBroker(env: BrokerConformanceEnv): Promise<S
|
|
|
108
108
|
}
|
|
109
109
|
const parsed = parseDeclarationReport(body, env.broker);
|
|
110
110
|
if ("error" in parsed) return refuse(res, 400, parsed.error);
|
|
111
|
-
|
|
111
|
+
// It keeps the listing and spend a report carries (#3508) and answers with them, as studio's lobby does.
|
|
112
|
+
const { capabilities, listing, spend } = parsed;
|
|
113
|
+
const kept: KeptDeclaration = { capabilities, at: new Date().toISOString(), ...(listing ? { listing } : {}), ...(spend ? { spend } : {}) };
|
|
112
114
|
reports.set(box, kept);
|
|
113
115
|
return send(res, 200, { ...kept, payer });
|
|
114
116
|
}
|
|
@@ -350,6 +350,28 @@ export const BROKER_CHECKS: readonly BrokerCheck[] = [
|
|
|
350
350
|
return refusalProblems(r, [], 400);
|
|
351
351
|
},
|
|
352
352
|
},
|
|
353
|
+
{
|
|
354
|
+
id: "declaration-listing-spend",
|
|
355
|
+
capability: "declaration",
|
|
356
|
+
title: "a report that also carries the box's listing and this month's spend (#3508) is answered 200, and an answer that echoes them echoes them unchanged",
|
|
357
|
+
async run(ctx) {
|
|
358
|
+
const body = {
|
|
359
|
+
capabilities: [{ name: "inference", broker: ctx.env.broker, scope: ["agent", "decide"] }],
|
|
360
|
+
listing: { published: true, title: "Conformance", line: "a box the broker conformance suite reports" },
|
|
361
|
+
spend: { month: "2026-10", usd: 1.25, runs: 3, unpriced: 1, byPrincipal: [{ principal: "github:conformance", usd: 1.25, runs: 2, unpriced: 0 }, { principal: null, usd: 0, runs: 1, unpriced: 1 }] },
|
|
362
|
+
};
|
|
363
|
+
const problems = ctx.validate("declarationReport", body).map((p) => `the suite's own report: ${p}`);
|
|
364
|
+
const r = await ctx.call(A, "POST", BROKER_ROUTES.declaration, { body });
|
|
365
|
+
if (r.status !== 200) return [...problems, `expected 200, got ${r.status}: ${r.text.slice(0, 200)}`];
|
|
366
|
+
problems.push(...ctx.validate("declarationKept", r.json));
|
|
367
|
+
const answer = r.json as { capabilities?: { name: string }[]; listing?: unknown; spend?: unknown };
|
|
368
|
+
if (!(answer.capabilities ?? []).some((c) => c.name === "inference")) problems.push("it did not keep the report's inference entry");
|
|
369
|
+
for (const key of ["listing", "spend"] as const) {
|
|
370
|
+
if (answer[key] !== undefined && !isDeepStrictEqual(answer[key], body[key])) problems.push(`the answer's ${key} is ${JSON.stringify(answer[key])}, not the ${key} reported`);
|
|
371
|
+
}
|
|
372
|
+
return problems;
|
|
373
|
+
},
|
|
374
|
+
},
|
|
353
375
|
{
|
|
354
376
|
id: "unreported-refused",
|
|
355
377
|
capability: "declaration",
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
},
|
|
134
134
|
"agents": {
|
|
135
135
|
"type": "array",
|
|
136
|
-
"description": "The agent sessions the workspace declares (#2524 D20, #2548, ws-067). Each is bound to one member: it writes
|
|
136
|
+
"description": "The agent sessions the workspace declares (#2524 D20, #2548, ws-067, ws-101). Each is bound to the members it names, one with member or several with members: it writes the files of any of them and the records of kinds any of them or the workspace declares, as writeScope.agent allows. A session resumes from chant workspace agent <name> --json, which prints its member, its scope and the spec, read from the repository alone. A write names its session with the CHANT_AGENT environment variable, or a commit with a Chant-Agent trailer. Added in schema 1 by chant 0.101.0, so a declaration that uses it sets minReader to 0.101.0 or newer.",
|
|
137
137
|
"items": {
|
|
138
138
|
"$ref": "#/$defs/agent"
|
|
139
139
|
}
|
|
@@ -1322,7 +1322,7 @@
|
|
|
1322
1322
|
},
|
|
1323
1323
|
"agentScope": {
|
|
1324
1324
|
"type": "object",
|
|
1325
|
-
"description": "The agent class's write scope. Its members are always its session
|
|
1325
|
+
"description": "The agent class's write scope. Its members are always the members its session is bound to, so only the record kinds and the protected paths can be set.",
|
|
1326
1326
|
"properties": {
|
|
1327
1327
|
"protected": {
|
|
1328
1328
|
"type": "array",
|
|
@@ -1343,10 +1343,27 @@
|
|
|
1343
1343
|
},
|
|
1344
1344
|
"agent": {
|
|
1345
1345
|
"type": "object",
|
|
1346
|
-
"description": "An agent session (#2548).",
|
|
1346
|
+
"description": "An agent session (#2548). It names its members with exactly one of member and members (ws-101).",
|
|
1347
1347
|
"required": [
|
|
1348
|
-
"name"
|
|
1349
|
-
|
|
1348
|
+
"name"
|
|
1349
|
+
],
|
|
1350
|
+
"oneOf": [
|
|
1351
|
+
{
|
|
1352
|
+
"properties": {
|
|
1353
|
+
"member": true
|
|
1354
|
+
},
|
|
1355
|
+
"required": [
|
|
1356
|
+
"member"
|
|
1357
|
+
]
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
"properties": {
|
|
1361
|
+
"members": true
|
|
1362
|
+
},
|
|
1363
|
+
"required": [
|
|
1364
|
+
"members"
|
|
1365
|
+
]
|
|
1366
|
+
}
|
|
1350
1367
|
],
|
|
1351
1368
|
"properties": {
|
|
1352
1369
|
"name": {
|
|
@@ -1355,7 +1372,16 @@
|
|
|
1355
1372
|
},
|
|
1356
1373
|
"member": {
|
|
1357
1374
|
"$ref": "#/$defs/name",
|
|
1358
|
-
"description": "The one member the session is bound to. It must be a declared member, not an example group."
|
|
1375
|
+
"description": "The one member the session is bound to. It must be a declared member, not an example group. A session bound to several names them with members instead."
|
|
1376
|
+
},
|
|
1377
|
+
"members": {
|
|
1378
|
+
"type": "array",
|
|
1379
|
+
"minItems": 1,
|
|
1380
|
+
"uniqueItems": true,
|
|
1381
|
+
"items": {
|
|
1382
|
+
"$ref": "#/$defs/name"
|
|
1383
|
+
},
|
|
1384
|
+
"description": "The members the session is bound to, when it writes in more than one, such as a factory that builds an app and writes its evidence records in a design member (ws-101, arugula-salad/studio#402). Each must be a declared member, not an example group. The session may write the files of any of them and the records of kinds any of them or the workspace declares, and the write scope is judged against all of them together. Added in schema 1 by chant 0.105.0, so a declaration that uses it sets minReader to 0.105.0 or newer."
|
|
1359
1385
|
},
|
|
1360
1386
|
"principals": {
|
|
1361
1387
|
"type": "array",
|