@miadi/connector 0.1.7 → 0.1.9
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 +2 -0
- package/dist/approve.d.ts +31 -14
- package/dist/approve.js +155 -50
- package/dist/decisions.d.ts +1 -1
- package/dist/decisions.js +42 -4
- package/dist/queue-state.d.ts +15 -0
- package/dist/queue-state.js +24 -0
- package/dist/records.d.ts +3 -0
- package/dist/records.js +2 -0
- package/dist/request-state.d.ts +69 -0
- package/dist/request-state.js +173 -0
- package/dist/tools.js +33 -13
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -38,6 +38,8 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
|
|
|
38
38
|
|
|
39
39
|
The model that asked cannot approve its own request.
|
|
40
40
|
|
|
41
|
+
`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.
|
|
42
|
+
|
|
41
43
|
### Deciding inside the conversation
|
|
42
44
|
|
|
43
45
|
In a client that renders MCP Apps (ChatGPT among them), `show_decisions` draws one card per request awaiting a decision: what is asked, by whom, and what approving does. The person decides on the card with their own click: Approve, Approve with my changes, Send back for revision, or Decline. The card then shows the receipt.
|
package/dist/approve.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { Ledger } from "./store.js";
|
|
|
15
15
|
import type { MiadiClient } from "./miadi.js";
|
|
16
16
|
import { type MiadiPerson } from "./people.js";
|
|
17
17
|
import { type ConnectorRecord } from "./records.js";
|
|
18
|
+
import { type RequestProjection } from "./request-state.js";
|
|
19
|
+
export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
18
20
|
export interface ApprovalDeps {
|
|
19
21
|
config: ConnectorConfig;
|
|
20
22
|
ledger: Ledger<ConnectorRecord>;
|
|
@@ -32,26 +34,14 @@ export interface ApprovalDeps {
|
|
|
32
34
|
*/
|
|
33
35
|
export declare const DECISIONS: readonly ["approve", "approve_with_changes", "request_revision", "decline"];
|
|
34
36
|
export type Decision = (typeof DECISIONS)[number];
|
|
35
|
-
export declare function factoryDir(env?: NodeJS.ProcessEnv): string;
|
|
36
|
-
export declare function runBriefFile(record: {
|
|
37
|
-
id: string;
|
|
38
|
-
}): string;
|
|
39
|
-
/** Where a run request's brief is: waiting, delivered by the intake, cancelled, or not queued. */
|
|
40
|
-
export declare function readQueueState(record: ConnectorRecord, dir?: string): {
|
|
41
|
-
state: string;
|
|
42
|
-
file: string;
|
|
43
|
-
note?: undefined;
|
|
44
|
-
} | {
|
|
45
|
-
state: string;
|
|
46
|
-
file: string;
|
|
47
|
-
note: string;
|
|
48
|
-
};
|
|
49
37
|
/** The task a work proposal asks for, written from the proposal's own fields. */
|
|
50
38
|
export declare function proposalTask(record: ConnectorRecord): string;
|
|
51
39
|
export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string): {
|
|
52
40
|
to: string;
|
|
53
41
|
brief: string;
|
|
54
42
|
};
|
|
43
|
+
/** A request's refs as episode references: an `owner/repo#n` becomes its issue link, anything else stays as written. */
|
|
44
|
+
export declare function episodeReferences(record: ConnectorRecord): string[];
|
|
55
45
|
export declare function executeApproved(record: ConnectorRecord, approver: MiadiPerson, deps: ApprovalDeps, instructions?: string): Promise<Record<string, unknown>>;
|
|
56
46
|
/**
|
|
57
47
|
* Record a person's decision on a request and, when approved, run it once as
|
|
@@ -62,6 +52,8 @@ export declare function executeApproved(record: ConnectorRecord, approver: Miadi
|
|
|
62
52
|
/** A request is decided once: a second decision while one runs, or after it, is refused. */
|
|
63
53
|
export declare class AlreadyDecided extends Error {
|
|
64
54
|
}
|
|
55
|
+
/** Whether a decision on this request is being recorded now, its run may still be going. */
|
|
56
|
+
export declare function isBeingDecided(recordId: string): boolean;
|
|
65
57
|
export declare function decide(r: ConnectorRecord, person: MiadiPerson, choice: Decision, deps: ApprovalDeps, opts: {
|
|
66
58
|
instructions?: string;
|
|
67
59
|
note?: string;
|
|
@@ -71,9 +63,34 @@ export declare function decide(r: ConnectorRecord, person: MiadiPerson, choice:
|
|
|
71
63
|
state: ConnectorRecord["state"];
|
|
72
64
|
receipt: Record<string, unknown>;
|
|
73
65
|
}>;
|
|
66
|
+
/** Whether a request was approved and its run failed, so a person may run it again. */
|
|
67
|
+
export declare function isRerunnable(r: ConnectorRecord | undefined): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Run an approved request again when its run failed (state `blocked`): a door
|
|
70
|
+
* it called was down or refused, and was fixed since. It runs as the person
|
|
71
|
+
* who asks, with the instructions it was approved with. The approval stays the
|
|
72
|
+
* decider's; the new receipt names who ran it again and keeps the failed
|
|
73
|
+
* outcome it replaces. jgwill/Miadi#884
|
|
74
|
+
*/
|
|
75
|
+
export declare function rerun(r: ConnectorRecord, person: MiadiPerson, deps: ApprovalDeps, opts: {
|
|
76
|
+
via: string;
|
|
77
|
+
}): Promise<{
|
|
78
|
+
decision: "approved";
|
|
79
|
+
state: ConnectorRecord["state"];
|
|
80
|
+
receipt: Record<string, unknown>;
|
|
81
|
+
}>;
|
|
74
82
|
export declare function isApprovable(r: ConnectorRecord | undefined): r is ConnectorRecord;
|
|
75
83
|
/** What approving a request does, in one sentence, for the card a person decides on. */
|
|
76
84
|
export declare function consequenceOf(r: ConnectorRecord): string;
|
|
77
85
|
export declare function registerApprovalRoutes(app: Express, deps: ApprovalDeps): void;
|
|
86
|
+
/** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
|
|
87
|
+
/**
|
|
88
|
+
* Where a request stands, as every surface of the connector reads it (request-state.ts):
|
|
89
|
+
* the factory queue is read for the two kinds that dispatch a brief.
|
|
90
|
+
*/
|
|
91
|
+
export declare function requestProjection(record: ConnectorRecord, opts?: {
|
|
92
|
+
reviewBase?: string;
|
|
93
|
+
factoryDir?: string;
|
|
94
|
+
}): RequestProjection | null;
|
|
78
95
|
/** How a request is named to a person. */
|
|
79
96
|
export declare const KIND_LABELS: Record<string, string>;
|
package/dist/approve.js
CHANGED
|
@@ -10,49 +10,22 @@
|
|
|
10
10
|
* is appended as a record that supersedes the request (state, approval, receipt).
|
|
11
11
|
*/
|
|
12
12
|
import express from "express";
|
|
13
|
-
import {
|
|
13
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { execFileSync } from "node:child_process";
|
|
15
|
-
import { homedir } from "node:os";
|
|
16
15
|
import { join } from "node:path";
|
|
17
16
|
import { escapeHtml } from "@miadi/node-service-kit/format";
|
|
18
17
|
import { MiadiError } from "./miadi.js";
|
|
19
18
|
import { MiadiUnavailable } from "./people.js";
|
|
20
19
|
import { current, newRecordId } from "./records.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"deep_diver_request",
|
|
25
|
-
"review_episode_link_request",
|
|
26
|
-
"work_proposal",
|
|
27
|
-
"session_completion_proposal",
|
|
28
|
-
"inquiry_episode_request",
|
|
29
|
-
]);
|
|
20
|
+
import { APPROVABLE, projectRequest } from "./request-state.js";
|
|
21
|
+
import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
22
|
+
export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
|
|
30
23
|
/**
|
|
31
24
|
* What a person may decide on a request. Approving with changes runs it with the
|
|
32
25
|
* approver's instructions carried into what runs; a revision sends it back to
|
|
33
26
|
* whoever asked, with instructions, and runs nothing.
|
|
34
27
|
*/
|
|
35
28
|
export const DECISIONS = ["approve", "approve_with_changes", "request_revision", "decline"];
|
|
36
|
-
export function factoryDir(env = process.env) {
|
|
37
|
-
return env.MIADI_FACTORY_DIR || join(homedir(), ".miadi", "factory");
|
|
38
|
-
}
|
|
39
|
-
export function runBriefFile(record) {
|
|
40
|
-
return `connector-${record.id.replace(/[^\w-]/g, "-")}.md`;
|
|
41
|
-
}
|
|
42
|
-
/** Where a run request's brief is: waiting, delivered by the intake, cancelled, or not queued. */
|
|
43
|
-
export function readQueueState(record, dir = factoryDir()) {
|
|
44
|
-
const file = runBriefFile(record);
|
|
45
|
-
const queue = join(dir, "queue");
|
|
46
|
-
if (existsSync(join(queue, file)))
|
|
47
|
-
return { state: "queued", file };
|
|
48
|
-
if (existsSync(join(queue, "delivered", file)))
|
|
49
|
-
return { state: "delivered", file };
|
|
50
|
-
if (existsSync(join(queue, "cancelled", file)))
|
|
51
|
-
return { state: "cancelled", file };
|
|
52
|
-
if (existsSync(join(queue, "stale", file)))
|
|
53
|
-
return { state: "stale", file, note: "Its seat did not take it in time; the intake moved it aside." };
|
|
54
|
-
return { state: record.approval_state === "approved" ? "missing" : "not_queued", file };
|
|
55
|
-
}
|
|
56
29
|
function liveTmux() {
|
|
57
30
|
try {
|
|
58
31
|
return execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], { encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] })
|
|
@@ -133,6 +106,13 @@ function plainName(name) {
|
|
|
133
106
|
const n = name.replace(/[^\w .'-]/g, "").trim().slice(0, 40);
|
|
134
107
|
return /^[^\W_]/.test(n) ? n : "Miadi connector";
|
|
135
108
|
}
|
|
109
|
+
/** A request's refs as episode references: an `owner/repo#n` becomes its issue link, anything else stays as written. */
|
|
110
|
+
export function episodeReferences(record) {
|
|
111
|
+
return (record.refs ?? []).map((ref) => {
|
|
112
|
+
const m = /^([\w.-]+)\/([\w.-]+)#(\d+)$/.exec(ref);
|
|
113
|
+
return m ? `https://github.com/${m[1]}/${m[2]}/issues/${m[3]}` : ref;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
136
116
|
export async function executeApproved(record, approver, deps, instructions) {
|
|
137
117
|
const f = deps.fetchImpl ?? fetch;
|
|
138
118
|
const b = record.body;
|
|
@@ -198,7 +178,9 @@ export async function executeApproved(record, approver, deps, instructions) {
|
|
|
198
178
|
const inquiry = { artefact: b.artefact, ...(b.issue ? { issue: b.issue } : {}) };
|
|
199
179
|
try {
|
|
200
180
|
if (b.new_episode) {
|
|
201
|
-
const res = await deps.miadi.request(approver.token, "POST", "/api/chronicle/episodes",
|
|
181
|
+
const res = await deps.miadi.request(approver.token, "POST", "/api/chronicle/episodes",
|
|
182
|
+
// The mint needs at least one reference: the request's own refs, an owner/repo#n made a link (jgwill/Miadi#884).
|
|
183
|
+
{ title: b.new_episode.title, ...(b.new_episode.goal ? { goal: b.new_episode.goal } : {}), inquiry, references: episodeReferences(record) }, 120_000);
|
|
202
184
|
return { outcome: "minted_and_woven", response: res };
|
|
203
185
|
}
|
|
204
186
|
const res = await deps.miadi.request(approver.token, "POST", `/api/chronicle/episodes/${encodeURIComponent(b.episode)}/inquiry`, inquiry, 120_000);
|
|
@@ -272,6 +254,10 @@ ${r.refs.length ? `<dt>References</dt><dd>${r.refs.map(escapeHtml).join("<br>")}
|
|
|
272
254
|
export class AlreadyDecided extends Error {
|
|
273
255
|
}
|
|
274
256
|
const deciding = new Set();
|
|
257
|
+
/** Whether a decision on this request is being recorded now, its run may still be going. */
|
|
258
|
+
export function isBeingDecided(recordId) {
|
|
259
|
+
return deciding.has(recordId);
|
|
260
|
+
}
|
|
275
261
|
export async function decide(r, person, choice, deps, opts) {
|
|
276
262
|
// Checked again here, with no await between the check and the claim: the
|
|
277
263
|
// page and the card (or two cards) may decide the same request at once.
|
|
@@ -287,6 +273,68 @@ export async function decide(r, person, choice, deps, opts) {
|
|
|
287
273
|
deciding.delete(r.id);
|
|
288
274
|
}
|
|
289
275
|
}
|
|
276
|
+
/** Whether a request was approved and its run failed, so a person may run it again. */
|
|
277
|
+
export function isRerunnable(r) {
|
|
278
|
+
return !!r && r.approval_state === "approved" && r.state === "blocked";
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Run an approved request again when its run failed (state `blocked`): a door
|
|
282
|
+
* it called was down or refused, and was fixed since. It runs as the person
|
|
283
|
+
* who asks, with the instructions it was approved with. The approval stays the
|
|
284
|
+
* decider's; the new receipt names who ran it again and keeps the failed
|
|
285
|
+
* outcome it replaces. jgwill/Miadi#884
|
|
286
|
+
*/
|
|
287
|
+
export async function rerun(r, person, deps, opts) {
|
|
288
|
+
const live = current(deps.ledger.all()).find((x) => x.id === r.id);
|
|
289
|
+
if (!isRerunnable(live) || deciding.has(r.id)) {
|
|
290
|
+
throw new AlreadyDecided(deciding.has(r.id) ? "This request is being run." : `This request is ${live?.approval_state ?? "gone"} and ${live?.state ?? "unknown"}; only an approved request whose run failed is run again.`);
|
|
291
|
+
}
|
|
292
|
+
deciding.add(r.id);
|
|
293
|
+
try {
|
|
294
|
+
const prior = (live.completion_receipt ?? {});
|
|
295
|
+
const instructions = typeof prior.instructions === "string" ? prior.instructions : undefined;
|
|
296
|
+
let receipt = {
|
|
297
|
+
decided_by: prior.decided_by,
|
|
298
|
+
decided_at: prior.decided_at,
|
|
299
|
+
decision: prior.decision,
|
|
300
|
+
...(instructions ? { instructions } : {}),
|
|
301
|
+
rerun_by: { person_id: person.id, name: person.name },
|
|
302
|
+
rerun_at: new Date().toISOString(),
|
|
303
|
+
via: opts.via,
|
|
304
|
+
replaces: { outcome: prior.outcome, ...(prior.status !== undefined ? { status: prior.status } : {}), ...(prior.response !== undefined ? { response: prior.response } : {}) },
|
|
305
|
+
};
|
|
306
|
+
let state = "completed";
|
|
307
|
+
try {
|
|
308
|
+
const outcome = await executeApproved(live, person, deps, instructions);
|
|
309
|
+
receipt = { ...receipt, ...outcome };
|
|
310
|
+
if (outcome.outcome === "failed")
|
|
311
|
+
state = "blocked";
|
|
312
|
+
if (outcome.outcome === "queued")
|
|
313
|
+
state = "in_progress";
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
receipt = { ...receipt, outcome: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
317
|
+
state = "blocked";
|
|
318
|
+
}
|
|
319
|
+
deps.ledger.append({
|
|
320
|
+
id: newRecordId(r.kind),
|
|
321
|
+
kind: r.kind,
|
|
322
|
+
created_at: new Date().toISOString(),
|
|
323
|
+
actor: { person_id: person.id, name: person.name, client_id: opts.via },
|
|
324
|
+
refs: r.refs,
|
|
325
|
+
evidence_refs: [],
|
|
326
|
+
supersedes: r.id,
|
|
327
|
+
approval_state: "approved",
|
|
328
|
+
state,
|
|
329
|
+
completion_receipt: receipt,
|
|
330
|
+
body: { decision: "rerun" },
|
|
331
|
+
});
|
|
332
|
+
return { decision: "approved", state, receipt };
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
deciding.delete(r.id);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
290
338
|
async function decideOnce(r, person, choice, deps, opts) {
|
|
291
339
|
const { instructions } = opts;
|
|
292
340
|
const decision = choice === "approve" || choice === "approve_with_changes" ? "approved" : choice === "request_revision" ? "revision_requested" : "declined";
|
|
@@ -388,11 +436,24 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
388
436
|
return void res.status(403).type("html").send(signInToRead(r, `Reading and deciding this request needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.`));
|
|
389
437
|
}
|
|
390
438
|
if (!body.decision)
|
|
391
|
-
return void res.type("html").send(approvePage(r, { token: writer ? token : undefined }));
|
|
439
|
+
return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined }));
|
|
392
440
|
if (!writer)
|
|
393
|
-
return void res.status(403).type("html").send(approvePage(r, { error: `Deciding needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.` }));
|
|
441
|
+
return void res.status(403).type("html").send(approvePage(deps, r, { error: `Deciding needs Miadi's write tier; ${person.name} has ${person.api_tier ?? "no API tier"}.` }));
|
|
442
|
+
if (body.decision === "rerun") {
|
|
443
|
+
let ran;
|
|
444
|
+
try {
|
|
445
|
+
ran = await rerun(r, person, deps, { via: String(body.via ?? "approval-page").slice(0, 60) });
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
if (error instanceof AlreadyDecided)
|
|
449
|
+
return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, error: error.message }));
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
const failedAgain = ran.receipt.outcome === "failed";
|
|
453
|
+
return void res.type("html").send(page("Recorded", `<h1>${failedAgain ? "Run again, and it failed again" : "Run again"}</h1>${summary(find(r.id))}<pre>${escapeHtml(JSON.stringify(ran.receipt, null, 2))}</pre>`));
|
|
454
|
+
}
|
|
394
455
|
if (r.approval_state !== "awaiting_approval") {
|
|
395
|
-
return void res.status(409).type("html").send(approvePage(r, { error: `This request is already ${r.approval_state}.` }));
|
|
456
|
+
return void res.status(409).type("html").send(approvePage(deps, r, { error: `This request is already ${r.approval_state}.` }));
|
|
396
457
|
}
|
|
397
458
|
const choice = DECISIONS.includes(String(body.decision)) ? body.decision : "decline";
|
|
398
459
|
const instructions = String(body.instructions ?? "").trim().slice(0, 20000) || undefined;
|
|
@@ -400,7 +461,7 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
400
461
|
return void res
|
|
401
462
|
.status(400)
|
|
402
463
|
.type("html")
|
|
403
|
-
.send(approvePage(r, { token, error: choice === "request_revision" ? "Say what to revise." : "Write the changes to apply." }));
|
|
464
|
+
.send(approvePage(deps, r, { token, error: choice === "request_revision" ? "Say what to revise." : "Write the changes to apply." }));
|
|
404
465
|
}
|
|
405
466
|
let decided;
|
|
406
467
|
try {
|
|
@@ -408,13 +469,21 @@ export function registerApprovalRoutes(app, deps) {
|
|
|
408
469
|
}
|
|
409
470
|
catch (error) {
|
|
410
471
|
if (error instanceof AlreadyDecided)
|
|
411
|
-
return void res.status(409).type("html").send(approvePage(find(r.id), { error: error.message }));
|
|
472
|
+
return void res.status(409).type("html").send(approvePage(deps, find(r.id), { error: error.message }));
|
|
412
473
|
throw error;
|
|
413
474
|
}
|
|
414
475
|
const { decision, receipt } = decided;
|
|
415
|
-
const
|
|
416
|
-
const heading = decision === "approved"
|
|
417
|
-
|
|
476
|
+
const p = projectionOf(find(r.id), deps);
|
|
477
|
+
const heading = decision === "approved"
|
|
478
|
+
? p?.stage === "failed"
|
|
479
|
+
? "Approved, but running it failed"
|
|
480
|
+
: p?.outstanding.length
|
|
481
|
+
? "Approved, changes still to do"
|
|
482
|
+
: "Approved"
|
|
483
|
+
: decision === "revision_requested"
|
|
484
|
+
? "Sent back for revision"
|
|
485
|
+
: "Declined";
|
|
486
|
+
res.type("html").send(page("Recorded", `<h1>${heading}</h1>${summary(find(r.id))}${projectionBlock(p, receipt)}`));
|
|
418
487
|
});
|
|
419
488
|
// A record's own page, for citations in ChatGPT: its author and anyone with Miadi's read tier may read it.
|
|
420
489
|
app.get("/records/:id", (req, res) => {
|
|
@@ -448,9 +517,12 @@ ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
|
448
517
|
<div class="row"><button class="primary" type="submit">Read</button></div></form>`;
|
|
449
518
|
}
|
|
450
519
|
function signInToRead(r, error) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
520
|
+
// The label names no one and no instruction: this page is shown before sign-in.
|
|
521
|
+
const open = r.approval_state === "awaiting_approval";
|
|
522
|
+
const label = projectRequest(r, { recording: isBeingDecided(r.id) })?.label ?? r.approval_state ?? "";
|
|
523
|
+
return page(open ? "A Miadi request awaits a decision" : "A Miadi request was decided", `<h1>${open ? "A request awaits a decision" : "A request was decided"}</h1>
|
|
524
|
+
<p>${escapeHtml(KIND_LABELS[r.kind] ?? r.kind)}, asked ${escapeHtml(r.created_at.slice(0, 16).replace("T", " "))} UTC. ${escapeHtml(label)}.</p>
|
|
525
|
+
<p>${open ? "Sign in to read it. A person with Miadi's write tier then decides; whoever asked can read it." : "Sign in to read what was decided and what came of it. Whoever asked, and anyone with Miadi's write tier, can read it."}</p>
|
|
454
526
|
${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
455
527
|
<form method="post">
|
|
456
528
|
<label for="t">Your Miadi person token</label>
|
|
@@ -459,13 +531,41 @@ ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
|
|
|
459
531
|
</form>`);
|
|
460
532
|
}
|
|
461
533
|
/** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
534
|
+
/**
|
|
535
|
+
* Where a request stands, as every surface of the connector reads it (request-state.ts):
|
|
536
|
+
* the factory queue is read for the two kinds that dispatch a brief.
|
|
537
|
+
*/
|
|
538
|
+
export function requestProjection(record, opts = {}) {
|
|
539
|
+
const queue = record.kind === "run_request" || record.kind === "work_proposal" ? readQueueState(record, opts.factoryDir ?? factoryDir()) : undefined;
|
|
540
|
+
return projectRequest(record, {
|
|
541
|
+
...(opts.reviewBase ? { reviewBase: opts.reviewBase } : {}),
|
|
542
|
+
...(queue ? { queue } : {}),
|
|
543
|
+
recording: isBeingDecided(record.id),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
function projectionOf(r, deps) {
|
|
547
|
+
return requestProjection(r, { reviewBase: deps.config.reviewUrl, factoryDir: deps.factoryDir });
|
|
548
|
+
}
|
|
549
|
+
/** Where a request stands, as a page shows it: the label, each line, the review it produced, and the receipt for audit. */
|
|
550
|
+
function projectionBlock(p, receipt) {
|
|
551
|
+
if (!p)
|
|
552
|
+
return "";
|
|
553
|
+
const lines = p.lines.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
|
|
554
|
+
const review = p.receipt_url ? `<p><a href="${escapeHtml(p.receipt_url)}" rel="noopener">${escapeHtml(p.receipt_url)}</a></p>` : "";
|
|
555
|
+
const audit = receipt ? `<details><summary>Receipt</summary><pre>${escapeHtml(JSON.stringify(receipt, null, 2))}</pre></details>` : "";
|
|
556
|
+
return `<section aria-label="Where it stands"><p><strong>${escapeHtml(p.label)}</strong></p>${lines ? `<ul>${lines}</ul>` : ""}${review}${audit}</section>`;
|
|
557
|
+
}
|
|
558
|
+
/** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
|
|
559
|
+
function approvePage(deps, r, o = {}) {
|
|
560
|
+
const open = r.approval_state === "awaiting_approval";
|
|
561
|
+
// A decision being recorded offers no buttons: a second click would decide the same request again.
|
|
562
|
+
const actionable = open && !isBeingDecided(r.id);
|
|
563
|
+
return page("Approve a Miadi request", `<h1>${open ? "Approve a request" : "A decided request"}</h1>
|
|
564
|
+
${actionable ? `<p>${escapeHtml(consequenceOf(r))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
|
|
465
565
|
${summary(r)}
|
|
466
|
-
${
|
|
566
|
+
${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}
|
|
467
567
|
${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
|
|
468
|
-
${
|
|
568
|
+
${actionable && o.token
|
|
469
569
|
? `<form method="post">
|
|
470
570
|
<input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
|
|
471
571
|
<label for="i">Instructions (needed to approve with changes or to send back)</label>
|
|
@@ -476,9 +576,14 @@ ${r.approval_state === "awaiting_approval" && o.token
|
|
|
476
576
|
<button type="submit" name="decision" value="request_revision">Send back for revision</button>
|
|
477
577
|
<button type="submit" name="decision" value="decline">Decline</button>
|
|
478
578
|
</div></form>`
|
|
479
|
-
:
|
|
579
|
+
: actionable
|
|
480
580
|
? "<p>A person with Miadi's write tier decides this request.</p>"
|
|
481
|
-
:
|
|
581
|
+
: isRerunnable(r) && o.token
|
|
582
|
+
? `<p>It was approved, and running it failed. When what it calls has been fixed, it can be run again with the same approval and instructions; the ledger records who ran it again.</p>
|
|
583
|
+
<form method="post">
|
|
584
|
+
<input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
|
|
585
|
+
<div class="row"><button class="primary" type="submit" name="decision" value="rerun">Run it again</button></div></form>`
|
|
586
|
+
: ""}`);
|
|
482
587
|
}
|
|
483
588
|
/** How a request is named to a person. */
|
|
484
589
|
export const KIND_LABELS = {
|
package/dist/decisions.d.ts
CHANGED
|
@@ -15,4 +15,4 @@ export declare class DecisionTokens {
|
|
|
15
15
|
* and calls `decide_request` through the host. In a host without that bridge it
|
|
16
16
|
* shows each request's approval page instead.
|
|
17
17
|
*/
|
|
18
|
-
export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(decided[r.id]){
|
|
18
|
+
export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){\n var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));\n var link=el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\");p.appendChild(link);a.appendChild(p);return a;\n }\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&out.projection;\n var already=!!(out&&out.error===\"already_decided\");\n var ok=out&&!out.error;\n delete pending[r.id];\n if(proj){decided[r.id]=projectionView(proj,already?\"This request was already decided. Here is where it stands.\":null);}\n else{decided[r.id]={cls:ok?\"done\":\"fail\",label:ok?(out.summary||\"Recorded.\"):((out&&out.message)||\"Not recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){delete pending[r.id];errors[r.id]=\"Not recorded: \"+(err&&err.message?err.message:String(err));render();});\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
|
package/dist/decisions.js
CHANGED
|
@@ -102,6 +102,21 @@ a{color:inherit}
|
|
|
102
102
|
var t=tokens();
|
|
103
103
|
list.forEach(function(r){root.appendChild(card(r,t[r.id]));});
|
|
104
104
|
}
|
|
105
|
+
// Where a request stands, as the connector projected it: the label, its lines, and the review it produced.
|
|
106
|
+
function decidedBlock(d){
|
|
107
|
+
var box=el("div",{role:"status",class:d.cls});
|
|
108
|
+
if(d.note)box.appendChild(el("p",{class:"line"},d.note));
|
|
109
|
+
box.appendChild(el("p",{class:"label"},d.label));
|
|
110
|
+
(d.lines||[]).forEach(function(l){box.appendChild(el("p",{class:"line"},l));});
|
|
111
|
+
if(d.url){var p=el("p",{class:"line"});p.appendChild(el("a",{href:d.url,target:"_blank",rel:"noopener"},d.url));box.appendChild(p);}
|
|
112
|
+
return box;
|
|
113
|
+
}
|
|
114
|
+
function toolResult(res){
|
|
115
|
+
var out=(res&&(res.structuredContent||res.structured_content))||null;
|
|
116
|
+
if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function projectionView(p,note){return {cls:p.stage==="failed"?"fail":"done",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}
|
|
105
120
|
function card(r,token){
|
|
106
121
|
var a=el("article",{"aria-label":r.title});
|
|
107
122
|
a.appendChild(el("span",{class:"kind"},r.kind_label||r.kind));
|
|
@@ -111,7 +126,25 @@ a{color:inherit}
|
|
|
111
126
|
if(r.intention)a.appendChild(el("p",null,r.intention));
|
|
112
127
|
var det=el("details");det.appendChild(el("summary",null,"What exactly is asked"));det.appendChild(el("pre",null,r.detail||""));a.appendChild(det);
|
|
113
128
|
var status=el("p",{role:"status"});
|
|
114
|
-
if(
|
|
129
|
+
if(r.projection&&r.projection.stage!=="awaiting_decision"&&(!decided[r.id]||["recording","dispatching"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}
|
|
130
|
+
if(decided[r.id]){
|
|
131
|
+
var shown=decided[r.id];a.appendChild(decidedBlock(shown));
|
|
132
|
+
if(["recording","dispatching"].indexOf(shown.stage)!==-1&&host()&&host().callTool){
|
|
133
|
+
var refresh=el("button",{type:"button"},"Refresh status");
|
|
134
|
+
refresh.addEventListener("click",function(){
|
|
135
|
+
refresh.disabled=true;
|
|
136
|
+
host().callTool("get_run_result",{record_id:r.id}).then(function(res){
|
|
137
|
+
var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));
|
|
138
|
+
if(p){decided[r.id]=projectionView(p);delete errors[r.id];}
|
|
139
|
+
else{errors[r.id]=(out&&out.message)||"Status could not be refreshed.";}
|
|
140
|
+
render();
|
|
141
|
+
},function(err){errors[r.id]="Status could not be refreshed: "+String(err&&err.message||err);render();});
|
|
142
|
+
});
|
|
143
|
+
a.appendChild(refresh);
|
|
144
|
+
}
|
|
145
|
+
if(errors[r.id])a.appendChild(el("p",{class:"fail",role:"status"},errors[r.id]));
|
|
146
|
+
return a;
|
|
147
|
+
}
|
|
115
148
|
if(pending[r.id]){status.className="note";status.textContent="Recording your decision…";a.appendChild(status);return a;}
|
|
116
149
|
if(!token||!host()||!host().callTool){
|
|
117
150
|
var p=el("p",{class:"note"});p.appendChild(document.createTextNode("Decide on its approval page: "));
|
|
@@ -130,10 +163,15 @@ a{color:inherit}
|
|
|
130
163
|
if((choice==="approve_with_changes"||choice==="request_revision")&&!text){status.className="fail";status.textContent=choice==="request_revision"?"Say what to revise.":"Write the changes to apply.";ta.focus();return;}
|
|
131
164
|
btns.forEach(function(x){x.disabled=true;});status.className="note";status.textContent="Recording your decision…";pending[r.id]=true;delete errors[r.id];
|
|
132
165
|
host().callTool("decide_request",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){
|
|
133
|
-
var out=(res
|
|
134
|
-
|
|
166
|
+
var out=toolResult(res);
|
|
167
|
+
// The connector answers with the request's projection, also when a click finds it decided already.
|
|
168
|
+
var proj=out&&out.projection;
|
|
169
|
+
var already=!!(out&&out.error==="already_decided");
|
|
135
170
|
var ok=out&&!out.error;
|
|
136
|
-
delete pending[r.id];
|
|
171
|
+
delete pending[r.id];
|
|
172
|
+
if(proj){decided[r.id]=projectionView(proj,already?"This request was already decided. Here is where it stands.":null);}
|
|
173
|
+
else{decided[r.id]={cls:ok?"done":"fail",label:ok?(out.summary||"Recorded."):((out&&out.message)||"Not recorded."),lines:[],url:null,note:null};}
|
|
174
|
+
render();
|
|
137
175
|
},function(err){delete pending[r.id];errors[r.id]="Not recorded: "+(err&&err.message?err.message:String(err));render();});
|
|
138
176
|
}
|
|
139
177
|
return a;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ConnectorRecord } from "./records.js";
|
|
2
|
+
export declare function factoryDir(env?: NodeJS.ProcessEnv): string;
|
|
3
|
+
export declare function runBriefFile(record: {
|
|
4
|
+
id: string;
|
|
5
|
+
}): string;
|
|
6
|
+
/** Evidence only: never creates a queue, dispatches work, or changes a request. */
|
|
7
|
+
export declare function readQueueState(record: ConnectorRecord, dir?: string): {
|
|
8
|
+
state: string;
|
|
9
|
+
file: string;
|
|
10
|
+
note?: undefined;
|
|
11
|
+
} | {
|
|
12
|
+
state: string;
|
|
13
|
+
file: string;
|
|
14
|
+
note: string;
|
|
15
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Shared, read-only factory queue evidence for every request-state surface. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export function factoryDir(env = process.env) {
|
|
6
|
+
return env.MIADI_FACTORY_DIR || join(homedir(), ".miadi", "factory");
|
|
7
|
+
}
|
|
8
|
+
export function runBriefFile(record) {
|
|
9
|
+
return `connector-${record.id.replace(/[^\w-]/g, "-")}.md`;
|
|
10
|
+
}
|
|
11
|
+
/** Evidence only: never creates a queue, dispatches work, or changes a request. */
|
|
12
|
+
export function readQueueState(record, dir = factoryDir()) {
|
|
13
|
+
const file = runBriefFile(record);
|
|
14
|
+
const queue = join(dir, "queue");
|
|
15
|
+
if (existsSync(join(queue, file)))
|
|
16
|
+
return { state: "queued", file };
|
|
17
|
+
if (existsSync(join(queue, "delivered", file)))
|
|
18
|
+
return { state: "delivered", file };
|
|
19
|
+
if (existsSync(join(queue, "cancelled", file)))
|
|
20
|
+
return { state: "cancelled", file };
|
|
21
|
+
if (existsSync(join(queue, "stale", file)))
|
|
22
|
+
return { state: "stale", file, note: "Its seat did not take it in time; the intake moved it aside." };
|
|
23
|
+
return { state: record.approval_state === "approved" ? "missing" : "not_queued", file };
|
|
24
|
+
}
|
package/dist/records.d.ts
CHANGED
|
@@ -87,4 +87,7 @@ export declare function newRecordId(kind: RecordKind): string;
|
|
|
87
87
|
* Fold the ledger: a record superseded by a later one shows the later one's
|
|
88
88
|
* state, approval and receipt, keeping its own id and history.
|
|
89
89
|
*/
|
|
90
|
+
export { projectRequest, APPROVABLE_KINDS, APPROVABLE } from "./request-state.js";
|
|
91
|
+
export { readQueueState, runBriefFile } from "./queue-state.js";
|
|
92
|
+
export type { RequestProjection, RequestStage, RequestStanding, RequestDecision, ProjectionContext } from "./request-state.js";
|
|
90
93
|
export declare function current(records: ConnectorRecord[]): ConnectorRecord[];
|
package/dist/records.js
CHANGED
|
@@ -76,6 +76,8 @@ export function newRecordId(kind) {
|
|
|
76
76
|
* Fold the ledger: a record superseded by a later one shows the later one's
|
|
77
77
|
* state, approval and receipt, keeping its own id and history.
|
|
78
78
|
*/
|
|
79
|
+
export { projectRequest, APPROVABLE_KINDS, APPROVABLE } from "./request-state.js";
|
|
80
|
+
export { readQueueState, runBriefFile } from "./queue-state.js";
|
|
79
81
|
export function current(records) {
|
|
80
82
|
const byId = new Map();
|
|
81
83
|
for (const r of records) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a request stands, read once for every surface that shows it: the
|
|
3
|
+
* decision card in the conversation, the approval page, get_run_result and
|
|
4
|
+
* Miadi's /research (jgwill/Miadi#889, Mission A). A surface shows the
|
|
5
|
+
* projection's `label` and `lines`. It does not read the record's state,
|
|
6
|
+
* approval state or receipt by itself.
|
|
7
|
+
*
|
|
8
|
+
* Stages, from the ledger's folded record (see `current()`):
|
|
9
|
+
* - awaiting_decision: awaiting_approval, nothing decided
|
|
10
|
+
* - declined, sent_back: decided, nothing ran
|
|
11
|
+
* - dispatching: approved, and its brief is queued or running (state in_progress)
|
|
12
|
+
* - completed: approved, and its run finished (state completed)
|
|
13
|
+
* - failed: approved, and its run failed (state blocked); the same approval may run it again
|
|
14
|
+
*
|
|
15
|
+
* Approving with changes keeps the changes with the decision. A kind whose run
|
|
16
|
+
* does not carry instructions (approve.ts runBrief carries them only for runs and
|
|
17
|
+
* work proposals) lists them as outstanding. The projection never says they ran.
|
|
18
|
+
*/
|
|
19
|
+
import type { ConnectorRecord } from "./records.js";
|
|
20
|
+
export declare const APPROVABLE_KINDS: readonly ["run_request", "review_operation_request", "deep_diver_request", "review_episode_link_request", "work_proposal", "session_completion_proposal", "inquiry_episode_request"];
|
|
21
|
+
export declare const APPROVABLE: ReadonlySet<string>;
|
|
22
|
+
/** `recording`: a decision was taken a moment ago and its run may still be going; it is not the ledger's state. */
|
|
23
|
+
export type RequestStage = "awaiting_decision" | "recording" | "declined" | "sent_back" | "dispatching" | "completed" | "failed";
|
|
24
|
+
export type RequestStanding = "waiting" | "open" | "done" | "stopped";
|
|
25
|
+
export interface RequestDecision {
|
|
26
|
+
/** approve, approve_with_changes, request_revision or decline, as the person chose. */
|
|
27
|
+
choice: string;
|
|
28
|
+
by: string;
|
|
29
|
+
by_id?: string;
|
|
30
|
+
at?: string;
|
|
31
|
+
via?: string;
|
|
32
|
+
instructions?: string;
|
|
33
|
+
rerun_by?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface RequestProjection {
|
|
36
|
+
ref: string;
|
|
37
|
+
kind: string;
|
|
38
|
+
stage: RequestStage;
|
|
39
|
+
standing: RequestStanding;
|
|
40
|
+
/** Plain words for where it stands. Names, times and instructions are not in it, so it is safe before sign-in. */
|
|
41
|
+
label: string;
|
|
42
|
+
decision?: RequestDecision;
|
|
43
|
+
/** The receipt's outcome: queued, done, failed, linked, woven, minted_and_woven, marked_ready, decided, nothing_to_run. */
|
|
44
|
+
outcome?: string;
|
|
45
|
+
failure?: string;
|
|
46
|
+
/** The review the request produced or changed, at the review service, when the receipt names one. */
|
|
47
|
+
receipt_url?: string;
|
|
48
|
+
/** Approved and failed: a write-tier person may run it again with the same approval and instructions. */
|
|
49
|
+
rerun: {
|
|
50
|
+
available: boolean;
|
|
51
|
+
by?: string;
|
|
52
|
+
};
|
|
53
|
+
/** Work the approver asked for that did not run. Empty when nothing is outstanding. */
|
|
54
|
+
outstanding: string[];
|
|
55
|
+
/** Every sentence a surface shows under the label, names and instructions included. */
|
|
56
|
+
lines: string[];
|
|
57
|
+
}
|
|
58
|
+
export interface ProjectionContext {
|
|
59
|
+
/** The review service's public address, for receipt_url. Default MIADI_REVIEW_BASE_URL, else the public service. */
|
|
60
|
+
reviewBase?: string;
|
|
61
|
+
/** The factory queue state of a brief (queued, delivered, missing, not_queued), when the caller read it. */
|
|
62
|
+
queue?: {
|
|
63
|
+
state: string;
|
|
64
|
+
};
|
|
65
|
+
/** A decision on this request is being recorded now (its run may still be going). */
|
|
66
|
+
recording?: boolean;
|
|
67
|
+
}
|
|
68
|
+
/** The projection of an approvable request, or null for a record that is not one. */
|
|
69
|
+
export declare function projectRequest(record: ConnectorRecord, ctx?: ProjectionContext): RequestProjection | null;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export const APPROVABLE_KINDS = [
|
|
2
|
+
"run_request",
|
|
3
|
+
"review_operation_request",
|
|
4
|
+
"deep_diver_request",
|
|
5
|
+
"review_episode_link_request",
|
|
6
|
+
"work_proposal",
|
|
7
|
+
"session_completion_proposal",
|
|
8
|
+
"inquiry_episode_request",
|
|
9
|
+
];
|
|
10
|
+
export const APPROVABLE = new Set(APPROVABLE_KINDS);
|
|
11
|
+
/** The kinds whose approved run carries the approver's instructions into what it does. */
|
|
12
|
+
const CARRIES_INSTRUCTIONS = new Set(["run_request", "work_proposal"]);
|
|
13
|
+
const CHOICE_WORDS = {
|
|
14
|
+
approve: "Approved",
|
|
15
|
+
approve_with_changes: "Approved with changes",
|
|
16
|
+
request_revision: "Sent back for revision",
|
|
17
|
+
decline: "Declined",
|
|
18
|
+
};
|
|
19
|
+
const OUTCOME_WORDS = {
|
|
20
|
+
queued: "queued for the factory intake",
|
|
21
|
+
done: "done",
|
|
22
|
+
linked: "filed in the episode",
|
|
23
|
+
woven: "woven into the episode",
|
|
24
|
+
minted_and_woven: "a new episode minted with it",
|
|
25
|
+
marked_ready: "marked ready for Deep Diver",
|
|
26
|
+
decided: "recorded",
|
|
27
|
+
nothing_to_run: "nothing to run",
|
|
28
|
+
failed: "failed",
|
|
29
|
+
};
|
|
30
|
+
const NOT_CARRIED = "The changes you approved are recorded and did not run: this kind of request does not carry instructions into its run. They are still to do.";
|
|
31
|
+
const text = (v) => (typeof v === "string" ? v.trim() : "");
|
|
32
|
+
function reviewBaseOf(ctx) {
|
|
33
|
+
const base = ctx.reviewBase ?? process.env.MIADI_REVIEW_BASE_URL ?? "https://miadi-review-service.vercel.app";
|
|
34
|
+
return base.replace(/\/$/, "");
|
|
35
|
+
}
|
|
36
|
+
/** The projection of an approvable request, or null for a record that is not one. */
|
|
37
|
+
export function projectRequest(record, ctx = {}) {
|
|
38
|
+
if (!APPROVABLE.has(record.kind))
|
|
39
|
+
return null;
|
|
40
|
+
const receipt = (record.completion_receipt ?? {});
|
|
41
|
+
const choice = text(receipt.decision) || undefined;
|
|
42
|
+
const instructions = text(receipt.instructions) || undefined;
|
|
43
|
+
const decision = record.approval_state === "awaiting_approval" || !choice
|
|
44
|
+
? undefined
|
|
45
|
+
: {
|
|
46
|
+
choice,
|
|
47
|
+
by: text(receipt.decided_by?.name) || "someone",
|
|
48
|
+
...(receipt.decided_by?.person_id ? { by_id: String(receipt.decided_by.person_id) } : {}),
|
|
49
|
+
...(text(receipt.decided_at) ? { at: text(receipt.decided_at) } : {}),
|
|
50
|
+
...(text(receipt.via) ? { via: text(receipt.via) } : {}),
|
|
51
|
+
...(instructions ? { instructions } : {}),
|
|
52
|
+
...(text(receipt.rerun_by?.name) ? { rerun_by: text(receipt.rerun_by.name) } : {}),
|
|
53
|
+
};
|
|
54
|
+
const base = { ref: record.id, kind: record.kind, rerun: { available: false }, outstanding: [] };
|
|
55
|
+
if (record.approval_state === "awaiting_approval" && ctx.recording) {
|
|
56
|
+
return finish({ ...base, stage: "recording", standing: "open", label: "Being recorded" }, [
|
|
57
|
+
"A decision on this request is being recorded. It shows here when it is done.",
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
if (record.approval_state === "awaiting_approval") {
|
|
61
|
+
return finish({ ...base, stage: "awaiting_decision", standing: "waiting", label: "Awaiting a decision" }, [
|
|
62
|
+
"Waiting for a person with Miadi's write tier to decide.",
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
if (record.approval_state === "declined") {
|
|
66
|
+
return finish({ ...base, stage: "declined", standing: "stopped", decision, label: "Declined" }, decisionLines(decision));
|
|
67
|
+
}
|
|
68
|
+
if (record.approval_state === "revision_requested") {
|
|
69
|
+
const lines = decisionLines(decision);
|
|
70
|
+
if (instructions)
|
|
71
|
+
lines.push(`Instructions for whoever asked: ${instructions}`);
|
|
72
|
+
return finish({ ...base, stage: "sent_back", standing: "waiting", decision, label: "Sent back for revision" }, lines);
|
|
73
|
+
}
|
|
74
|
+
if (record.approval_state !== "approved")
|
|
75
|
+
return null;
|
|
76
|
+
const changes = choice === "approve_with_changes";
|
|
77
|
+
const outcome = text(receipt.outcome) || undefined;
|
|
78
|
+
const outcomeWord = outcome ? (OUTCOME_WORDS[outcome] ?? outcome.replace(/_/g, " ")) : undefined;
|
|
79
|
+
const rerunBy = decision?.rerun_by;
|
|
80
|
+
if (record.state === "blocked") {
|
|
81
|
+
const resp = (receipt.response ?? {});
|
|
82
|
+
const status = typeof receipt.status === "number" ? receipt.status : undefined;
|
|
83
|
+
const failure = text(receipt.message) || text(resp.message) || text(resp.error) || (status !== undefined ? `the door answered ${status}` : "the door it called refused");
|
|
84
|
+
const outstanding = outstandingFor(record, decision);
|
|
85
|
+
const lines = [...decisionLines(decision), `Failure: ${failure}`];
|
|
86
|
+
if (receipt.outcome)
|
|
87
|
+
lines.push(`Outcome: ${outcomeWord}.`);
|
|
88
|
+
lines.push(...outstanding);
|
|
89
|
+
return finish({
|
|
90
|
+
...base,
|
|
91
|
+
stage: "failed",
|
|
92
|
+
standing: "stopped",
|
|
93
|
+
decision,
|
|
94
|
+
label: `${changes ? "Approved with changes" : "Approved"}, but running it failed`,
|
|
95
|
+
outcome: outcome ?? "failed",
|
|
96
|
+
failure,
|
|
97
|
+
rerun: { available: true, ...(rerunBy ? { by: rerunBy } : {}) },
|
|
98
|
+
outstanding,
|
|
99
|
+
}, lines);
|
|
100
|
+
}
|
|
101
|
+
if (record.state === "in_progress") {
|
|
102
|
+
const delivered = ctx.queue?.state === "delivered";
|
|
103
|
+
const outstanding = [];
|
|
104
|
+
if (ctx.queue?.state === "missing")
|
|
105
|
+
outstanding.push("The brief is missing from the factory queue, so the intake cannot deliver it.");
|
|
106
|
+
if (ctx.queue?.state === "cancelled" || ctx.queue?.state === "stale") {
|
|
107
|
+
outstanding.push(`The factory intake set the brief aside (${ctx.queue.state}), so nothing is running for it.`);
|
|
108
|
+
}
|
|
109
|
+
const lines = [...decisionLines(decision), outcomeWord ? `Outcome: ${outcomeWord}.` : "Outcome: queued for the factory intake."];
|
|
110
|
+
if (delivered)
|
|
111
|
+
lines.push("Delivered to an agent session.");
|
|
112
|
+
lines.push(...outstanding);
|
|
113
|
+
return finish({
|
|
114
|
+
...base,
|
|
115
|
+
stage: "dispatching",
|
|
116
|
+
standing: "open",
|
|
117
|
+
decision,
|
|
118
|
+
label: `${changes ? "Approved with changes" : "Approved"}, ${delivered ? "delivered to a session" : "dispatching"}`,
|
|
119
|
+
outcome,
|
|
120
|
+
outstanding,
|
|
121
|
+
}, lines);
|
|
122
|
+
}
|
|
123
|
+
const outstanding = outstandingFor(record, decision);
|
|
124
|
+
const receiptUrl = reviewUrlOf(record, receipt, ctx);
|
|
125
|
+
const lines = [...decisionLines(decision)];
|
|
126
|
+
if (outcomeWord)
|
|
127
|
+
lines.push(`Outcome: ${outcomeWord}.`);
|
|
128
|
+
if (receiptUrl)
|
|
129
|
+
lines.push(`Review: ${receiptUrl}`);
|
|
130
|
+
lines.push(...outstanding);
|
|
131
|
+
return finish({
|
|
132
|
+
...base,
|
|
133
|
+
stage: "completed",
|
|
134
|
+
standing: outstanding.length ? "open" : "done",
|
|
135
|
+
decision,
|
|
136
|
+
label: outstanding.length
|
|
137
|
+
? "Approved with changes, changes outstanding"
|
|
138
|
+
: `${changes ? "Approved with changes" : "Approved"}${outcomeWord ? `, ${outcomeWord}` : ""}`,
|
|
139
|
+
outcome,
|
|
140
|
+
...(receiptUrl ? { receipt_url: receiptUrl } : {}),
|
|
141
|
+
outstanding,
|
|
142
|
+
}, lines);
|
|
143
|
+
}
|
|
144
|
+
function finish(p, lines) {
|
|
145
|
+
return { ...p, lines: lines.filter((l) => l.length > 0) };
|
|
146
|
+
}
|
|
147
|
+
function decisionLines(decision) {
|
|
148
|
+
if (!decision)
|
|
149
|
+
return [];
|
|
150
|
+
const when = decision.at ? ` at ${decision.at}` : "";
|
|
151
|
+
const through = decision.via ? ` (through ${decision.via})` : "";
|
|
152
|
+
const out = [`${CHOICE_WORDS[decision.choice] ?? decision.choice} by ${decision.by}${when}${through}.`];
|
|
153
|
+
if (decision.instructions)
|
|
154
|
+
out.push(`Instructions: ${decision.instructions}`);
|
|
155
|
+
if (decision.rerun_by)
|
|
156
|
+
out.push(`Run again by ${decision.rerun_by}.`);
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function outstandingFor(record, decision) {
|
|
160
|
+
if (decision?.choice !== "approve_with_changes" || !decision.instructions)
|
|
161
|
+
return [];
|
|
162
|
+
if (CARRIES_INSTRUCTIONS.has(record.kind))
|
|
163
|
+
return [];
|
|
164
|
+
return [NOT_CARRIED];
|
|
165
|
+
}
|
|
166
|
+
function reviewUrlOf(record, receipt, ctx) {
|
|
167
|
+
if (record.kind !== "review_operation_request" && record.kind !== "deep_diver_request")
|
|
168
|
+
return undefined;
|
|
169
|
+
if (receipt.outcome !== "done" && receipt.outcome !== "marked_ready")
|
|
170
|
+
return undefined;
|
|
171
|
+
const id = text(receipt.response?.id) || text(record.body.review_id);
|
|
172
|
+
return id ? `${reviewBaseOf(ctx)}/review/${encodeURIComponent(id)}` : undefined;
|
|
173
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -16,7 +16,7 @@ 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 } from "./approve.js";
|
|
19
|
+
import { AlreadyDecided, DECISIONS, KIND_LABELS, consequenceOf, decide, isApprovable, readQueueState, requestProjection } from "./approve.js";
|
|
20
20
|
import { DECISIONS_CARD_HTML, DECISIONS_CARD_URI, DECISION_TOKENS_META } from "./decisions.js";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { artefactVisibility, readArtefactWeave, saveResearchArtefact } from "@miadi/inquiry-weave";
|
|
@@ -461,6 +461,8 @@ export function registerTools(server, ctx) {
|
|
|
461
461
|
record: r,
|
|
462
462
|
approval_url: r.approval_state === "awaiting_approval" ? approvalUrl(r.id) : undefined,
|
|
463
463
|
queue: r.kind === "run_request" || r.kind === "work_proposal" ? readQueueState(r, ctx.factoryDir) : undefined,
|
|
464
|
+
// Where the request stands, in the words every surface uses (jgwill/Miadi#889).
|
|
465
|
+
projection: requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
464
466
|
...(r.approval_state === "revision_requested"
|
|
465
467
|
? { revision: { instructions: r.completion_receipt?.instructions, next: `Submit a revised request that cites ${r.id} in refs.` } }
|
|
466
468
|
: {}),
|
|
@@ -1083,9 +1085,9 @@ export function registerTools(server, ctx) {
|
|
|
1083
1085
|
const { person, auth } = who(extra);
|
|
1084
1086
|
const writer = person.api_tier === "write";
|
|
1085
1087
|
const waiting = all()
|
|
1086
|
-
.filter((r) =>
|
|
1088
|
+
.filter((r) => isApprovable(r))
|
|
1087
1089
|
.filter((r) => writer || r.actor.person_id === person.id)
|
|
1088
|
-
.filter((r) =>
|
|
1090
|
+
.filter((r) => record_id ? r.id === record_id : r.approval_state === "awaiting_approval")
|
|
1089
1091
|
.slice(-20)
|
|
1090
1092
|
.reverse();
|
|
1091
1093
|
const canDecide = writer && auth.scopes.includes(SCOPE_APPROVE) && Boolean(ctx.decisionTokens);
|
|
@@ -1107,17 +1109,20 @@ export function registerTools(server, ctx) {
|
|
|
1107
1109
|
consequence: consequenceOf(r),
|
|
1108
1110
|
detail: detailOf(r),
|
|
1109
1111
|
approval_url: approvalUrl(r.id),
|
|
1112
|
+
projection: requestProjection(r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir }),
|
|
1110
1113
|
}));
|
|
1111
|
-
const
|
|
1114
|
+
const actionable = decisions.filter((d) => d.projection?.stage === "awaiting_decision");
|
|
1115
|
+
const tokens = canDecide ? Object.fromEntries(actionable.map((r) => [r.id, ctx.decisionTokens.issue(r.id, person.id, auth.clientId)])) : {};
|
|
1112
1116
|
const structured = {
|
|
1113
1117
|
decisions,
|
|
1114
1118
|
count: decisions.length,
|
|
1119
|
+
awaiting_count: actionable.length,
|
|
1115
1120
|
can_decide_here: canDecide,
|
|
1116
1121
|
...(reason ? { reason } : {}),
|
|
1117
1122
|
...(decisions.length ? {} : { message: record_id ? "That request is not awaiting a decision." : "No request is waiting for a decision." }),
|
|
1118
1123
|
};
|
|
1119
1124
|
const text = decisions.length
|
|
1120
|
-
? `${decisions.length} request(s)
|
|
1125
|
+
? `${decisions.length} request(s) shown as cards; ${actionable.length} await a decision. ${canDecide ? "The person decides an awaiting request on its card; do not decide or claim a decision for them." : reason}`
|
|
1121
1126
|
: structured.message;
|
|
1122
1127
|
return { structuredContent: structured, content: [{ type: "text", text }], _meta: { [DECISION_TOKENS_META]: tokens } };
|
|
1123
1128
|
}
|
|
@@ -1153,28 +1158,43 @@ export function registerTools(server, ctx) {
|
|
|
1153
1158
|
const check = ctx.decisionTokens.verify(a.decision_token, r.id, person.id, auth.clientId);
|
|
1154
1159
|
if (check !== "ok")
|
|
1155
1160
|
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.");
|
|
1156
|
-
|
|
1157
|
-
|
|
1161
|
+
// A stale card or a second client gets where the request stands, not a bare refusal (jgwill/Miadi#889).
|
|
1162
|
+
const projectionNow = () => requestProjection(all().find((x) => x.id === r.id) ?? r, { reviewBase: ctx.approval?.config.reviewUrl, factoryDir: ctx.factoryDir });
|
|
1163
|
+
if (r.approval_state !== "awaiting_approval") {
|
|
1164
|
+
throw new ToolRefusal("already_decided", `This request is already ${r.approval_state}.`, { projection: projectionNow() });
|
|
1165
|
+
}
|
|
1158
1166
|
const instructions = a.instructions?.trim() || undefined;
|
|
1159
1167
|
if ((a.decision === "approve_with_changes" || a.decision === "request_revision") && !instructions) {
|
|
1160
1168
|
throw new ToolRefusal("instructions_required", a.decision === "request_revision" ? "Say what to revise." : "Write the changes to apply.");
|
|
1161
1169
|
}
|
|
1162
1170
|
const client = ctx.oauthState.read().clients[auth.clientId];
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1171
|
+
let decided;
|
|
1172
|
+
try {
|
|
1173
|
+
decided = await decide(r, person, a.decision, ctx.approval, {
|
|
1174
|
+
instructions,
|
|
1175
|
+
via: `card:${String(client?.client_name ?? auth.clientId).slice(0, 50)}`,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
// A decision recorded a moment earlier, by another client, is reported with where it stands.
|
|
1180
|
+
if (error instanceof AlreadyDecided)
|
|
1181
|
+
throw new ToolRefusal("already_decided", error.message, { projection: projectionNow() });
|
|
1182
|
+
throw error;
|
|
1183
|
+
}
|
|
1184
|
+
const { decision, state, receipt } = decided;
|
|
1185
|
+
const projection = projectionNow();
|
|
1167
1186
|
const summary = decision === "approved"
|
|
1168
1187
|
? `Approved by ${person.name}. ${receipt.outcome === "failed" ? "Running it failed; see the receipt." : `Outcome: ${String(receipt.outcome ?? "recorded").replace(/_/g, " ")}.`}`
|
|
1169
1188
|
: decision === "revision_requested"
|
|
1170
1189
|
? `Sent back for revision by ${person.name}.`
|
|
1171
1190
|
: `Declined by ${person.name}.`;
|
|
1172
|
-
const out = { record_id: r.id, decision, state, summary, receipt };
|
|
1191
|
+
const out = { record_id: r.id, decision, state, summary, receipt, projection };
|
|
1173
1192
|
return { structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1174
1193
|
}
|
|
1175
1194
|
catch (error) {
|
|
1176
1195
|
if (error instanceof ToolRefusal || error instanceof AlreadyDecided) {
|
|
1177
|
-
const
|
|
1196
|
+
const detail = error instanceof ToolRefusal ? error.detail : undefined;
|
|
1197
|
+
const out = error instanceof AlreadyDecided ? { error: "already_decided", message: error.message } : { error: error.code, message: error.message, ...detail };
|
|
1178
1198
|
return { isError: true, structuredContent: out, content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
|
|
1179
1199
|
}
|
|
1180
1200
|
return fail({ error: "failed", message: error instanceof Error ? error.message : String(error) });
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.9";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.1.
|
|
1
|
+
export const VERSION = "0.1.9";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miadi/connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@miadi/inquiry-weave": "0.18.
|
|
29
|
+
"@miadi/inquiry-weave": "0.18.5",
|
|
30
30
|
"@miadi/node-service-kit": "0.1.2",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
32
32
|
"express": "^5.2.1",
|