@miadi/connector 0.1.13 → 0.1.14
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/README.md +9 -1
- package/dist/approve.d.ts +8 -1
- package/dist/approve.js +39 -7
- package/dist/decisions.d.ts +4 -2
- package/dist/decisions.js +18 -9
- package/dist/mission-status.js +2 -1
- package/dist/queue-state.js +2 -2
- package/dist/records.d.ts +13 -4
- package/dist/records.js +11 -3
- package/dist/request-state.d.ts +9 -1
- package/dist/request-state.js +37 -1
- package/dist/revisions.d.ts +110 -0
- package/dist/revisions.js +245 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +197 -11
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,14 @@ The model that asked cannot approve its own request.
|
|
|
42
42
|
|
|
43
43
|
`get_run_result` returns one `projection` of the decision, dispatch/completion state, receipt link and outstanding instructions. The in-chat card, approval page and Miadi's research page use that same read model. Asking `show_decisions` for an already-decided `record_id` shows its receipt without issuing another decision token. An in-flight card can refresh status without repeating the operation. A completed basic review with unapplied approval instructions remains open as **changes outstanding**, not falsely finished.
|
|
44
44
|
|
|
45
|
+
### Revising a mission before it is decided
|
|
46
|
+
|
|
47
|
+
A mission is revised, never re-requested (jgwill/Miadi#919). `revise_pending_request` changes an agent run or work proposal that still awaits a decision, or that its approver sent back, and gives it its next version. Pass the version you read as `expected_version` and only the fields that change. The request keeps its id, its approval page and its one card. A change of `provider` is verified on the host again. Each revision is a record in the ledger carrying the whole new content, its reason, author, changed fields and `sha256` content hash, so every earlier version stays readable verbatim with `get_request_history`. A request already approved, queued or declined is refused: a change after a decision is a new request or a steer.
|
|
48
|
+
|
|
49
|
+
A decision names the version it decides. The approval page carries the version it shows, and each card's decision token is bound to its version. A page or card that showed an earlier version is refused as `stale_version`, so no brief is queued for a version the person did not read. The receipt and the brief (`version="N"`, and the hash) name the version approved.
|
|
50
|
+
|
|
51
|
+
Requests made before this, one new request per revision, are joined with `supersede_pending_requests`: name the request that stands and the exact ids of the earlier ones. Each earlier request must still await a decision and be asked by the same person. It is retired (`Superseded by a later version`: it can no longer be approved, and its page says which request to decide) and becomes an earlier version of the standing request. All are checked before anything is written, and a request already approved or queued is refused, never cancelled.
|
|
52
|
+
|
|
45
53
|
### Following and steering a mission
|
|
46
54
|
|
|
47
55
|
`mission_status` joins one mission read-only: its source request and decision, the sessions bound to it (each with how it is bound — a signed `session:` ref, the delivered brief's target, or the requested name), its issues and PRs, its steering grant, and where it stands stage by stage — approved, queued, delivered, running, merged, released, closed — each by its evidence, so an idle pane or a merge is never read as completion. Live panes and Miadi's session view are joined only for the write tier.
|
|
@@ -60,7 +68,7 @@ In a client that renders MCP Apps (ChatGPT among them), `show_decisions` draws o
|
|
|
60
68
|
|
|
61
69
|
The model that asked cannot decide for the person:
|
|
62
70
|
- `decide_request` is declared for the card only (`_meta.ui.visibility: ["app"]`), so the host does not offer it to the model.
|
|
63
|
-
- It requires a decision token that travels only in the `_meta` of the `show_decisions` result. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request, one person and one OAuth client, and expires after 30 minutes. A decided request no longer awaits a decision, so a token works once.
|
|
71
|
+
- It requires a decision token that travels only in the `_meta` of the `show_decisions` result. Hosts deliver that metadata to the card and keep it from the model. The token is bound to one request at the version its card shows, one person and one OAuth client, and expires after 30 minutes. A decided request no longer awaits a decision, so a token works once.
|
|
64
72
|
- The sign-in must carry `miadi:approve`. A person grants it by ticking "Let me approve requests on cards inside this client" on the sign-in page. Only a person with Miadi's write tier receives it, only for a client that sends people back to chatgpt.com, and it ends when Miadi no longer gives them the write tier. A person token used directly as Bearer never carries it.
|
|
65
73
|
|
|
66
74
|
The approval page itself (`/approve/<record>`) names only the kind of request until someone signs in. A write-tier person then reads and decides it, and whoever asked can read it.
|
package/dist/approve.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface ApprovalDeps {
|
|
|
24
24
|
miadi: MiadiClient;
|
|
25
25
|
identify: (token: string) => Promise<MiadiPerson | null>;
|
|
26
26
|
fetchImpl?: typeof fetch;
|
|
27
|
-
/** The factory directory holding queue/ (default
|
|
27
|
+
/** The factory directory holding queue/ (default /srv/miadi/factory). */
|
|
28
28
|
factoryDir?: string;
|
|
29
29
|
tmuxSessions?: () => string[];
|
|
30
30
|
/** The steering surface (jgwill/Miadi#893); tests inject a fake, production uses tmux. */
|
|
@@ -57,12 +57,19 @@ export declare function executeApproved(record: ConnectorRecord, approver: Miadi
|
|
|
57
57
|
/** A request is decided once: a second decision while one runs, or after it, is refused. */
|
|
58
58
|
export declare class AlreadyDecided extends Error {
|
|
59
59
|
}
|
|
60
|
+
/** A decision on a version the request no longer holds: it was revised after the page or card was read (#919). */
|
|
61
|
+
export declare class StaleVersion extends AlreadyDecided {
|
|
62
|
+
readonly currentVersion: number;
|
|
63
|
+
readonly decidedVersion: number;
|
|
64
|
+
constructor(currentVersion: number, decidedVersion: number);
|
|
65
|
+
}
|
|
60
66
|
/** Whether a decision on this request is being recorded now, its run may still be going. */
|
|
61
67
|
export declare function isBeingDecided(recordId: string): boolean;
|
|
62
68
|
export declare function decide(r: ConnectorRecord, person: MiadiPerson, choice: Decision, deps: ApprovalDeps, opts: {
|
|
63
69
|
instructions?: string;
|
|
64
70
|
note?: string;
|
|
65
71
|
via: string;
|
|
72
|
+
expectedVersion?: number;
|
|
66
73
|
}): Promise<{
|
|
67
74
|
decision: NonNullable<ConnectorRecord["approval_state"]>;
|
|
68
75
|
state: ConnectorRecord["state"];
|
package/dist/approve.js
CHANGED
|
@@ -20,6 +20,7 @@ import { current, newRecordId } from "./records.js";
|
|
|
20
20
|
import { APPROVABLE, projectRequest } from "./request-state.js";
|
|
21
21
|
import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
22
22
|
import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
|
|
23
|
+
import { contentHash, versionOf } from "./revisions.js";
|
|
23
24
|
export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
24
25
|
/**
|
|
25
26
|
* What a person may decide on a request. Approving with changes runs it with the
|
|
@@ -67,21 +68,23 @@ export function runBrief(record, approver, sessions, instructions) {
|
|
|
67
68
|
const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
|
|
68
69
|
const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
|
|
69
70
|
const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
|
|
70
|
-
|
|
71
|
+
// A new session opens where the request says, else where the factory runner's config says (defaultCwd): no host path here (#911).
|
|
72
|
+
const cwd = body.cwd;
|
|
71
73
|
const open = [
|
|
72
74
|
`to="${attr(to)}"`,
|
|
73
75
|
'status="send"',
|
|
74
76
|
'type="connector-run"',
|
|
75
77
|
`record="${attr(record.id)}"`,
|
|
78
|
+
`version="${versionOf(record)}"`,
|
|
76
79
|
// The provider the connector verified at request time (jgwill/Miadi#893); the intake may honour it or record why not.
|
|
77
80
|
...(body.resolved_provider ? [`provider="${attr(body.resolved_provider)}"`] : []),
|
|
78
|
-
...(to.startsWith("new:") ? [`cwd="${attr(cwd)}"`] : []),
|
|
81
|
+
...(to.startsWith("new:") && cwd ? [`cwd="${attr(cwd)}"`] : []),
|
|
79
82
|
];
|
|
80
83
|
const lines = [
|
|
81
84
|
`<input ${open.join(" ")}>`,
|
|
82
85
|
`A bounded task requested through the Miadi connector (jgwill/Miadi#815, #816).`,
|
|
83
86
|
"",
|
|
84
|
-
`- Record: ${text(record.id)}`,
|
|
87
|
+
`- Record: ${text(record.id)}, version ${versionOf(record)} (${contentHash(record)})`,
|
|
85
88
|
`- Requested by: ${text(record.actor.name)}${record.actor.client_name ? ` through ${text(record.actor.client_name)}` : ""}, ${text(record.created_at)}`,
|
|
86
89
|
`- Approved by: ${text(approver.name)}, ${new Date().toISOString()}`,
|
|
87
90
|
...(record.circle ? [`- Accountable circle: ${text(record.circle)}`] : []),
|
|
@@ -106,7 +109,7 @@ export function runBrief(record, approver, sessions, instructions) {
|
|
|
106
109
|
"",
|
|
107
110
|
"When it is done, reply with what was achieved, the evidence (commits, paths, URLs), what is still open, and whether this session should end. Then record the receipt through the connector's update_handoff if a handoff names this record, and end the session per its lifecycle.",
|
|
108
111
|
"",
|
|
109
|
-
"Queued by the Miadi connector's approval door, delivered by the factory
|
|
112
|
+
"Queued by the Miadi connector's approval door, delivered by the factory runner (@miadi/factory-runner).",
|
|
110
113
|
"</input>",
|
|
111
114
|
];
|
|
112
115
|
return { to, brief: `${lines.join("\n")}\n` };
|
|
@@ -267,6 +270,7 @@ ${r.circle ? `<dt>Circle</dt><dd>${escapeHtml(r.circle)}</dd>` : ""}
|
|
|
267
270
|
${r.intention ? `<dt>Intention</dt><dd>${escapeHtml(r.intention)}</dd>` : ""}
|
|
268
271
|
${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}</dd>` : ""}
|
|
269
272
|
<dt>State</dt><dd>${escapeHtml(r.approval_state ?? "—")}</dd>
|
|
273
|
+
${versionOf(r) > 1 ? `<dt>Version</dt><dd>${versionOf(r)}, the one a decision here decides. Earlier versions stay in the ledger.</dd>` : ""}
|
|
270
274
|
</dl>
|
|
271
275
|
<pre>${escapeHtml(JSON.stringify(shown, null, 2))}</pre>`;
|
|
272
276
|
}
|
|
@@ -279,6 +283,16 @@ ${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}
|
|
|
279
283
|
/** A request is decided once: a second decision while one runs, or after it, is refused. */
|
|
280
284
|
export class AlreadyDecided extends Error {
|
|
281
285
|
}
|
|
286
|
+
/** A decision on a version the request no longer holds: it was revised after the page or card was read (#919). */
|
|
287
|
+
export class StaleVersion extends AlreadyDecided {
|
|
288
|
+
currentVersion;
|
|
289
|
+
decidedVersion;
|
|
290
|
+
constructor(currentVersion, decidedVersion) {
|
|
291
|
+
super(`This request was revised to version ${currentVersion} after version ${decidedVersion} was shown. Read version ${currentVersion} and decide again.`);
|
|
292
|
+
this.currentVersion = currentVersion;
|
|
293
|
+
this.decidedVersion = decidedVersion;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
282
296
|
const deciding = new Set();
|
|
283
297
|
/** Whether a decision on this request is being recorded now, its run may still be going. */
|
|
284
298
|
export function isBeingDecided(recordId) {
|
|
@@ -286,11 +300,17 @@ export function isBeingDecided(recordId) {
|
|
|
286
300
|
}
|
|
287
301
|
export async function decide(r, person, choice, deps, opts) {
|
|
288
302
|
// Checked again here, with no await between the check and the claim: the
|
|
289
|
-
// page and the card (or two cards) may decide the same request at once
|
|
303
|
+
// page and the card (or two cards) may decide the same request at once, and
|
|
304
|
+
// a revision may land between reading a version and deciding it (#919).
|
|
290
305
|
const live = current(deps.ledger.all()).find((x) => x.id === r.id);
|
|
291
306
|
if (!live || live.approval_state !== "awaiting_approval" || deciding.has(r.id)) {
|
|
292
307
|
throw new AlreadyDecided(deciding.has(r.id) ? "A decision on this request is being recorded." : `This request is already ${live?.approval_state ?? "gone"}.`);
|
|
293
308
|
}
|
|
309
|
+
// A decision that names no version (a page opened before versions, a surface that sends none) decides version 1:
|
|
310
|
+
// a request revised since is refused, never decided blind (#919 review F1).
|
|
311
|
+
const decidedVersion = opts.expectedVersion ?? 1;
|
|
312
|
+
if (versionOf(live) !== decidedVersion)
|
|
313
|
+
throw new StaleVersion(versionOf(live), decidedVersion);
|
|
294
314
|
deciding.add(r.id);
|
|
295
315
|
try {
|
|
296
316
|
return await decideOnce(live, person, choice, deps, opts);
|
|
@@ -368,6 +388,9 @@ async function decideOnce(r, person, choice, deps, opts) {
|
|
|
368
388
|
decided_by: { person_id: person.id, name: person.name },
|
|
369
389
|
decided_at: new Date().toISOString(),
|
|
370
390
|
decision: choice,
|
|
391
|
+
// The exact version decided, so the brief, the receipt and the history name the same content (#919).
|
|
392
|
+
version: versionOf(r),
|
|
393
|
+
content_hash: contentHash(r),
|
|
371
394
|
via: opts.via,
|
|
372
395
|
...(instructions ? { instructions } : {}),
|
|
373
396
|
note: opts.note,
|
|
@@ -397,7 +420,7 @@ async function decideOnce(r, person, choice, deps, opts) {
|
|
|
397
420
|
supersedes: r.id,
|
|
398
421
|
approval_state: decision,
|
|
399
422
|
state,
|
|
400
|
-
...(decision === "revision_requested" ? { next_action: `Revise
|
|
423
|
+
...(decision === "revision_requested" ? { next_action: `Revise it in place with revise_pending_request (request_id ${r.id}, expected_version ${versionOf(r)}).` } : {}),
|
|
401
424
|
completion_receipt: receipt,
|
|
402
425
|
body: { decision: choice, ...(instructions ? { instructions } : {}) },
|
|
403
426
|
});
|
|
@@ -493,9 +516,17 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
493
516
|
}
|
|
494
517
|
let decided;
|
|
495
518
|
try {
|
|
496
|
-
|
|
519
|
+
const shown = Number(body.version);
|
|
520
|
+
decided = await decide(r, person, choice, deps, {
|
|
521
|
+
instructions,
|
|
522
|
+
note: body.note || undefined,
|
|
523
|
+
via: String(body.via ?? "approval-page").slice(0, 60),
|
|
524
|
+
...(Number.isInteger(shown) && shown >= 1 ? { expectedVersion: shown } : {}),
|
|
525
|
+
});
|
|
497
526
|
}
|
|
498
527
|
catch (error) {
|
|
528
|
+
if (error instanceof StaleVersion)
|
|
529
|
+
return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, error: error.message }));
|
|
499
530
|
if (error instanceof AlreadyDecided)
|
|
500
531
|
return void res.status(409).type("html").send(approvePage(deps, find(r.id), { error: error.message }));
|
|
501
532
|
throw error;
|
|
@@ -596,6 +627,7 @@ ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
|
|
|
596
627
|
${actionable && o.token
|
|
597
628
|
? `<form method="post">
|
|
598
629
|
<input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
|
|
630
|
+
<input type="hidden" name="version" value="${versionOf(r)}">
|
|
599
631
|
<label for="i">Instructions (needed to approve with changes or to send back)</label>
|
|
600
632
|
<textarea id="i" name="instructions" rows="4"></textarea>
|
|
601
633
|
<div class="row">
|
package/dist/decisions.d.ts
CHANGED
|
@@ -6,8 +6,10 @@ export declare class DecisionTokens {
|
|
|
6
6
|
private readonly secret;
|
|
7
7
|
constructor(stateDir: string);
|
|
8
8
|
private sign;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** A token for one card: one request, at the version the card shows, for one person in one client. */
|
|
10
|
+
issue(recordId: string, personId: string, clientId: string, now?: number, version?: number): string;
|
|
11
|
+
/** `stale`: the token is genuine but was minted for a version the request no longer holds. */
|
|
12
|
+
verify(token: string, recordId: string, personId: string, clientId: string, now?: number, currentVersion?: number): "ok" | "expired" | "invalid" | "stale";
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* The card. Self-contained: no network, no external script. It reads the
|
package/dist/decisions.js
CHANGED
|
@@ -30,23 +30,32 @@ export class DecisionTokens {
|
|
|
30
30
|
writeFileSync(file, newSecret(32), { mode: 0o600, flag: "wx" });
|
|
31
31
|
this.secret = readFileSync(file, "utf8").trim();
|
|
32
32
|
}
|
|
33
|
-
sign(recordId, personId, clientId, exp) {
|
|
34
|
-
|
|
33
|
+
sign(recordId, personId, clientId, exp, version) {
|
|
34
|
+
// A token is bound to the version the card showed (jgwill/Miadi#919); one minted before versions has none.
|
|
35
|
+
const v = version === undefined ? "" : `\nv${version}`;
|
|
36
|
+
return createHmac("sha256", this.secret).update(`${recordId}\n${personId}\n${clientId}\n${exp}${v}`).digest("base64url");
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
/** A token for one card: one request, at the version the card shows, for one person in one client. */
|
|
39
|
+
issue(recordId, personId, clientId, now = Math.floor(Date.now() / 1000), version = 1) {
|
|
37
40
|
const exp = now + DECISION_TOKEN_TTL;
|
|
38
|
-
return `${exp}.${this.sign(recordId, personId, clientId, exp)}`;
|
|
41
|
+
return `${exp}.v${version}.${this.sign(recordId, personId, clientId, exp, version)}`;
|
|
39
42
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
+
/** `stale`: the token is genuine but was minted for a version the request no longer holds. */
|
|
44
|
+
verify(token, recordId, personId, clientId, now = Math.floor(Date.now() / 1000), currentVersion = 1) {
|
|
45
|
+
const parts = String(token ?? "").split(".");
|
|
46
|
+
const exp = Number(parts[0]);
|
|
47
|
+
const versioned = parts.length === 3 && /^v\d+$/.test(parts[1]);
|
|
48
|
+
const version = versioned ? Number(parts[1].slice(1)) : undefined;
|
|
49
|
+
const mac = versioned ? parts[2] : parts.length === 2 ? parts[1] : "";
|
|
43
50
|
if (!Number.isInteger(exp) || !mac)
|
|
44
51
|
return "invalid";
|
|
45
|
-
const expected = Buffer.from(this.sign(recordId, personId, clientId, exp));
|
|
52
|
+
const expected = Buffer.from(this.sign(recordId, personId, clientId, exp, version));
|
|
46
53
|
const given = Buffer.from(mac);
|
|
47
54
|
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
48
55
|
return "invalid";
|
|
49
|
-
|
|
56
|
+
if (exp < now)
|
|
57
|
+
return "expired";
|
|
58
|
+
return (version ?? 1) === currentVersion ? "ok" : "stale";
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
/**
|
package/dist/mission-status.js
CHANGED
|
@@ -34,7 +34,8 @@ export function resolveMissionSubject(ref, records) {
|
|
|
34
34
|
const exact = records.find((r) => r.id === ref);
|
|
35
35
|
if (exact)
|
|
36
36
|
return { record: exact, via: "record_id", resolution: "exact", candidates: [] };
|
|
37
|
-
|
|
37
|
+
// A request retired as an earlier version of another (jgwill/Miadi#919) is not a mission of its own.
|
|
38
|
+
const missions = records.filter((r) => MISSION_KINDS.has(r.kind) && r.completion_receipt?.decision !== "superseded").sort(newestFirst);
|
|
38
39
|
if (SESSION_REF.test(ref)) {
|
|
39
40
|
const citing = missions.filter((r) => r.refs.includes(ref));
|
|
40
41
|
if (!citing.length)
|
package/dist/queue-state.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Shared, read-only factory queue evidence for every request-state surface. */
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
export function factoryDir(env = process.env) {
|
|
6
|
-
|
|
5
|
+
// The factory's data folder, beside the connector's own /srv/miadi/connector: never a home directory (jgwill/Miadi#911).
|
|
6
|
+
return env.MIADI_FACTORY_DIR || "/srv/miadi/factory";
|
|
7
7
|
}
|
|
8
8
|
export function runBriefFile(record) {
|
|
9
9
|
return `connector-${record.id.replace(/[^\w-]/g, "-")}.md`;
|
package/dist/records.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RequestLineage, RequestRevision } from "./revisions.js";
|
|
1
2
|
export declare const CIRCLES: readonly [{
|
|
2
3
|
readonly id: "story";
|
|
3
4
|
readonly glyph: "📖";
|
|
@@ -81,13 +82,21 @@ export interface ConnectorRecord {
|
|
|
81
82
|
supersedes?: string;
|
|
82
83
|
idempotency_key?: string;
|
|
83
84
|
completion_receipt?: Record<string, unknown>;
|
|
85
|
+
/** A revision of a waiting request: this record carries its whole new content (jgwill/Miadi#919). */
|
|
86
|
+
revision?: RequestRevision;
|
|
87
|
+
/** The requests a waiting request adopted as its earlier versions (jgwill/Miadi#919). */
|
|
88
|
+
lineage?: RequestLineage;
|
|
89
|
+
/** Folded only: the version the request is at, counted across its lineage. Absent means 1. */
|
|
90
|
+
version?: number;
|
|
84
91
|
}
|
|
85
92
|
export declare function newRecordId(kind: RecordKind): string;
|
|
86
|
-
/**
|
|
87
|
-
* Fold the ledger: a record superseded by a later one shows the later one's
|
|
88
|
-
* state, approval and receipt, keeping its own id and history.
|
|
89
|
-
*/
|
|
90
93
|
export { projectRequest, APPROVABLE_KINDS, APPROVABLE } from "./request-state.js";
|
|
91
94
|
export { readQueueState, runBriefFile } from "./queue-state.js";
|
|
92
95
|
export type { RequestProjection, RequestStage, RequestStanding, RequestDecision, ProjectionContext } from "./request-state.js";
|
|
96
|
+
/**
|
|
97
|
+
* Fold the ledger: a record superseded by a later one shows the later one's
|
|
98
|
+
* state, approval and receipt, keeping its own id and history. A revision also
|
|
99
|
+
* replaces the content (intention, circle, refs, body) and moves the version;
|
|
100
|
+
* a lineage adds the versions of the requests it adopted (jgwill/Miadi#919).
|
|
101
|
+
*/
|
|
93
102
|
export declare function current(records: ConnectorRecord[]): ConnectorRecord[];
|
package/dist/records.js
CHANGED
|
@@ -73,19 +73,27 @@ export const APPROVAL_STATES = ["awaiting_approval", "approved", "declined", "re
|
|
|
73
73
|
export function newRecordId(kind) {
|
|
74
74
|
return `${kind}:${randomUUID()}`;
|
|
75
75
|
}
|
|
76
|
+
export { projectRequest, APPROVABLE_KINDS, APPROVABLE } from "./request-state.js";
|
|
77
|
+
export { readQueueState, runBriefFile } from "./queue-state.js";
|
|
76
78
|
/**
|
|
77
79
|
* Fold the ledger: a record superseded by a later one shows the later one's
|
|
78
|
-
* state, approval and receipt, keeping its own id and history.
|
|
80
|
+
* state, approval and receipt, keeping its own id and history. A revision also
|
|
81
|
+
* replaces the content (intention, circle, refs, body) and moves the version;
|
|
82
|
+
* a lineage adds the versions of the requests it adopted (jgwill/Miadi#919).
|
|
79
83
|
*/
|
|
80
|
-
export { projectRequest, APPROVABLE_KINDS, APPROVABLE } from "./request-state.js";
|
|
81
|
-
export { readQueueState, runBriefFile } from "./queue-state.js";
|
|
82
84
|
export function current(records) {
|
|
83
85
|
const byId = new Map();
|
|
84
86
|
for (const r of records) {
|
|
85
87
|
if (r.supersedes && byId.has(r.supersedes)) {
|
|
86
88
|
const base = byId.get(r.supersedes);
|
|
89
|
+
const adopted = r.lineage ? r.lineage.adopted.reduce((n, a) => n + (byId.get(a)?.version ?? 1), 0) : 0;
|
|
87
90
|
byId.set(r.supersedes, {
|
|
88
91
|
...base,
|
|
92
|
+
...(r.revision
|
|
93
|
+
? { intention: r.intention, circle: r.circle ?? base.circle, refs: r.refs, body: r.body, version: r.revision.version }
|
|
94
|
+
: r.lineage
|
|
95
|
+
? { version: (base.version ?? 1) + adopted }
|
|
96
|
+
: {}),
|
|
89
97
|
state: r.state ?? base.state,
|
|
90
98
|
approval_state: r.approval_state ?? base.approval_state,
|
|
91
99
|
next_action: r.next_action ?? base.next_action,
|
package/dist/request-state.d.ts
CHANGED
|
@@ -54,6 +54,10 @@ export interface RequestProjection {
|
|
|
54
54
|
outstanding: string[];
|
|
55
55
|
/** Every sentence a surface shows under the label, names and instructions included. */
|
|
56
56
|
lines: string[];
|
|
57
|
+
/** The version the request is at (jgwill/Miadi#919); a decision names it. 1 for a request never revised. */
|
|
58
|
+
version: number;
|
|
59
|
+
/** A request retired as an earlier version of another: the request to decide instead. */
|
|
60
|
+
superseded_by?: string;
|
|
57
61
|
}
|
|
58
62
|
export interface ProjectionContext {
|
|
59
63
|
/** The review service's public address, for receipt_url. Default MIADI_REVIEW_BASE_URL, else the public service. */
|
|
@@ -65,5 +69,9 @@ export interface ProjectionContext {
|
|
|
65
69
|
/** A decision on this request is being recorded now (its run may still be going). */
|
|
66
70
|
recording?: boolean;
|
|
67
71
|
}
|
|
68
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* The projection of an approvable request, or null for a record that is not one. A
|
|
74
|
+
* request retired as an earlier version of another reads as superseded; a revised one
|
|
75
|
+
* says its version, and a request sent back says which version answers it (#919).
|
|
76
|
+
*/
|
|
69
77
|
export declare function projectRequest(record: ConnectorRecord, ctx?: ProjectionContext): RequestProjection | null;
|
package/dist/request-state.js
CHANGED
|
@@ -17,6 +17,7 @@ const CHOICE_WORDS = {
|
|
|
17
17
|
request_revision: "Sent back for revision",
|
|
18
18
|
decline: "Declined",
|
|
19
19
|
covered_by_grant: "Covered by the approved run's steering grant",
|
|
20
|
+
superseded: "Superseded by a later version",
|
|
20
21
|
};
|
|
21
22
|
const OUTCOME_WORDS = {
|
|
22
23
|
queued: "queued for the factory intake",
|
|
@@ -37,8 +38,43 @@ function reviewBaseOf(ctx) {
|
|
|
37
38
|
const base = ctx.reviewBase ?? process.env.MIADI_REVIEW_BASE_URL ?? "https://miadi-review-service.vercel.app";
|
|
38
39
|
return base.replace(/\/$/, "");
|
|
39
40
|
}
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* The projection of an approvable request, or null for a record that is not one. A
|
|
43
|
+
* request retired as an earlier version of another reads as superseded; a revised one
|
|
44
|
+
* says its version, and a request sent back says which version answers it (#919).
|
|
45
|
+
*/
|
|
41
46
|
export function projectRequest(record, ctx = {}) {
|
|
47
|
+
const p = projectCore(record, ctx);
|
|
48
|
+
if (!p)
|
|
49
|
+
return null;
|
|
50
|
+
const version = typeof record.version === "number" && record.version >= 1 ? record.version : 1;
|
|
51
|
+
const receipt = (record.completion_receipt ?? {});
|
|
52
|
+
if (record.approval_state === "declined" && receipt.decision === "superseded") {
|
|
53
|
+
const by = text(receipt.superseded_by);
|
|
54
|
+
const who = text(receipt.decided_by?.name) || "someone";
|
|
55
|
+
const when = text(receipt.decided_at) ? ` at ${text(receipt.decided_at)}` : "";
|
|
56
|
+
const why = text(receipt.reason) ? `: ${text(receipt.reason)}` : "";
|
|
57
|
+
return {
|
|
58
|
+
...p,
|
|
59
|
+
version,
|
|
60
|
+
label: "Superseded by a later version",
|
|
61
|
+
decision: p.decision ? { ...p.decision, choice: "superseded" } : p.decision,
|
|
62
|
+
...(by ? { superseded_by: by } : {}),
|
|
63
|
+
lines: [`Superseded by ${by || "a later request"}, as ${who} asked${when}${why}.`, `Nothing runs from this version. Decide ${by || "the later request"} instead.`],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const lines = [...p.lines];
|
|
67
|
+
if (record.approval_state === "awaiting_approval" && receipt.decision === "request_revision" && receipt.answered_by_version) {
|
|
68
|
+
const who = text(receipt.decided_by?.name) || "someone";
|
|
69
|
+
const asked = text(receipt.instructions).replace(/[.\s]+$/, "");
|
|
70
|
+
const answered = Number(receipt.answered_by_version);
|
|
71
|
+
lines.push(`Sent back for revision by ${who}${asked ? `: ${asked}` : ""}. Version ${answered} ${answered === version ? "answers" : "answered"} it.`);
|
|
72
|
+
}
|
|
73
|
+
if (version > 1)
|
|
74
|
+
lines.unshift(`Version ${version}; the earlier versions stay readable (get_request_history).`);
|
|
75
|
+
return { ...p, version, lines };
|
|
76
|
+
}
|
|
77
|
+
function projectCore(record, ctx = {}) {
|
|
42
78
|
if (!APPROVABLE.has(record.kind))
|
|
43
79
|
return null;
|
|
44
80
|
const receipt = (record.completion_receipt ?? {});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ConnectorRecord } from "./records.js";
|
|
2
|
+
/** The kinds that open a session when approved, and the only ones revised in place. */
|
|
3
|
+
export declare const REVISABLE_KINDS: ReadonlySet<string>;
|
|
4
|
+
/** A request is revised only before a decision runs it: waiting, or sent back by its approver. */
|
|
5
|
+
export declare const REVISABLE_STATES: ReadonlySet<string>;
|
|
6
|
+
/** What a revision record carries beside the new content. */
|
|
7
|
+
export interface RequestRevision {
|
|
8
|
+
/** The version this record makes current, counted across the lineage (2, 3, …). */
|
|
9
|
+
version: number;
|
|
10
|
+
previous_version: number;
|
|
11
|
+
reason: string;
|
|
12
|
+
/** The fields that differ from the previous version. */
|
|
13
|
+
changed: string[];
|
|
14
|
+
content_hash: string;
|
|
15
|
+
previous_hash: string;
|
|
16
|
+
author: {
|
|
17
|
+
person_id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
};
|
|
20
|
+
at: string;
|
|
21
|
+
}
|
|
22
|
+
/** What a lineage record carries: the requests the standing one adopted as its earlier versions. */
|
|
23
|
+
export interface RequestLineage {
|
|
24
|
+
adopted: string[];
|
|
25
|
+
reason: string;
|
|
26
|
+
author: {
|
|
27
|
+
person_id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
};
|
|
30
|
+
at: string;
|
|
31
|
+
}
|
|
32
|
+
/** The fields that make a request's content: what a version is. */
|
|
33
|
+
export declare const CONTENT_FIELDS: readonly ["intention", "circle", "refs", "body"];
|
|
34
|
+
/** The content of a request version, as the hash and the history show it. */
|
|
35
|
+
export declare function contentOf(r: Pick<ConnectorRecord, "kind" | "intention" | "circle" | "refs" | "body">): Record<string, unknown>;
|
|
36
|
+
/** sha256 of the canonical JSON of a version's content. */
|
|
37
|
+
export declare function contentHash(r: Pick<ConnectorRecord, "kind" | "intention" | "circle" | "refs" | "body">): string;
|
|
38
|
+
/** The version a folded request is at: 1 for a request never revised. */
|
|
39
|
+
export declare function versionOf(r: Pick<ConnectorRecord, "version"> | undefined): number;
|
|
40
|
+
/** The names of the content fields (body fields by name) that differ between two versions. */
|
|
41
|
+
export declare function changedFields(before: Record<string, unknown>, after: Record<string, unknown>): string[];
|
|
42
|
+
/** One version of a request, as the history returns it. */
|
|
43
|
+
export interface RequestVersion {
|
|
44
|
+
version: number;
|
|
45
|
+
/** The record whose content this version is: the request itself, a revision record, or an adopted request. */
|
|
46
|
+
record_id: string;
|
|
47
|
+
/** The request this version belongs to when it is not the standing one (an adopted, retired request). */
|
|
48
|
+
request_id: string;
|
|
49
|
+
at: string;
|
|
50
|
+
author: string;
|
|
51
|
+
reason?: string;
|
|
52
|
+
changed?: string[];
|
|
53
|
+
content_hash: string;
|
|
54
|
+
content: Record<string, unknown>;
|
|
55
|
+
current: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Every version of a request, oldest first, numbered across its lineage: the requests
|
|
59
|
+
* it adopted (each with its own versions) and then its own. `raw` is the unfolded ledger,
|
|
60
|
+
* `folded` the folded one (`current()`).
|
|
61
|
+
*/
|
|
62
|
+
export declare function requestHistory(id: string, raw: ConnectorRecord[], folded?: ConnectorRecord[]): RequestVersion[];
|
|
63
|
+
/** A revision refused, with the code a tool answers. */
|
|
64
|
+
export declare class RevisionRefused extends Error {
|
|
65
|
+
readonly code: "not_found" | "not_revisable" | "forbidden" | "stale_version" | "being_decided" | "no_change" | "invalid_request";
|
|
66
|
+
readonly detail?: Record<string, unknown> | undefined;
|
|
67
|
+
constructor(code: "not_found" | "not_revisable" | "forbidden" | "stale_version" | "being_decided" | "no_change" | "invalid_request", message: string, detail?: Record<string, unknown> | undefined);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The new record that revises a waiting request, or a refusal. `next` holds the new
|
|
71
|
+
* content (kind, intention, circle, refs, body); `live` is the folded request.
|
|
72
|
+
*/
|
|
73
|
+
export declare function revisionRecord(live: ConnectorRecord | undefined, next: {
|
|
74
|
+
intention?: string;
|
|
75
|
+
circle?: ConnectorRecord["circle"];
|
|
76
|
+
refs: string[];
|
|
77
|
+
body: Record<string, unknown>;
|
|
78
|
+
}, o: {
|
|
79
|
+
id: string;
|
|
80
|
+
person: {
|
|
81
|
+
id: string;
|
|
82
|
+
name: string;
|
|
83
|
+
writer: boolean;
|
|
84
|
+
};
|
|
85
|
+
actor: ConnectorRecord["actor"];
|
|
86
|
+
expectedVersion: number;
|
|
87
|
+
reason: string;
|
|
88
|
+
beingDecided: boolean;
|
|
89
|
+
idempotency_key?: string;
|
|
90
|
+
now?: string;
|
|
91
|
+
}): ConnectorRecord;
|
|
92
|
+
/**
|
|
93
|
+
* The records that make `standingId` the one request of a lineage: one retirement for
|
|
94
|
+
* each named request still waiting, and one lineage record on the standing request.
|
|
95
|
+
* Every named request is checked before anything is written; one refusal writes none.
|
|
96
|
+
* Only a request named by its exact id is retired, never one that looks similar, and a
|
|
97
|
+
* request already decided (approved, queued, declined) is refused, never cancelled.
|
|
98
|
+
*/
|
|
99
|
+
export declare function supersedeRecords(folded: ConnectorRecord[], standingId: string, retire: string[], o: {
|
|
100
|
+
person: {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
writer: boolean;
|
|
104
|
+
};
|
|
105
|
+
actor: ConnectorRecord["actor"];
|
|
106
|
+
reason: string;
|
|
107
|
+
beingDecided: (id: string) => boolean;
|
|
108
|
+
newId: (kind: ConnectorRecord["kind"]) => string;
|
|
109
|
+
now?: string;
|
|
110
|
+
}): ConnectorRecord[];
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One mission, many versions, one decision (jgwill/Miadi#919).
|
|
3
|
+
*
|
|
4
|
+
* A request that still waits for a decision is revised in place: a revision is a
|
|
5
|
+
* record appended to the ledger that supersedes the request and carries the whole
|
|
6
|
+
* new content, so the request keeps its id, its approval page and its card, and
|
|
7
|
+
* every earlier version stays readable verbatim. Requests made separately before
|
|
8
|
+
* this existed (one new request per revision) are joined by `supersedePending`: the
|
|
9
|
+
* request that stands adopts the others as its earlier versions, and each adopted
|
|
10
|
+
* one is retired, so only the standing request can be approved.
|
|
11
|
+
*
|
|
12
|
+
* A decision names the version it decides. The approval page and the card carry it,
|
|
13
|
+
* and a decision on any other version is refused as stale, so no brief is ever
|
|
14
|
+
* queued for a version the person did not read.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
/** The kinds that open a session when approved, and the only ones revised in place. */
|
|
18
|
+
export const REVISABLE_KINDS = new Set(["run_request", "work_proposal"]);
|
|
19
|
+
/** A request is revised only before a decision runs it: waiting, or sent back by its approver. */
|
|
20
|
+
export const REVISABLE_STATES = new Set(["awaiting_approval", "revision_requested"]);
|
|
21
|
+
/** The fields that make a request's content: what a version is. */
|
|
22
|
+
export const CONTENT_FIELDS = ["intention", "circle", "refs", "body"];
|
|
23
|
+
function canonical(value) {
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return value.map(canonical);
|
|
26
|
+
if (value && typeof value === "object") {
|
|
27
|
+
return Object.fromEntries(Object.keys(value)
|
|
28
|
+
.filter((k) => value[k] !== undefined)
|
|
29
|
+
.sort()
|
|
30
|
+
.map((k) => [k, canonical(value[k])]));
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/** The content of a request version, as the hash and the history show it. */
|
|
35
|
+
export function contentOf(r) {
|
|
36
|
+
return { kind: r.kind, intention: r.intention, circle: r.circle, refs: r.refs ?? [], body: r.body ?? {} };
|
|
37
|
+
}
|
|
38
|
+
/** sha256 of the canonical JSON of a version's content. */
|
|
39
|
+
export function contentHash(r) {
|
|
40
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(canonical(contentOf(r)))).digest("hex")}`;
|
|
41
|
+
}
|
|
42
|
+
/** The version a folded request is at: 1 for a request never revised. */
|
|
43
|
+
export function versionOf(r) {
|
|
44
|
+
return typeof r?.version === "number" && r.version >= 1 ? r.version : 1;
|
|
45
|
+
}
|
|
46
|
+
/** The names of the content fields (body fields by name) that differ between two versions. */
|
|
47
|
+
export function changedFields(before, after) {
|
|
48
|
+
const out = [];
|
|
49
|
+
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
50
|
+
for (const k of ["intention", "circle", "refs"])
|
|
51
|
+
if (!same(before[k], after[k]))
|
|
52
|
+
out.push(k);
|
|
53
|
+
const b = (before.body ?? {});
|
|
54
|
+
const a = (after.body ?? {});
|
|
55
|
+
for (const k of [...new Set([...Object.keys(b), ...Object.keys(a)])].sort())
|
|
56
|
+
if (!same(b[k], a[k]))
|
|
57
|
+
out.push(k);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Every version of a request, oldest first, numbered across its lineage: the requests
|
|
62
|
+
* it adopted (each with its own versions) and then its own. `raw` is the unfolded ledger,
|
|
63
|
+
* `folded` the folded one (`current()`).
|
|
64
|
+
*/
|
|
65
|
+
export function requestHistory(id, raw, folded = []) {
|
|
66
|
+
// `current` marks the version the request holds now: the last one.
|
|
67
|
+
const own = (rid, seen) => {
|
|
68
|
+
if (seen.has(rid))
|
|
69
|
+
return [];
|
|
70
|
+
seen.add(rid);
|
|
71
|
+
const base = raw.find((r) => r.id === rid);
|
|
72
|
+
if (!base)
|
|
73
|
+
return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
const lineage = raw.filter((r) => r.supersedes === rid && r.lineage).flatMap((r) => r.lineage.adopted);
|
|
76
|
+
const adopted = lineage
|
|
77
|
+
.map((a) => raw.find((r) => r.id === a))
|
|
78
|
+
.filter((r) => !!r)
|
|
79
|
+
.sort((x, y) => x.created_at.localeCompare(y.created_at));
|
|
80
|
+
for (const a of adopted)
|
|
81
|
+
out.push(...own(a.id, seen));
|
|
82
|
+
out.push({ record_id: base.id, request_id: rid, at: base.created_at, author: base.actor?.name ?? "someone", content_hash: contentHash(base), content: contentOf(base) });
|
|
83
|
+
for (const rev of raw.filter((r) => r.supersedes === rid && r.revision)) {
|
|
84
|
+
out.push({
|
|
85
|
+
record_id: rev.id,
|
|
86
|
+
request_id: rid,
|
|
87
|
+
at: rev.revision.at,
|
|
88
|
+
author: rev.revision.author.name,
|
|
89
|
+
reason: rev.revision.reason,
|
|
90
|
+
changed: rev.revision.changed,
|
|
91
|
+
content_hash: rev.revision.content_hash,
|
|
92
|
+
content: contentOf(rev),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
void folded;
|
|
98
|
+
const versions = own(id, new Set());
|
|
99
|
+
return versions.map((v, i) => ({ ...v, version: i + 1, current: i === versions.length - 1 }));
|
|
100
|
+
}
|
|
101
|
+
/** A revision refused, with the code a tool answers. */
|
|
102
|
+
export class RevisionRefused extends Error {
|
|
103
|
+
code;
|
|
104
|
+
detail;
|
|
105
|
+
constructor(code, message, detail) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.code = code;
|
|
108
|
+
this.detail = detail;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The new record that revises a waiting request, or a refusal. `next` holds the new
|
|
113
|
+
* content (kind, intention, circle, refs, body); `live` is the folded request.
|
|
114
|
+
*/
|
|
115
|
+
export function revisionRecord(live, next, o) {
|
|
116
|
+
if (!live)
|
|
117
|
+
throw new RevisionRefused("not_found", "No request with that id.");
|
|
118
|
+
if (!REVISABLE_KINDS.has(live.kind))
|
|
119
|
+
throw new RevisionRefused("invalid_request", `A ${live.kind} is not revised in place; only agent runs and work proposals are.`);
|
|
120
|
+
if (!o.person.writer && live.actor?.person_id !== o.person.id) {
|
|
121
|
+
throw new RevisionRefused("forbidden", "Only whoever asked, or a person with Miadi's write tier, revises a request.");
|
|
122
|
+
}
|
|
123
|
+
if (o.beingDecided)
|
|
124
|
+
throw new RevisionRefused("being_decided", "A decision on this request is being recorded; it cannot be revised now.");
|
|
125
|
+
if (!REVISABLE_STATES.has(live.approval_state ?? "")) {
|
|
126
|
+
throw new RevisionRefused("not_revisable", `This request is already ${live.approval_state ?? "decided"}. A change after a decision is a new request, or a steer of the session it opened (request_session_steer).`);
|
|
127
|
+
}
|
|
128
|
+
const current = versionOf(live);
|
|
129
|
+
if (o.expectedVersion !== current) {
|
|
130
|
+
throw new RevisionRefused("stale_version", `The request is at version ${current}, not ${o.expectedVersion}. Read it with get_request_history and revise version ${current}.`, {
|
|
131
|
+
current_version: current,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const before = contentOf(live);
|
|
135
|
+
const after = contentOf({ kind: live.kind, intention: next.intention, circle: next.circle, refs: next.refs, body: next.body });
|
|
136
|
+
const changed = changedFields(before, after);
|
|
137
|
+
if (!changed.length)
|
|
138
|
+
throw new RevisionRefused("no_change", `Version ${current} already says this; nothing was recorded.`, { current_version: current });
|
|
139
|
+
const at = o.now ?? new Date().toISOString();
|
|
140
|
+
const sentBack = live.approval_state === "revision_requested";
|
|
141
|
+
return {
|
|
142
|
+
id: o.id,
|
|
143
|
+
kind: live.kind,
|
|
144
|
+
created_at: at,
|
|
145
|
+
actor: o.actor,
|
|
146
|
+
refs: next.refs,
|
|
147
|
+
evidence_refs: [],
|
|
148
|
+
...(next.intention !== undefined ? { intention: next.intention } : {}),
|
|
149
|
+
...(next.circle !== undefined ? { circle: next.circle } : {}),
|
|
150
|
+
body: next.body,
|
|
151
|
+
supersedes: live.id,
|
|
152
|
+
approval_state: "awaiting_approval",
|
|
153
|
+
state: "pending",
|
|
154
|
+
next_action: `Awaiting a decision on version ${current + 1}.`,
|
|
155
|
+
...(o.idempotency_key ? { idempotency_key: o.idempotency_key } : {}),
|
|
156
|
+
// A request sent back keeps the approver's instructions on its receipt, now as what the revision answers.
|
|
157
|
+
...(sentBack && live.completion_receipt ? { completion_receipt: { ...live.completion_receipt, answered_by_version: current + 1 } } : {}),
|
|
158
|
+
revision: {
|
|
159
|
+
version: current + 1,
|
|
160
|
+
previous_version: current,
|
|
161
|
+
reason: o.reason,
|
|
162
|
+
changed,
|
|
163
|
+
content_hash: contentHash({ kind: live.kind, intention: next.intention, circle: next.circle, refs: next.refs, body: next.body }),
|
|
164
|
+
previous_hash: contentHash(live),
|
|
165
|
+
author: { person_id: o.person.id, name: o.person.name },
|
|
166
|
+
at,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The records that make `standingId` the one request of a lineage: one retirement for
|
|
172
|
+
* each named request still waiting, and one lineage record on the standing request.
|
|
173
|
+
* Every named request is checked before anything is written; one refusal writes none.
|
|
174
|
+
* Only a request named by its exact id is retired, never one that looks similar, and a
|
|
175
|
+
* request already decided (approved, queued, declined) is refused, never cancelled.
|
|
176
|
+
*/
|
|
177
|
+
export function supersedeRecords(folded, standingId, retire, o) {
|
|
178
|
+
const standing = folded.find((r) => r.id === standingId);
|
|
179
|
+
if (!standing)
|
|
180
|
+
throw new RevisionRefused("not_found", `No request ${standingId}.`);
|
|
181
|
+
if (!REVISABLE_KINDS.has(standing.kind))
|
|
182
|
+
throw new RevisionRefused("invalid_request", "Only agent runs and work proposals carry versions.");
|
|
183
|
+
if (!REVISABLE_STATES.has(standing.approval_state ?? "")) {
|
|
184
|
+
throw new RevisionRefused("not_revisable", `The request that stands is already ${standing.approval_state}; earlier versions are joined only to a request still waiting.`);
|
|
185
|
+
}
|
|
186
|
+
if (o.beingDecided(standingId))
|
|
187
|
+
throw new RevisionRefused("being_decided", "A decision on the request that stands is being recorded; nothing was retired.");
|
|
188
|
+
// Earlier requests come before the standing one's own versions: joined after an in-place revision,
|
|
189
|
+
// the numbers it was given would shift (#919 review F4).
|
|
190
|
+
if (versionOf(standing) > 1) {
|
|
191
|
+
throw new RevisionRefused("not_revisable", `The request that stands is already at version ${versionOf(standing)}: join earlier requests to it before revising it in place, or revise it instead.`);
|
|
192
|
+
}
|
|
193
|
+
const ids = [...new Set(retire.map((x) => x.trim()).filter(Boolean))];
|
|
194
|
+
if (!ids.length)
|
|
195
|
+
throw new RevisionRefused("invalid_request", "Name at least one earlier request by its exact id.");
|
|
196
|
+
if (ids.includes(standingId))
|
|
197
|
+
throw new RevisionRefused("invalid_request", "The request that stands cannot also be retired.");
|
|
198
|
+
const may = (r) => o.person.writer || r.actor?.person_id === o.person.id;
|
|
199
|
+
if (!may(standing))
|
|
200
|
+
throw new RevisionRefused("forbidden", "Only whoever asked, or a person with Miadi's write tier, joins versions of a request.");
|
|
201
|
+
const olds = [];
|
|
202
|
+
for (const id of ids) {
|
|
203
|
+
const r = folded.find((x) => x.id === id);
|
|
204
|
+
if (!r)
|
|
205
|
+
throw new RevisionRefused("not_found", `No request ${id}; nothing was retired.`);
|
|
206
|
+
if (r.kind !== standing.kind)
|
|
207
|
+
throw new RevisionRefused("invalid_request", `${id} is a ${r.kind}, the request that stands a ${standing.kind}; nothing was retired.`);
|
|
208
|
+
if (!may(r))
|
|
209
|
+
throw new RevisionRefused("forbidden", `${id} was asked by someone else; nothing was retired.`);
|
|
210
|
+
if (o.beingDecided(id))
|
|
211
|
+
throw new RevisionRefused("being_decided", `A decision on ${id} is being recorded; nothing was retired.`);
|
|
212
|
+
if (!REVISABLE_STATES.has(r.approval_state ?? "")) {
|
|
213
|
+
throw new RevisionRefused("not_revisable", `${id} is already ${r.approval_state}: a decided request is never retired. Nothing was retired.`);
|
|
214
|
+
}
|
|
215
|
+
olds.push(r);
|
|
216
|
+
}
|
|
217
|
+
const at = o.now ?? new Date().toISOString();
|
|
218
|
+
const by = { person_id: o.person.id, name: o.person.name };
|
|
219
|
+
const out = olds.map((r) => ({
|
|
220
|
+
id: o.newId(r.kind),
|
|
221
|
+
kind: r.kind,
|
|
222
|
+
created_at: at,
|
|
223
|
+
actor: o.actor,
|
|
224
|
+
refs: r.refs,
|
|
225
|
+
evidence_refs: [],
|
|
226
|
+
supersedes: r.id,
|
|
227
|
+
approval_state: "declined",
|
|
228
|
+
state: "withdrawn",
|
|
229
|
+
next_action: `Superseded by ${standingId}: decide that request instead.`,
|
|
230
|
+
completion_receipt: { decision: "superseded", superseded_by: standingId, decided_by: by, decided_at: at, via: o.actor.client_name ?? o.actor.client_id, reason: o.reason },
|
|
231
|
+
body: { decision: "superseded", superseded_by: standingId },
|
|
232
|
+
}));
|
|
233
|
+
out.push({
|
|
234
|
+
id: o.newId(standing.kind),
|
|
235
|
+
kind: standing.kind,
|
|
236
|
+
created_at: at,
|
|
237
|
+
actor: o.actor,
|
|
238
|
+
refs: standing.refs,
|
|
239
|
+
evidence_refs: [],
|
|
240
|
+
supersedes: standingId,
|
|
241
|
+
body: { decision: "lineage" },
|
|
242
|
+
lineage: { adopted: olds.map((r) => r.id), reason: o.reason, author: by, at },
|
|
243
|
+
});
|
|
244
|
+
return out;
|
|
245
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { JevAdapter } from "./model-preflight.js";
|
|
|
9
9
|
export interface ConnectorDeps {
|
|
10
10
|
identify: (token: string) => Promise<MiadiPerson | null>;
|
|
11
11
|
fetchImpl?: typeof fetch;
|
|
12
|
-
/** The factory directory holding queue/ (default
|
|
12
|
+
/** The factory directory holding queue/ (default /srv/miadi/factory). */
|
|
13
13
|
factoryDir?: string;
|
|
14
14
|
tmuxSessions?: () => string[];
|
|
15
15
|
/** The steering surface (jgwill/Miadi#893); tests inject a fake, production uses tmux. */
|
|
@@ -29,4 +29,4 @@ export declare function createConnectorApp(config: ConnectorConfig, deps: Connec
|
|
|
29
29
|
provider: MiadiOAuthProvider;
|
|
30
30
|
ledger: Ledger<ConnectorRecord>;
|
|
31
31
|
};
|
|
32
|
-
export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and
|
|
32
|
+
export declare const INSTRUCTIONS = "Miadi is a relational software factory: agent sessions, Miadi reviews of screenwalks, Chronicle episodes, talking circles and the five accountable circles (Story, Stewardship, Engineering, Evaluation, Continuity) across three domains (Creative, Ceremonial, Engineering).\n\nStart with get_capabilities: it says who you are signed in as, which tools are live, and which return not_implemented.\n\nRules every tool keeps:\n- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.\n- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.\n- A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.\n- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.\n- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.\n- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.";
|
package/dist/server.js
CHANGED
|
@@ -145,7 +145,8 @@ Start with get_capabilities: it says who you are signed in as, which tools are l
|
|
|
145
145
|
|
|
146
146
|
Rules every tool keeps:
|
|
147
147
|
- Every record names the person who made it, with a claim category: observed, hypothesis, desired_future, fiction or verified_implementation. Keep fiction and desired futures apart from what was observed.
|
|
148
|
-
- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and
|
|
148
|
+
- Requests (runs, work proposals, review operations, Deep Diver, review-to-episode links, inquiry-to-episode weaves, session completion) are recorded as awaiting_approval. Nothing runs, publishes or closes until a person decides. After making a request, call show_decisions: it shows the request as a card in the conversation, where the person decides with their own click (or follows its approval page). Never claim a decision the card has not reported. The person may approve, approve with changes (their instructions are carried into what runs), send it back for revision with instructions (approval_state revision_requested; read the instructions with get_run_result and revise it in place with revise_pending_request), or decline.
|
|
149
|
+
- A mission is revised, never re-requested: to change a run or work proposal that still awaits a decision, call revise_pending_request with its id and the version you read (expected_version). It keeps one id, one approval page and one card; get_request_history shows every version. A new request_authorized_run or propose_work is for a new mission only. Requests made earlier as separate versions are joined with supersede_pending_requests, naming their exact ids, only when the person names or confirms them.
|
|
149
150
|
- A research report (a Deep Research report) is delivered with save_research exactly as written, never summarised or shortened, with its sources; a long report goes in parts. Check the bytes and first and last lines the answer returns against what you sent.
|
|
150
151
|
- An unknown id is answered as not found, never invented. A missing backing service is answered as unavailable.
|
|
151
152
|
- Use the miadi-review:<uuid>, miadi-chronicle://<n>, owner/repo#n and session:<uuid> reference forms.`;
|
package/dist/tools.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface ToolContext {
|
|
|
16
16
|
reviews: ReviewClient;
|
|
17
17
|
ledger: Ledger<ConnectorRecord>;
|
|
18
18
|
oauthState: JsonFile<OAuthState>;
|
|
19
|
-
/** The factory directory holding queue/ (default
|
|
19
|
+
/** The factory directory holding queue/ (default /srv/miadi/factory). */
|
|
20
20
|
factoryDir?: string;
|
|
21
21
|
/** What the in-chat approval card needs to run a decided request. */
|
|
22
22
|
approval?: ApprovalDeps;
|
package/dist/tools.js
CHANGED
|
@@ -16,7 +16,8 @@ import { reviewIdOf, videoIdOf } from "./reviews.js";
|
|
|
16
16
|
import { SCOPE_APPROVE, SCOPE_CONTRIBUTE } from "./oauth.js";
|
|
17
17
|
import { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, current, newRecordId, } from "./records.js";
|
|
18
18
|
import { VERSION } from "./version.js";
|
|
19
|
-
import { AlreadyDecided, DECISIONS, KIND_LABELS, consequenceOf, decide, isApprovable, readQueueState, requestProjection } from "./approve.js";
|
|
19
|
+
import { AlreadyDecided, DECISIONS, KIND_LABELS, StaleVersion, consequenceOf, decide, isApprovable, isBeingDecided, readQueueState, requestProjection } from "./approve.js";
|
|
20
|
+
import { RevisionRefused, requestHistory, revisionRecord, supersedeRecords, versionOf } from "./revisions.js";
|
|
20
21
|
import { DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
|
|
21
22
|
import { discoverProviders, jevConfigOf, resolveProvider, routerPreflight, PROVIDERS } from "./providers.js";
|
|
22
23
|
import { authorizesActions, missionStatus, primaryIssueOf, resolveMissionSubject } from "./mission-status.js";
|
|
@@ -638,12 +639,14 @@ export function registerTools(server, ctx) {
|
|
|
638
639
|
return { state: "not_found", record_id };
|
|
639
640
|
return {
|
|
640
641
|
record: r,
|
|
642
|
+
version: versionOf(r),
|
|
643
|
+
...(versionOf(r) > 1 ? { history: "Earlier versions: get_request_history." } : {}),
|
|
641
644
|
approval_url: r.approval_state === "awaiting_approval" ? approvalUrl(r.id) : undefined,
|
|
642
645
|
queue: r.kind === "run_request" || r.kind === "work_proposal" ? readQueueState(r, ctx.factoryDir) : undefined,
|
|
643
646
|
// Where the request stands, in the words every surface uses (jgwill/Miadi#889).
|
|
644
647
|
projection: requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
645
648
|
...(r.approval_state === "revision_requested"
|
|
646
|
-
? { revision: { instructions: r.completion_receipt?.instructions, next: `
|
|
649
|
+
? { revision: { instructions: r.completion_receipt?.instructions, next: `Revise it in place: revise_pending_request with request_id ${r.id} and expected_version ${versionOf(r)}.` } }
|
|
647
650
|
: {}),
|
|
648
651
|
};
|
|
649
652
|
}));
|
|
@@ -1051,7 +1054,7 @@ export function registerTools(server, ctx) {
|
|
|
1051
1054
|
}));
|
|
1052
1055
|
server.registerTool("propose_work", {
|
|
1053
1056
|
title: "Propose work to a circle",
|
|
1054
|
-
description: "Propose a bounded piece of work: what should exist when it is done, the circle accountable, the sources. Nothing starts until a person decides. Approving it starts the work as a factory run, with any changes the person writes; the person may instead send it back for revision with instructions (approval_state revision_requested, instructions in completion_receipt): then
|
|
1057
|
+
description: "Propose a bounded piece of work: what should exist when it is done, the circle accountable, the sources. Nothing starts until a person decides. Approving it starts the work as a factory run, with any changes the person writes; the person may instead send it back for revision with instructions (approval_state revision_requested, instructions in completion_receipt): then revise it in place with revise_pending_request (same id). To change a proposal still awaiting a decision, revise it the same way rather than proposing again.",
|
|
1055
1058
|
inputSchema: {
|
|
1056
1059
|
title: z.string().min(1).max(200),
|
|
1057
1060
|
desired_outcome: z.string().min(1).max(6000),
|
|
@@ -1135,14 +1138,14 @@ export function registerTools(server, ctx) {
|
|
|
1135
1138
|
// ── requests: recorded, approved by a person on the approval page, then run ──
|
|
1136
1139
|
server.registerTool("request_authorized_run", {
|
|
1137
1140
|
title: "Request a bounded agent run",
|
|
1138
|
-
description: "Ask for an agent session to do one bounded task. The request waits for a person with Miadi's write tier to approve it on the returned approval_url; once approved, a brief is queued for the factory intake, which opens or reaches the session within minutes. Read it back with get_run_result.",
|
|
1141
|
+
description: "Ask for an agent session to do one bounded task. The request waits for a person with Miadi's write tier to approve it on the returned approval_url; once approved, a brief is queued for the factory intake, which opens or reaches the session within minutes. Read it back with get_run_result. This is for a new mission only: to change a request that still awaits a decision, call revise_pending_request with its id, so the person keeps one card to decide.",
|
|
1139
1142
|
inputSchema: {
|
|
1140
1143
|
task: z.string().min(1).max(12000).describe("What the session does and what it returns."),
|
|
1141
1144
|
circle,
|
|
1142
1145
|
refs,
|
|
1143
1146
|
primary_issue: primaryIssue,
|
|
1144
1147
|
session: z.string().regex(/^[A-Za-z0-9._-]{3,80}$/).optional().describe("An existing tmux session to hand it to, or a name for a new one."),
|
|
1145
|
-
cwd: z.string().max(300).optional().describe("Working folder for a new session (default the
|
|
1148
|
+
cwd: z.string().max(300).optional().describe("Working folder for a new session (default: the factory runner's defaultCwd on the host)."),
|
|
1146
1149
|
provider: z
|
|
1147
1150
|
.enum(["codex", "pi", "copilot", "claude"])
|
|
1148
1151
|
.optional()
|
|
@@ -1192,6 +1195,178 @@ export function registerTools(server, ctx) {
|
|
|
1192
1195
|
approval_url: approvalUrl(r.id),
|
|
1193
1196
|
};
|
|
1194
1197
|
}));
|
|
1198
|
+
// ── one mission, many versions, one decision (jgwill/Miadi#919) ────────────
|
|
1199
|
+
const revisionOut = (r) => {
|
|
1200
|
+
const live = all().find((x) => x.id === r.id) ?? r;
|
|
1201
|
+
return {
|
|
1202
|
+
request_id: live.id,
|
|
1203
|
+
version: versionOf(live),
|
|
1204
|
+
approval_url: approvalUrl(live.id),
|
|
1205
|
+
projection: requestProjection(live, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
1206
|
+
};
|
|
1207
|
+
};
|
|
1208
|
+
server.registerTool("revise_pending_request", {
|
|
1209
|
+
title: "Revise a request that still awaits a decision",
|
|
1210
|
+
description: "Change an agent run or a work proposal that waits for a decision, or that its approver sent back for revision, without making a new request. The request keeps its id, its approval page and its one card; the new content becomes the next version and every earlier version stays readable with get_request_history. Pass the version you are revising as expected_version: a request revised or decided since then is refused. Give only the fields that change. A request already approved, queued or declined is not revised: a change after a decision is a new request or a steer. Call show_decisions afterwards so the person sees the one current card.",
|
|
1211
|
+
inputSchema: {
|
|
1212
|
+
request_id: z.string().min(1).describe("The request's id, run_request:<uuid> or work_proposal:<uuid>."),
|
|
1213
|
+
expected_version: z.number().int().min(1).describe("The version you read and are revising (projection.version, 1 for a request never revised)."),
|
|
1214
|
+
reason: z.string().min(1).max(2000).describe("Why it changes, in the person's words when they gave them."),
|
|
1215
|
+
changes: z
|
|
1216
|
+
.object({
|
|
1217
|
+
task: z.string().min(1).max(12000).optional().describe("Agent run: what the session does and returns."),
|
|
1218
|
+
title: z.string().min(1).max(200).optional().describe("Work proposal: its title."),
|
|
1219
|
+
desired_outcome: z.string().min(1).max(6000).optional().describe("Work proposal: what should exist when it is done."),
|
|
1220
|
+
repo: z.string().max(120).optional(),
|
|
1221
|
+
intention: z.string().max(2000).optional(),
|
|
1222
|
+
circle: circle.optional(),
|
|
1223
|
+
refs: z.array(z.string()).max(50).optional().describe("The whole new list of refs, replacing the old one."),
|
|
1224
|
+
primary_issue: primaryIssue,
|
|
1225
|
+
session: z.string().regex(/^[A-Za-z0-9._-]{3,80}$/).optional(),
|
|
1226
|
+
cwd: z.string().max(300).optional(),
|
|
1227
|
+
provider: z.enum(["codex", "pi", "copilot", "claude"]).optional().describe("Verified installed on the connector host again, as request_authorized_run does."),
|
|
1228
|
+
steering: z
|
|
1229
|
+
.object({ max_attempts: z.number().int().min(1).max(20), expires_at: z.string().optional(), pane: z.string().regex(/^\d+\.\d+$/).optional() })
|
|
1230
|
+
.optional(),
|
|
1231
|
+
})
|
|
1232
|
+
.describe("Only the fields that change."),
|
|
1233
|
+
idempotency_key: z.string().max(200).optional().describe("Repeat the same key to get the same revision back instead of a second one."),
|
|
1234
|
+
},
|
|
1235
|
+
annotations: WRITE,
|
|
1236
|
+
}, wrap(async (a, extra) => {
|
|
1237
|
+
const { person, auth } = who(extra);
|
|
1238
|
+
requireContribute(auth);
|
|
1239
|
+
const raw = ledger.all();
|
|
1240
|
+
if (a.idempotency_key) {
|
|
1241
|
+
const prior = raw.find((r) => r.supersedes === a.request_id && r.revision && r.idempotency_key === a.idempotency_key && r.actor.person_id === person.id);
|
|
1242
|
+
if (prior)
|
|
1243
|
+
return { ...revisionOut({ ...prior, id: a.request_id }), revised: prior.revision, repeated: true };
|
|
1244
|
+
}
|
|
1245
|
+
const live = all().find((r) => r.id === a.request_id);
|
|
1246
|
+
const c = (a.changes ?? {});
|
|
1247
|
+
const body = { ...(live?.body ?? {}) };
|
|
1248
|
+
let provider;
|
|
1249
|
+
if (live?.kind === "run_request") {
|
|
1250
|
+
for (const k of ["task", "session", "cwd"])
|
|
1251
|
+
if (c[k] !== undefined)
|
|
1252
|
+
body[k] = c[k];
|
|
1253
|
+
if (c.steering !== undefined)
|
|
1254
|
+
body.steering = c.steering;
|
|
1255
|
+
if (c.provider !== undefined) {
|
|
1256
|
+
const resolution = resolveProvider(c.provider, discoverProviders(ctx.providersProbe));
|
|
1257
|
+
if (resolution.state === "provider_unknown")
|
|
1258
|
+
throw new ToolRefusal("invalid_request", resolution.message, { available: resolution.available });
|
|
1259
|
+
if (resolution.state !== "ok")
|
|
1260
|
+
return { state: resolution.state, message: resolution.message, available: resolution.available, note: "Nothing was recorded; the request stays at its version." };
|
|
1261
|
+
body.provider = c.provider;
|
|
1262
|
+
body.resolved_provider = resolution.provider.id;
|
|
1263
|
+
provider = { requested: c.provider, resolved: resolution.provider.id, explicit: resolution.explicit, version: resolution.provider.version };
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
else if (live?.kind === "work_proposal") {
|
|
1267
|
+
for (const k of ["title", "desired_outcome", "repo"])
|
|
1268
|
+
if (c[k] !== undefined)
|
|
1269
|
+
body[k] = c[k];
|
|
1270
|
+
}
|
|
1271
|
+
if (c.primary_issue !== undefined)
|
|
1272
|
+
body.primary_issue = c.primary_issue;
|
|
1273
|
+
const nextRefs = withPrimary(c.refs ?? live?.refs ?? [], c.primary_issue ?? body.primary_issue);
|
|
1274
|
+
let record;
|
|
1275
|
+
try {
|
|
1276
|
+
record = revisionRecord(live, { intention: c.intention ?? live?.intention, circle: c.circle ?? live?.circle, refs: nextRefs, body }, {
|
|
1277
|
+
id: newRecordId(live?.kind ?? "run_request"),
|
|
1278
|
+
person: { id: person.id, name: person.name, writer: person.api_tier === "write" },
|
|
1279
|
+
actor: actorOf(person, auth),
|
|
1280
|
+
expectedVersion: a.expected_version,
|
|
1281
|
+
reason: a.reason,
|
|
1282
|
+
beingDecided: isBeingDecided(a.request_id),
|
|
1283
|
+
idempotency_key: a.idempotency_key,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
catch (error) {
|
|
1287
|
+
if (error instanceof RevisionRefused) {
|
|
1288
|
+
throw new ToolRefusal(error.code, error.message, { ...(error.detail ?? {}), ...(live ? revisionOut(live) : {}) });
|
|
1289
|
+
}
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
ledger.append(record);
|
|
1293
|
+
return {
|
|
1294
|
+
...revisionOut(record.supersedes ? { ...record, id: record.supersedes } : record),
|
|
1295
|
+
revised: record.revision,
|
|
1296
|
+
...(provider ? { provider } : {}),
|
|
1297
|
+
note: `One request, one approval: its page and card now show version ${record.revision.version}. Earlier versions stay readable with get_request_history.`,
|
|
1298
|
+
};
|
|
1299
|
+
}));
|
|
1300
|
+
server.registerTool("get_request_history", {
|
|
1301
|
+
title: "Every version of a request",
|
|
1302
|
+
description: "Every version of an agent run or work proposal, oldest first, with who changed it, when, why, which fields changed and each version's content hash, and the decisions taken on it. Earlier requests joined to it as its earlier versions (supersede_pending_requests) are included. A request retired that way names the request that stands.",
|
|
1303
|
+
inputSchema: { request_id: z.string().min(1) },
|
|
1304
|
+
annotations: READ,
|
|
1305
|
+
}, wrap(async ({ request_id }, extra) => {
|
|
1306
|
+
const { person } = who(extra);
|
|
1307
|
+
const live = all().find((r) => r.id === request_id);
|
|
1308
|
+
if (!live || !isApprovable(live))
|
|
1309
|
+
return { state: "not_found", request_id };
|
|
1310
|
+
if (person.api_tier !== "write" && live.actor.person_id !== person.id) {
|
|
1311
|
+
throw new ToolRefusal("forbidden", "A request's history is read by whoever asked and by people with Miadi's write tier.");
|
|
1312
|
+
}
|
|
1313
|
+
const raw = ledger.all();
|
|
1314
|
+
const versions = requestHistory(request_id, raw, all()).map((v) => {
|
|
1315
|
+
const content = v.content;
|
|
1316
|
+
const shownBody = redact({ ...live, body: (content.body ?? {}) }).body;
|
|
1317
|
+
return { ...v, content: { ...content, body: shownBody } };
|
|
1318
|
+
});
|
|
1319
|
+
const decisions = raw
|
|
1320
|
+
.filter((r) => r.supersedes && versions.some((v) => v.request_id === r.supersedes) && !r.revision && !r.lineage && r.completion_receipt)
|
|
1321
|
+
.map((r) => {
|
|
1322
|
+
const rc = r.completion_receipt;
|
|
1323
|
+
return { request_id: r.supersedes, at: rc.decided_at ?? r.created_at, decision: rc.decision, by: rc.decided_by?.name, version: rc.version, superseded_by: rc.superseded_by, instructions: rc.instructions, reason: rc.reason };
|
|
1324
|
+
});
|
|
1325
|
+
const receipt = (live.completion_receipt ?? {});
|
|
1326
|
+
return {
|
|
1327
|
+
request_id,
|
|
1328
|
+
version: versionOf(live),
|
|
1329
|
+
versions,
|
|
1330
|
+
decisions,
|
|
1331
|
+
...(receipt.decision === "superseded" ? { superseded_by: receipt.superseded_by, note: `This request was retired as an earlier version of ${receipt.superseded_by}; read that one's history for every version.` } : {}),
|
|
1332
|
+
projection: requestProjection(live, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
1333
|
+
};
|
|
1334
|
+
}));
|
|
1335
|
+
server.registerTool("supersede_pending_requests", {
|
|
1336
|
+
title: "Join earlier requests to the one that stands",
|
|
1337
|
+
description: "For requests made before revise_pending_request existed, one new request per revision: name the request that stands and, by exact id, the earlier requests that are its earlier versions. Each named request must still await a decision and be asked by the same person; each is retired (it can no longer be approved, and its card and page say which request to decide instead) and becomes an earlier version of the standing request, readable with get_request_history. All are checked before anything is written. Never infer which requests are duplicates: retire only ids the person named or confirmed. A request already approved or queued is refused, never cancelled.",
|
|
1338
|
+
inputSchema: {
|
|
1339
|
+
request_id: z.string().min(1).describe("The request that stands, the one the person will decide."),
|
|
1340
|
+
supersede: z.array(z.string().min(1)).min(1).max(10).describe("The exact ids of the earlier requests it replaces."),
|
|
1341
|
+
reason: z.string().min(1).max(2000).describe("Why they are one mission, in the person's words when they gave them."),
|
|
1342
|
+
},
|
|
1343
|
+
annotations: WRITE,
|
|
1344
|
+
}, wrap(async (a, extra) => {
|
|
1345
|
+
const { person, auth } = who(extra);
|
|
1346
|
+
requireContribute(auth);
|
|
1347
|
+
let out;
|
|
1348
|
+
try {
|
|
1349
|
+
out = supersedeRecords(all(), a.request_id, a.supersede, {
|
|
1350
|
+
person: { id: person.id, name: person.name, writer: person.api_tier === "write" },
|
|
1351
|
+
actor: actorOf(person, auth),
|
|
1352
|
+
reason: a.reason,
|
|
1353
|
+
beingDecided: isBeingDecided,
|
|
1354
|
+
newId: newRecordId,
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
catch (error) {
|
|
1358
|
+
if (error instanceof RevisionRefused)
|
|
1359
|
+
throw new ToolRefusal(error.code, error.message, error.detail);
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
for (const r of out)
|
|
1363
|
+
ledger.append(r);
|
|
1364
|
+
return {
|
|
1365
|
+
...revisionOut({ ...out[out.length - 1], id: a.request_id }),
|
|
1366
|
+
retired: a.supersede,
|
|
1367
|
+
note: "The standing request is now the one card to decide; the retired requests are its earlier versions (get_request_history).",
|
|
1368
|
+
};
|
|
1369
|
+
}));
|
|
1195
1370
|
server.registerTool("request_session_steer", {
|
|
1196
1371
|
title: "Send one authorized follow-up to a running agent",
|
|
1197
1372
|
description: "Steer one existing live agent session within an approved run's steering grant (jgwill/Miadi#893): the exact instruction is typed into the mission's bound pane — never whichever pane is active — after a peek, and a re-peek verifies the agent accepted it. When the approved run carries no steering grant covering this, the steer waits for one explicit scoped human decision on the returned approval_url. Every attempt leaves a durable artifact with its hash and both captures.",
|
|
@@ -1539,7 +1714,8 @@ export function registerTools(server, ctx) {
|
|
|
1539
1714
|
id: r.id,
|
|
1540
1715
|
kind: r.kind,
|
|
1541
1716
|
kind_label: KIND_LABELS[r.kind] ?? r.kind,
|
|
1542
|
-
title: titleOf(r),
|
|
1717
|
+
title: versionOf(r) > 1 ? `${titleOf(r)} · version ${versionOf(r)}` : titleOf(r),
|
|
1718
|
+
version: versionOf(r),
|
|
1543
1719
|
by: r.actor.name,
|
|
1544
1720
|
via: r.actor.client_name,
|
|
1545
1721
|
created_at: r.created_at,
|
|
@@ -1551,7 +1727,8 @@ export function registerTools(server, ctx) {
|
|
|
1551
1727
|
projection: requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
1552
1728
|
}));
|
|
1553
1729
|
const actionable = decisions.filter((d) => d.projection?.stage === "awaiting_decision");
|
|
1554
|
-
|
|
1730
|
+
// Each token is bound to the version its card shows: a card left open while the request is revised cannot approve it (#919).
|
|
1731
|
+
const tokens = canDecide ? Object.fromEntries(actionable.map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId, undefined, r.version)])) : {};
|
|
1555
1732
|
const structured = {
|
|
1556
1733
|
decisions,
|
|
1557
1734
|
count: decisions.length,
|
|
@@ -1594,11 +1771,14 @@ export function registerTools(server, ctx) {
|
|
|
1594
1771
|
const r = all().find((x) => x.id === a.record_id);
|
|
1595
1772
|
if (!isApprovable(r))
|
|
1596
1773
|
throw new ToolRefusal("not_found", "No request with that id.");
|
|
1597
|
-
const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId);
|
|
1598
|
-
if (check !== "ok")
|
|
1599
|
-
throw new ToolRefusal(check === "expired" ? "token_expired" : "invalid_token", check === "expired" ? "This card is too old; show the requests again." : "This decision did not come from the person's card.");
|
|
1600
1774
|
// A stale card or a second client gets where the request stands, not a bare refusal (jgwill/Miadi#889).
|
|
1601
1775
|
const projectionNow = () => requestProjection(all().find((x) => x.id === r.id) ?? r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir });
|
|
1776
|
+
const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId, undefined, versionOf(r));
|
|
1777
|
+
if (check === "stale") {
|
|
1778
|
+
throw new ToolRefusal("stale_version", `This card showed an earlier version; the request is now at version ${versionOf(r)}. Show the requests again and decide version ${versionOf(r)}.`, { projection: projectionNow() });
|
|
1779
|
+
}
|
|
1780
|
+
if (check !== "ok")
|
|
1781
|
+
throw new ToolRefusal(check === "expired" ? "token_expired" : "invalid_token", check === "expired" ? "This card is too old; show the requests again." : "This decision did not come from the person's card.");
|
|
1602
1782
|
if (r.approval_state !== "awaiting_approval") {
|
|
1603
1783
|
throw new ToolRefusal("already_decided", `This request is already ${r.approval_state}.`, { projection: projectionNow() });
|
|
1604
1784
|
}
|
|
@@ -1612,9 +1792,12 @@ export function registerTools(server, ctx) {
|
|
|
1612
1792
|
decided = await decide(r, person, a.decision, ctx.approval, {
|
|
1613
1793
|
instructions,
|
|
1614
1794
|
via: `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`,
|
|
1795
|
+
expectedVersion: versionOf(r),
|
|
1615
1796
|
});
|
|
1616
1797
|
}
|
|
1617
1798
|
catch (error) {
|
|
1799
|
+
if (error instanceof StaleVersion)
|
|
1800
|
+
throw new ToolRefusal("stale_version", error.message, { projection: projectionNow() });
|
|
1618
1801
|
// A decision recorded a moment earlier, by another client, is reported with where it stands.
|
|
1619
1802
|
if (error instanceof AlreadyDecided)
|
|
1620
1803
|
throw new ToolRefusal("already_decided", error.message, { projection: projectionNow() });
|
|
@@ -1627,7 +1810,7 @@ export function registerTools(server, ctx) {
|
|
|
1627
1810
|
: decision === "revision_requested"
|
|
1628
1811
|
? `Sent back for revision by ${person.name}.`
|
|
1629
1812
|
: `Declined by ${person.name}.`;
|
|
1630
|
-
const out = { record_id: r.id, decision, state, summary, receipt, projection };
|
|
1813
|
+
const out = { record_id: r.id, version: versionOf(r), decision, state, summary, receipt, projection };
|
|
1631
1814
|
return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1632
1815
|
}
|
|
1633
1816
|
catch (error) {
|
|
@@ -1719,6 +1902,9 @@ export const TOOL_STATUS = {
|
|
|
1719
1902
|
prepare_calendar_event: "live, contribute (draft and .ics only)",
|
|
1720
1903
|
propose_session_completion: "live, contribute, needs_approval (decision recorded, nothing closed)",
|
|
1721
1904
|
request_authorized_run: "live, contribute, needs_approval (queues a factory brief once approved; may name a verified provider and a bounded steering grant)",
|
|
1905
|
+
revise_pending_request: "live, contribute (a waiting or sent-back run or proposal gets its next version; same id, same card)",
|
|
1906
|
+
get_request_history: "live (every version of a request, oldest first; whoever asked, or the write tier)",
|
|
1907
|
+
supersede_pending_requests: "live, contribute (retires named earlier requests still waiting and joins them as earlier versions of the one that stands)",
|
|
1722
1908
|
request_session_steer: "live, contribute, write tier (runs inside an approved run's steering grant, else one scoped human decision)",
|
|
1723
1909
|
request_review_operation: "live, contribute, needs_approval",
|
|
1724
1910
|
request_deep_diver: "live, contribute, needs_approval",
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.14";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.14";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miadi/connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "The Miadi connector: one remote MCP service through which a person signed in with their Miadi person token, from ChatGPT or any MCP client, reads sessions, reviews, episodes and circles and records observations, inquiries, handoffs and proposals with their name on each record.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Miadi",
|