@miadi/connector 0.1.18 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/approve.js CHANGED
@@ -12,8 +12,10 @@
12
12
  import express from "express";
13
13
  import { mkdirSync, writeFileSync } from "node:fs";
14
14
  import { execFileSync } from "node:child_process";
15
+ import { randomUUID } from "node:crypto";
15
16
  import { join } from "node:path";
16
17
  import { escapeHtml } from "@miadi/node-service-kit/format";
18
+ import { DEFAULT_DECISION_WAIT_MS } from "./config.js";
17
19
  import { MiadiError } from "./miadi.js";
18
20
  import { MiadiUnavailable } from "./people.js";
19
21
  import { current, newRecordId } from "./records.js";
@@ -22,7 +24,45 @@ import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
22
24
  import { factoryLedgerPath, readFactoryReceipts, receiptsForRecord } from "./factory-receipt.js";
23
25
  import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
24
26
  import { contentHash, versionOf } from "./revisions.js";
27
+ import { canReadReading } from "./reading/privacy.js";
28
+ import { authorizationReceipt, authorizeProvider, briefAttributes, CHOICE_TTL_MS, CODEX_POLICIES, isPersonId, needsPersonChoice, providerPolicyOf, } from "./provider-policy.js";
25
29
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
30
+ /**
31
+ * One line per step of a decision, for the journal: the request, a correlation id,
32
+ * the door it came through, timings, the outcome and the HTTP status the run met.
33
+ * Never a token, never instructions (jgwill/Miadi#924).
34
+ */
35
+ export function logDecision(deps, event) {
36
+ const line = { evt: "connector.decision", at: new Date().toISOString(), ...event };
37
+ try {
38
+ (deps.log ?? ((e) => console.log(JSON.stringify(e))))(line);
39
+ }
40
+ catch {
41
+ /* a log that fails never fails a decision */
42
+ }
43
+ }
44
+ /** How long a decision answer waits for its run before saying it is recorded and still running. */
45
+ export function decisionWaitOf(deps) {
46
+ return deps.config.decisionWaitMs ?? DEFAULT_DECISION_WAIT_MS;
47
+ }
48
+ /** What `settleWithin` answers when the promise has not settled in time; the promise goes on. */
49
+ export const STILL_RUNNING = Symbol("still_running");
50
+ /** The promise's value if it settles within `ms`, else STILL_RUNNING. A rejection after the bound is caught, never unhandled. */
51
+ export async function settleWithin(p, ms) {
52
+ let timer;
53
+ const bound = new Promise((resolve) => {
54
+ timer = setTimeout(() => resolve(STILL_RUNNING), ms);
55
+ });
56
+ try {
57
+ const out = await Promise.race([p, bound]);
58
+ if (out === STILL_RUNNING)
59
+ p.catch(() => { });
60
+ return out;
61
+ }
62
+ finally {
63
+ clearTimeout(timer);
64
+ }
65
+ }
26
66
  /**
27
67
  * What a person may decide on a request. Approving with changes runs it with the
28
68
  * approver's instructions carried into what runs; a revision sends it back to
@@ -65,8 +105,10 @@ export function proposalTask(record) {
65
105
  }
66
106
  return lines.join("\n");
67
107
  }
68
- export function runBrief(record, approver, sessions, instructions) {
108
+ export function runBrief(record, approver, sessions, instructions, providerAttrs = {}) {
69
109
  const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
110
+ // A request recorded before providers were resolved (jgwill/Miadi#893) names only what was asked (#926 review RR7).
111
+ const provider = body.resolved_provider ?? body.provider;
70
112
  const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
71
113
  const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
72
114
  // A new session opens where the request says, else where the factory runner's config says (defaultCwd): no host path here (#911).
@@ -78,7 +120,9 @@ export function runBrief(record, approver, sessions, instructions) {
78
120
  `record="${attr(record.id)}"`,
79
121
  `version="${versionOf(record)}"`,
80
122
  // The provider the connector verified at request time (jgwill/Miadi#893); the intake may honour it or record why not.
81
- ...(body.resolved_provider ? [`provider="${attr(body.resolved_provider)}"`] : []),
123
+ ...(provider ? [`provider="${attr(provider)}"`] : []),
124
+ // What authorizes a provider that needs the requester's own choice; the runner refuses Codex without it (jgwill/Miadi#926).
125
+ ...Object.entries(providerAttrs).map(([k, v]) => `${k}="${attr(v)}"`),
82
126
  ...(to.startsWith("new:") && cwd ? [`cwd="${attr(cwd)}"`] : []),
83
127
  ];
84
128
  const lines = [
@@ -151,6 +195,11 @@ export async function executeApproved(record, approver, deps, instructions) {
151
195
  return { status: res.status, ok: res.ok, response: parsed };
152
196
  };
153
197
  const queueBrief = () => {
198
+ // Checked again where the brief is written: an approval, and every rerun of it (jgwill/Miadi#926).
199
+ const authorization = record.kind === "run_request" ? authorizeProvider(record, deps.ledger.all()) : undefined;
200
+ if (authorization && !authorization.authorized) {
201
+ return { outcome: "failed", provider_authorization: authorizationReceipt(authorization), message: `No brief was written. ${authorization.explanation}` };
202
+ }
154
203
  const queue = join(deps.factoryDir ?? factoryDir(), "queue");
155
204
  mkdirSync(queue, { recursive: true });
156
205
  // A lister that cannot answer means no session is known live: the brief opens a new one (#922 R16).
@@ -161,10 +210,16 @@ export async function executeApproved(record, approver, deps, instructions) {
161
210
  catch {
162
211
  sessions = [];
163
212
  }
164
- const { to, brief } = runBrief(record, approver, sessions, instructions);
213
+ const { to, brief } = runBrief(record, approver, sessions, instructions, authorization ? briefAttributes(authorization) : {});
165
214
  const file = runBriefFile(record);
166
215
  writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
167
- return { outcome: "queued", to, file, note: "The factory intake delivers queued briefs every 5 minutes." };
216
+ return {
217
+ outcome: "queued",
218
+ to,
219
+ file,
220
+ ...(authorization ? { provider_authorization: authorizationReceipt(authorization) } : {}),
221
+ note: "The factory intake delivers queued briefs every 5 minutes.",
222
+ };
168
223
  };
169
224
  switch (record.kind) {
170
225
  case "run_request":
@@ -300,9 +355,15 @@ ${versionOf(r) > 1 ? `<dt>Version</dt><dd>${versionOf(r)}, the one a decision he
300
355
  * caller has already checked who the person is, their write tier, and that the
301
356
  * request still awaits approval.
302
357
  */
303
- /** A request is decided once: a second decision while one runs, or after it, is refused. */
358
+ /** A request is decided once: a second, different decision while one runs, or after it, is refused. */
304
359
  export class AlreadyDecided extends Error {
305
360
  }
361
+ /**
362
+ * A decision that differs from the one recorded or being recorded (another choice, or other
363
+ * instructions). The same decision sent again is not refused: it answers with the first one's receipt (#924).
364
+ */
365
+ export class DecisionConflict extends AlreadyDecided {
366
+ }
306
367
  /** A decision on a version the request no longer holds: it was revised after the page or card was read (#919). */
307
368
  export class StaleVersion extends AlreadyDecided {
308
369
  currentVersion;
@@ -313,32 +374,129 @@ export class StaleVersion extends AlreadyDecided {
313
374
  this.decidedVersion = decidedVersion;
314
375
  }
315
376
  }
316
- const deciding = new Set();
377
+ /**
378
+ * An approval of a run whose provider needs the requester's own choice for this mission,
379
+ * and has none (jgwill/Miadi#926). Declining or sending it back stays possible.
380
+ */
381
+ export class ProviderChoiceRequired extends Error {
382
+ authorization;
383
+ constructor(authorization) {
384
+ super(authorization.explanation);
385
+ this.authorization = authorization;
386
+ }
387
+ }
388
+ /** The decisions being recorded now, by request id: one at a time per request, in this process. */
389
+ const deciding = new Map();
317
390
  /** Whether a decision on this request is being recorded now, its run may still be going. */
318
391
  export function isBeingDecided(recordId) {
319
392
  return deciding.has(recordId);
320
393
  }
394
+ const APPROVAL_OF = {
395
+ approve: "approved",
396
+ approve_with_changes: "approved",
397
+ request_revision: "revision_requested",
398
+ decline: "declined",
399
+ };
400
+ /** The approval state a choice leaves a request in. */
401
+ export function approvalStateOf(choice) {
402
+ return APPROVAL_OF[choice];
403
+ }
404
+ const sameDecision = (a, b) => a.choice === b.choice && String(a.instructions ?? "").trim() === String(b.instructions ?? "").trim();
405
+ /**
406
+ * Record a person's decision once. The page and the card (or two cards, or a host
407
+ * that retries a call it gave up waiting on) may send it more than once:
408
+ * - the same decision while the first is being recorded waits for that one and
409
+ * answers with its receipt, marked `replayed`; nothing runs twice;
410
+ * - the same decision after it was recorded answers with the recorded receipt;
411
+ * - a different decision is refused as a DecisionConflict (jgwill/Miadi#924).
412
+ */
321
413
  export async function decide(r, person, choice, deps, opts) {
322
414
  // Checked again here, with no await between the check and the claim: the
323
415
  // page and the card (or two cards) may decide the same request at once, and
324
416
  // a revision may land between reading a version and deciding it (#919).
325
- const live = current(deps.ledger.all()).find((x) => x.id === r.id);
326
- if (!live || live.approval_state !== "awaiting_approval" || deciding.has(r.id)) {
327
- throw new AlreadyDecided(deciding.has(r.id) ? "A decision on this request is being recorded." : `This request is already ${live?.approval_state ?? "gone"}.`);
417
+ const asked = { choice, instructions: opts.instructions };
418
+ const inflight = deciding.get(r.id);
419
+ if (inflight) {
420
+ if (inflight.promise && inflight.choice !== "rerun" && sameDecision({ choice: inflight.choice, instructions: inflight.instructions }, asked)) {
421
+ logDecision(deps, { phase: "replay", record: r.id, correlation: inflight.correlation, via: opts.via, choice, while: "recording" });
422
+ return { ...(await inflight.promise), replayed: true };
423
+ }
424
+ logDecision(deps, { phase: "conflict", record: r.id, correlation: inflight.correlation, via: opts.via, choice, while: "recording" });
425
+ throw new DecisionConflict(inflight.choice === "rerun" ? "This request is being run again." : "A different decision on this request is being recorded.");
328
426
  }
427
+ const live = current(deps.ledger.all()).find((x) => x.id === r.id);
428
+ if (!live)
429
+ throw new AlreadyDecided("This request is gone.");
329
430
  // A decision that names no version (a page opened before versions, a surface that sends none) decides version 1:
330
431
  // a request revised since is refused, never decided blind (#919 review F1).
331
432
  const decidedVersion = opts.expectedVersion ?? 1;
433
+ if (live.approval_state !== "awaiting_approval") {
434
+ const prior = (live.completion_receipt ?? {});
435
+ const priorVersion = typeof prior.version === "number" ? prior.version : 1;
436
+ if (typeof prior.decision === "string" && sameDecision({ choice: prior.decision, instructions: prior.instructions }, asked) && priorVersion === decidedVersion) {
437
+ logDecision(deps, { phase: "replay", record: r.id, via: opts.via, choice, while: "recorded", state: live.state });
438
+ return { decision: live.approval_state, state: live.state, receipt: prior, replayed: true };
439
+ }
440
+ logDecision(deps, { phase: "conflict", record: r.id, via: opts.via, choice, while: "recorded", recorded: prior.decision });
441
+ throw new DecisionConflict(`This request is already ${live.approval_state ?? "gone"}.`);
442
+ }
332
443
  if (versionOf(live) !== decidedVersion)
333
444
  throw new StaleVersion(versionOf(live), decidedVersion);
334
- deciding.add(r.id);
445
+ // A generic approval never chooses Codex: the requester's own fresh choice or policy must already stand (jgwill/Miadi#926).
446
+ if (live.kind === "run_request" && approvalStateOf(choice) === "approved") {
447
+ const authorization = authorizeProvider(live, deps.ledger.all(), { requireFresh: true });
448
+ if (!authorization.authorized) {
449
+ logDecision(deps, { phase: "provider_choice_required", record: r.id, via: opts.via, choice, provider: authorization.provider });
450
+ throw new ProviderChoiceRequired(authorization);
451
+ }
452
+ }
453
+ const entry = { ...asked, correlation: randomUUID(), started: Date.now() };
454
+ deciding.set(r.id, entry);
455
+ entry.promise = decideOnce(live, person, choice, deps, { ...opts, correlation: entry.correlation, started: entry.started });
335
456
  try {
336
- return await decideOnce(live, person, choice, deps, opts);
457
+ return await entry.promise;
337
458
  }
338
459
  finally {
339
460
  deciding.delete(r.id);
340
461
  }
341
462
  }
463
+ /**
464
+ * The kinds whose run is done at once on this host (a brief written to the
465
+ * factory queue, an ending recorded): nothing outside is called, so nothing is
466
+ * written ahead of it. Every other approved run is recorded as running first.
467
+ */
468
+ const RUNS_AT_ONCE = new Set(["run_request", "work_proposal", "session_completion_proposal"]);
469
+ /** What the receipt of a run the connector lost to a restart says (jgwill/Miadi#924). */
470
+ export const INTERRUPTED_MESSAGE = "The connector stopped while this ran, so whether it finished is not known. Read what it was to change before running it again. A review create is safe to run again: the review service answers with the review it already holds for that video.";
471
+ /**
472
+ * A run recorded as running whose process is gone (the connector restarted mid-run)
473
+ * becomes a failed run that a person may run again. Called once when the connector
474
+ * starts; a run this process is still recording is left alone.
475
+ */
476
+ export function reconcileInterrupted(deps) {
477
+ const out = [];
478
+ for (const r of current(deps.ledger.all())) {
479
+ const receipt = (r.completion_receipt ?? {});
480
+ if (r.approval_state !== "approved" || r.state !== "in_progress" || receipt.outcome !== "running" || deciding.has(r.id))
481
+ continue;
482
+ deps.ledger.append({
483
+ id: newRecordId(r.kind),
484
+ kind: r.kind,
485
+ created_at: new Date().toISOString(),
486
+ actor: { person_id: "system:miadi-connector", name: "Miadi connector", client_id: "restart" },
487
+ refs: r.refs,
488
+ evidence_refs: [],
489
+ supersedes: r.id,
490
+ approval_state: "approved",
491
+ state: "blocked",
492
+ completion_receipt: { ...receipt, outcome: "failed", interrupted: true, message: INTERRUPTED_MESSAGE, interrupted_at: new Date().toISOString() },
493
+ body: { decision: "interrupted" },
494
+ });
495
+ logDecision(deps, { phase: "interrupted", record: r.id, correlation: receipt.correlation, started_at: receipt.started_at });
496
+ out.push(r.id);
497
+ }
498
+ return out;
499
+ }
342
500
  /** Whether a request was approved and its run failed, so a person may run it again. */
343
501
  export function isRerunnable(r) {
344
502
  return !!r && r.approval_state === "approved" && r.state === "blocked";
@@ -355,7 +513,7 @@ export async function rerun(r, person, deps, opts) {
355
513
  if (!isRerunnable(live) || deciding.has(r.id)) {
356
514
  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.`);
357
515
  }
358
- deciding.add(r.id);
516
+ deciding.set(r.id, { choice: "rerun", correlation: randomUUID(), started: Date.now() });
359
517
  try {
360
518
  const prior = (live.completion_receipt ?? {});
361
519
  const instructions = typeof prior.instructions === "string" ? prior.instructions : undefined;
@@ -403,7 +561,8 @@ export async function rerun(r, person, deps, opts) {
403
561
  }
404
562
  async function decideOnce(r, person, choice, deps, opts) {
405
563
  const { instructions } = opts;
406
- const decision = choice === "approve" || choice === "approve_with_changes" ? "approved" : choice === "request_revision" ? "revision_requested" : "declined";
564
+ const decision = approvalStateOf(choice);
565
+ logDecision(deps, { phase: "start", record: r.id, kind: r.kind, correlation: opts.correlation, via: opts.via, choice, version: versionOf(r) });
407
566
  let receipt = {
408
567
  decided_by: { person_id: person.id, name: person.name },
409
568
  decided_at: new Date().toISOString(),
@@ -414,9 +573,28 @@ async function decideOnce(r, person, choice, deps, opts) {
414
573
  via: opts.via,
415
574
  ...(instructions ? { instructions } : {}),
416
575
  note: opts.note,
576
+ correlation: opts.correlation,
417
577
  };
418
578
  let state = decision === "approved" ? "completed" : decision === "revision_requested" ? "pending" : "withdrawn";
419
579
  if (decision === "approved") {
580
+ if (!RUNS_AT_ONCE.has(r.kind)) {
581
+ // Written ahead of the run: the decision is in the ledger before anything is called, so a restart
582
+ // mid-run can never offer the request for a second approval, and every surface reads it as running (#924).
583
+ deps.ledger.append({
584
+ id: newRecordId(r.kind),
585
+ kind: r.kind,
586
+ created_at: new Date().toISOString(),
587
+ actor: { person_id: person.id, name: person.name, client_id: opts.via },
588
+ refs: r.refs,
589
+ evidence_refs: [],
590
+ supersedes: r.id,
591
+ approval_state: decision,
592
+ state: "in_progress",
593
+ completion_receipt: { ...receipt, outcome: "running", started_at: new Date(opts.started).toISOString() },
594
+ body: { decision: choice, ...(instructions ? { instructions } : {}) },
595
+ });
596
+ logDecision(deps, { phase: "running", record: r.id, correlation: opts.correlation, ms: Date.now() - opts.started });
597
+ }
420
598
  try {
421
599
  const outcome = await executeApproved(r, person, deps, instructions);
422
600
  receipt = { ...receipt, ...outcome };
@@ -444,17 +622,29 @@ async function decideOnce(r, person, choice, deps, opts) {
444
622
  completion_receipt: receipt,
445
623
  body: { decision: choice, ...(instructions ? { instructions } : {}) },
446
624
  });
625
+ logDecision(deps, {
626
+ phase: "done",
627
+ record: r.id,
628
+ correlation: opts.correlation,
629
+ decision,
630
+ state,
631
+ outcome: receipt.outcome,
632
+ ...(typeof receipt.status === "number" ? { status: receipt.status } : {}),
633
+ ms: Date.now() - opts.started,
634
+ });
447
635
  return { decision, state, receipt };
448
636
  }
449
637
  export function isApprovable(r) {
450
638
  return Boolean(r && APPROVABLE.has(r.kind));
451
639
  }
452
640
  /** What approving a request does, in one sentence, for the card a person decides on. */
453
- export function consequenceOf(r) {
641
+ export function consequenceOf(r, authorization) {
454
642
  const b = r.body;
455
643
  switch (r.kind) {
456
644
  case "run_request":
457
- return "Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes.";
645
+ return authorization && !authorization.authorized
646
+ ? `Approving is refused until ${r.actor.name} chooses ${authorization.provider} for this mission on its approval page; approving does not choose it. Declining or sending it back stays possible.`
647
+ : `Approving queues a brief for the factory intake, which opens or reaches an agent session within minutes${authorization?.provider ? ` with ${authorization.provider}` : ""}.`;
458
648
  case "work_proposal":
459
649
  return "Approving starts the work: a brief written from the proposal and your instructions is queued for an agent session.";
460
650
  case "review_operation_request":
@@ -506,8 +696,43 @@ export function registerApprovalRoutes(app, deps) {
506
696
  if (!writer && r.actor.person_id !== person.id) {
507
697
  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"}.`));
508
698
  }
699
+ const requester = r.actor.person_id === person.id && isPersonId(person.id);
509
700
  if (!body.decision)
510
- return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined }));
701
+ return void res.type("html").send(approvePage(deps, r, { token: writer ? token : undefined, chooserToken: requester ? token : undefined }));
702
+ if (body.decision === "choose_provider") {
703
+ // The requester's own, separate choice of a provider that needs one, for this version only (jgwill/Miadi#926).
704
+ const live = find(r.id);
705
+ const again = (status, error) => void res.status(status).type("html").send(approvePage(deps, live, { token: writer ? token : undefined, chooserToken: requester ? token : undefined, error }));
706
+ if (!requester) {
707
+ return again(403, isPersonId(r.actor.person_id)
708
+ ? `Only ${r.actor.name}, who asked, can choose a provider for their own mission.`
709
+ : `${r.actor.name} is a service token: only a person chooses a provider, for a request of their own. Name another provider for this request.`);
710
+ }
711
+ if (live.approval_state !== "awaiting_approval" || isBeingDecided(live.id))
712
+ return again(409, "A provider is chosen only while the request awaits a decision.");
713
+ const provider = String(body.provider ?? "");
714
+ const named = authorizeProvider(live, deps.ledger.all(), { requireFresh: true });
715
+ if (!needsPersonChoice(provider) || provider !== named.provider)
716
+ return again(400, `This request names ${named.provider ?? "no provider"}; only that provider can be chosen here.`);
717
+ if (Number(body.version) !== versionOf(live))
718
+ return again(409, `This request is now at version ${versionOf(live)}. Read it and choose again.`);
719
+ if (body.confirm !== "yes")
720
+ return again(400, `Tick the box to choose ${provider} for this mission.`);
721
+ if (!named.authorized || named.state !== "person_choice") {
722
+ deps.ledger.append({
723
+ id: newRecordId("provider_choice"),
724
+ kind: "provider_choice",
725
+ created_at: new Date().toISOString(),
726
+ actor: { person_id: person.id, name: person.name, client_id: "approval-page" },
727
+ refs: [live.id],
728
+ evidence_refs: [],
729
+ claim_category: "observed",
730
+ body: { request_id: live.id, provider, version: versionOf(live), content_hash: contentHash(live) },
731
+ });
732
+ logDecision(deps, { phase: "provider_chosen", record: live.id, provider, version: versionOf(live) });
733
+ }
734
+ return void res.type("html").send(approvePage(deps, find(r.id), { token: writer ? token : undefined, chooserToken: token }));
735
+ }
511
736
  if (!writer)
512
737
  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"}.` }));
513
738
  if (body.decision === "rerun") {
@@ -523,12 +748,11 @@ export function registerApprovalRoutes(app, deps) {
523
748
  const failedAgain = ran.receipt.outcome === "failed";
524
749
  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>`));
525
750
  }
526
- if (r.approval_state !== "awaiting_approval") {
527
- return void res.status(409).type("html").send(approvePage(deps, r, { error: `This request is already ${r.approval_state}.` }));
528
- }
529
751
  const choice = DECISIONS.includes(String(body.decision)) ? body.decision : "decline";
530
752
  const instructions = String(body.instructions ?? "").trim().slice(0, 20000) || undefined;
531
- if ((choice === "approve_with_changes" || choice === "request_revision") && !instructions) {
753
+ const via = String(body.via ?? "approval-page").slice(0, 60);
754
+ // Only a decision that would be recorded now needs its instructions; a form sent again after it was recorded is answered below (#924).
755
+ if (r.approval_state === "awaiting_approval" && !isBeingDecided(r.id) && (choice === "approve_with_changes" || choice === "request_revision") && !instructions) {
532
756
  return void res
533
757
  .status(400)
534
758
  .type("html")
@@ -537,32 +761,47 @@ export function registerApprovalRoutes(app, deps) {
537
761
  let decided;
538
762
  try {
539
763
  const shown = Number(body.version);
540
- decided = await decide(r, person, choice, deps, {
764
+ const running = decide(r, person, choice, deps, {
541
765
  instructions,
542
766
  note: body.note || undefined,
543
- via: String(body.via ?? "approval-page").slice(0, 60),
767
+ via,
544
768
  ...(Number.isInteger(shown) && shown >= 1 ? { expectedVersion: shown } : {}),
545
769
  });
770
+ // A run that takes longer than a proxy waits (the review service may take minutes) is answered as
771
+ // recorded and running; the run goes on, and signing in again shows how it ended (#924).
772
+ const settled = await settleWithin(running, decisionWaitOf(deps));
773
+ if (settled === STILL_RUNNING) {
774
+ logDecision(deps, { phase: "answered_running", record: r.id, via, choice });
775
+ const now = find(r.id);
776
+ return void res.status(202).type("html").send(page("Recorded", `<h1>${escapeHtml(projectionOf(now, deps)?.label ?? "Being recorded")}</h1>${summary(now)}${projectionBlock(projectionOf(now, deps), undefined)}${refreshForm(token)}`));
777
+ }
778
+ decided = settled;
546
779
  }
547
780
  catch (error) {
781
+ if (error instanceof ProviderChoiceRequired) {
782
+ return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, chooserToken: requester ? token : undefined, error: error.message }));
783
+ }
548
784
  if (error instanceof StaleVersion)
549
785
  return void res.status(409).type("html").send(approvePage(deps, find(r.id), { token, error: error.message }));
550
786
  if (error instanceof AlreadyDecided)
551
787
  return void res.status(409).type("html").send(approvePage(deps, find(r.id), { error: error.message }));
552
788
  throw error;
553
789
  }
554
- const { decision, receipt } = decided;
790
+ const { decision, receipt, replayed } = decided;
555
791
  const p = projectionOf(find(r.id), deps);
556
792
  const heading = decision === "approved"
557
793
  ? p?.stage === "failed"
558
794
  ? "Approved, but running it failed"
559
- : p?.outstanding.length
560
- ? "Approved, changes still to do"
561
- : "Approved"
795
+ : p?.stage === "recording"
796
+ ? "Approved, running"
797
+ : p?.outstanding.length
798
+ ? "Approved, changes still to do"
799
+ : "Approved"
562
800
  : decision === "revision_requested"
563
801
  ? "Sent back for revision"
564
802
  : "Declined";
565
- res.type("html").send(page("Recorded", `<h1>${heading}</h1>${summary(find(r.id))}${projectionBlock(p, receipt)}`));
803
+ const again = replayed ? "<p>This decision was already recorded. Nothing ran again; here is where it stands.</p>" : "";
804
+ res.type("html").send(page("Recorded", `<h1>${heading}</h1>${again}${summary(find(r.id))}${projectionBlock(p, receipt)}${p?.stage === "recording" ? refreshForm(token) : ""}`));
566
805
  });
567
806
  // A record's own page, for citations in ChatGPT: its author and anyone with Miadi's read tier may read it.
568
807
  app.get("/records/:id", (req, res) => {
@@ -583,12 +822,19 @@ export function registerApprovalRoutes(app, deps) {
583
822
  const r = find(String(req.params.id));
584
823
  if (!r)
585
824
  return void res.status(404).type("html").send(page("Not found", "<h1>No record with that id</h1>"));
825
+ if (!canReadReading(r, current(deps.ledger.all()), person)) {
826
+ return void res.status(403).type("html").send(page("Miadi record", recordForm(String(req.params.id), "This source and its reading are private to the source owner and factory operators.")));
827
+ }
586
828
  if (!person.api_tier && r.actor.person_id !== person.id) {
587
829
  return void res.status(403).type("html").send(page("Miadi record", recordForm(String(req.params.id), `Reading records needs Miadi's read tier; ${person.name} has none.`)));
588
830
  }
589
831
  res.type("html").send(page("Miadi record", `<h1>${escapeHtml(r.kind)}</h1>${summary(r)}`));
590
832
  });
591
833
  }
834
+ /** A button that shows where a running request stands now, signed in with the token the person just used. */
835
+ function refreshForm(token) {
836
+ return `<form method="post"><input type="hidden" name="person_token" value="${escapeHtml(token)}"><div class="row"><button class="primary" type="submit">Show where it stands now</button></div></form>`;
837
+ }
592
838
  function recordForm(id, error) {
593
839
  return `<h1>Miadi record</h1><p>${escapeHtml(id)}</p>
594
840
  ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
@@ -639,8 +885,12 @@ function approvePage(deps, r, o = {}) {
639
885
  const open = r.approval_state === "awaiting_approval";
640
886
  // A decision being recorded offers no buttons: a second click would decide the same request again.
641
887
  const actionable = open && !isBeingDecided(r.id);
888
+ // Freshness binds a decision still to come; a decided run shows the authorization it holds (#926 review R1).
889
+ const authorization = r.kind === "run_request" ? authorizeProvider(r, deps.ledger.all(), { requireFresh: open }) : undefined;
890
+ const blocked = Boolean(authorization && !authorization.authorized);
642
891
  return page("Approve a Miadi request", `<h1>${open ? "Approve a request" : "A decided request"}</h1>
643
- ${actionable ? `<p>${escapeHtml(consequenceOf(r))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
892
+ ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
893
+ ${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
644
894
  ${summary(r)}
645
895
  ${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}
646
896
  ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
@@ -651,19 +901,104 @@ ${actionable && o.token
651
901
  <label for="i">Instructions (needed to approve with changes or to send back)</label>
652
902
  <textarea id="i" name="instructions" rows="4"></textarea>
653
903
  <div class="row">
654
- <button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
655
- <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>
904
+ ${blocked
905
+ ? ""
906
+ : `<button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
907
+ <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>`}
656
908
  <button type="submit" name="decision" value="request_revision">Send back for revision</button>
657
909
  <button type="submit" name="decision" value="decline">Decline</button>
658
910
  </div></form>`
659
911
  : actionable
660
912
  ? "<p>A person with Miadi's write tier decides this request.</p>"
661
- : isRerunnable(r) && o.token
662
- ? `<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>
913
+ : o.token && projectionOf(r, deps)?.stage === "recording"
914
+ ? refreshForm(o.token)
915
+ : isRerunnable(r) && o.token
916
+ ? `<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>
663
917
  <form method="post">
664
918
  <input type="hidden" name="person_token" value="${escapeHtml(o.token)}">
665
919
  <div class="row"><button class="primary" type="submit" name="decision" value="rerun">Run it again</button></div></form>`
666
- : ""}`);
920
+ : ""}`);
921
+ }
922
+ /**
923
+ * Which provider a run launches with, who chose it, and, when the requester's own choice is
924
+ * missing, the separate control only the requester sees (jgwill/Miadi#926).
925
+ */
926
+ function providerSection(r, a, chooserToken) {
927
+ const lines = [
928
+ `<dt>Provider</dt><dd>${escapeHtml(a.provider ?? "the factory runner's default")}</dd>`,
929
+ `<dt>Asked for</dt><dd>${escapeHtml(a.requested ?? "none named")}${a.requester.client ? ` (through ${escapeHtml(a.requester.client)})` : ""}</dd>`,
930
+ `<dt>Authorized by</dt><dd>${escapeHtml(a.state.replace(/_/g, " "))}</dd>`,
931
+ ].join("");
932
+ const choose = a.state === "needs_person_choice" && chooserToken && a.provider
933
+ ? `<form method="post">
934
+ <input type="hidden" name="person_token" value="${escapeHtml(chooserToken)}">
935
+ <input type="hidden" name="version" value="${versionOf(r)}">
936
+ <input type="hidden" name="provider" value="${escapeHtml(a.provider)}">
937
+ <label><input type="checkbox" name="confirm" value="yes" required> I choose ${escapeHtml(a.provider)} for this mission (version ${versionOf(r)}) myself. It does not cover any other mission.</label>
938
+ <div class="row"><button type="submit" name="decision" value="choose_provider">Choose ${escapeHtml(a.provider)} for this mission</button></div>
939
+ </form>`
940
+ : a.state === "needs_person_choice" && !isPersonId(r.actor.person_id)
941
+ ? ""
942
+ : a.state === "needs_person_choice"
943
+ ? `<p>Only ${escapeHtml(r.actor.name)}, who asked, can choose ${escapeHtml(a.provider ?? "")} for this mission, signed in here. Within ${CHOICE_TTL_MS / 3_600_000} hours of that choice it can be approved.</p>`
944
+ : "";
945
+ return `<section aria-label="Provider"><dl>${lines}</dl><p>${escapeHtml(a.explanation)}</p>${choose}</section>`;
946
+ }
947
+ /** The person's own provider policy page: read and change it, signed in with their person token (jgwill/Miadi#926). */
948
+ export function registerProviderPolicyRoutes(app, deps) {
949
+ const form = express.urlencoded({ extended: false, limit: "16kb" });
950
+ const signIn = (error) => page("Your provider policy", `<h1>Your provider policy</h1><p>Which agent providers a request made in your name may name. Sign in to read or change yours; it applies to your requests only.</p>
951
+ ${error ? `<p class="err" role="alert">${escapeHtml(error)}</p>` : ""}
952
+ <form method="post"><label for="t">Your Miadi person token</label><input id="t" name="person_token" type="password" autocomplete="off" required>
953
+ <div class="row"><button class="primary" type="submit">Read my policy</button></div></form>`);
954
+ app.get("/providers", (_req, res) => void res.type("html").send(signIn()));
955
+ app.post("/providers", form, async (req, res) => {
956
+ const body = (req.body ?? {});
957
+ const token = String(body.person_token ?? "").trim();
958
+ let person;
959
+ try {
960
+ person = await deps.identify(token);
961
+ }
962
+ catch (error) {
963
+ if (!(error instanceof MiadiUnavailable))
964
+ throw error;
965
+ return void res.status(503).type("html").send(signIn("Miadi could not be reached. Try again in a minute."));
966
+ }
967
+ if (!person)
968
+ return void res.status(401).type("html").send(signIn("Miadi did not recognise that person token."));
969
+ if (!isPersonId(person.id))
970
+ return void res.status(403).type("html").send(signIn("A service token has no provider policy; a person sets their own."));
971
+ let saved = "";
972
+ if (body.codex !== undefined) {
973
+ if (!CODEX_POLICIES.includes(body.codex))
974
+ return void res.status(400).type("html").send(signIn("Unknown Codex policy."));
975
+ if (providerPolicyOf(deps.ledger.all(), person.id).codex !== body.codex) {
976
+ deps.ledger.append({
977
+ id: newRecordId("provider_policy"),
978
+ kind: "provider_policy",
979
+ created_at: new Date().toISOString(),
980
+ actor: { person_id: person.id, name: person.name, client_id: "providers-page" },
981
+ refs: [],
982
+ evidence_refs: [],
983
+ claim_category: "observed",
984
+ body: { codex: body.codex },
985
+ });
986
+ logDecision(deps, { phase: "provider_policy_set", person: person.id, codex: body.codex });
987
+ }
988
+ saved = "<p>Saved. It applies to your requests from now on, including a failed run of yours that is run again. A per-mission choice you already made still covers its mission.</p>";
989
+ }
990
+ const policy = providerPolicyOf(deps.ledger.all(), person.id);
991
+ const option = (value, label) => `<label><input type="radio" name="codex" value="${value}"${policy.codex === value ? " checked" : ""}> ${escapeHtml(label)}</label>`;
992
+ res.type("html").send(page("Your provider policy", `<h1>${escapeHtml(person.name)}'s provider policy</h1>${saved}
993
+ <dl><dt>Codex</dt><dd>${escapeHtml(policy.codex.replace(/_/g, " "))}${policy.source === "default" ? " (the default)" : `, set ${escapeHtml(policy.set_at ?? "")}`}</dd>
994
+ <dt>Other providers</dt><dd>Pi, Copilot and Claude Code run when a request names them and they are installed; Pi and Copilot may also be picked by the host's order.</dd></dl>
995
+ <form method="post"><input type="hidden" name="person_token" value="${escapeHtml(token)}">
996
+ <fieldset><legend>When a request in my name names Codex</legend>
997
+ ${option("per_mission_choice", "Codex runs only when I choose it for that mission on its approval page (default).")}
998
+ ${option("selectable", "Codex may be named for my requests without a per-mission choice.")}
999
+ </fieldset>
1000
+ <div class="row"><button class="primary" type="submit">Save my policy</button></div></form>`));
1001
+ });
667
1002
  }
668
1003
  /** How a request is named to a person. */
669
1004
  export const KIND_LABELS = {
package/dist/config.d.ts CHANGED
@@ -14,5 +14,17 @@ export interface ConnectorConfig {
14
14
  inquiryRoot?: string;
15
15
  /** owner/repo a saved report's inquiry issue is opened in (MIADI_INQUIRY_GITHUB_REPO). */
16
16
  inquiryRepo?: string;
17
+ /**
18
+ * How long a card's decision token stays usable, in seconds (MIADI_CONNECTOR_DECISION_TOKEN_TTL).
19
+ * Default 7 days, held between 5 minutes and 30 days. The request itself does not expire (jgwill/Miadi#924).
20
+ */
21
+ decisionTokenTtl?: number;
22
+ /**
23
+ * How long a decision answer waits for the run it starts, in milliseconds (MIADI_CONNECTOR_DECISION_WAIT_MS).
24
+ * Past it the answer says the decision is recorded and still running, and the run goes on. Default 20 s, at most 50 s.
25
+ */
26
+ decisionWaitMs?: number;
17
27
  }
28
+ export declare const DEFAULT_DECISION_TOKEN_TTL: number;
29
+ export declare const DEFAULT_DECISION_WAIT_MS = 20000;
18
30
  export declare function configFromEnv(env?: NodeJS.ProcessEnv): ConnectorConfig;
package/dist/config.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import { resolveEnv } from "@miadi/inquiry-weave";
2
2
  import { reviewToken } from "./reviews.js";
3
+ export const DEFAULT_DECISION_TOKEN_TTL = 7 * 24 * 3600;
4
+ export const DEFAULT_DECISION_WAIT_MS = 20_000;
5
+ /** A number from the environment held between two bounds; anything unreadable is the default. */
6
+ function bounded(raw, fallback, min, max) {
7
+ const n = Number(raw);
8
+ if (raw === undefined || raw.trim() === "" || !Number.isFinite(n))
9
+ return fallback;
10
+ return Math.min(max, Math.max(min, Math.round(n)));
11
+ }
3
12
  export function configFromEnv(env = process.env) {
4
13
  const port = Number(env.MIADI_CONNECTOR_PORT || 3337);
5
14
  const shelf = resolveEnv({}, env);
@@ -14,5 +23,7 @@ export function configFromEnv(env = process.env) {
14
23
  stateDir: env.MIADI_CONNECTOR_STATE_DIR || "/srv/miadi/connector",
15
24
  inquiryRoot: shelf.inquiryRoot,
16
25
  inquiryRepo: shelf.repo,
26
+ decisionTokenTtl: bounded(env.MIADI_CONNECTOR_DECISION_TOKEN_TTL, DEFAULT_DECISION_TOKEN_TTL, 300, 30 * 24 * 3600),
27
+ decisionWaitMs: bounded(env.MIADI_CONNECTOR_DECISION_WAIT_MS, DEFAULT_DECISION_WAIT_MS, 0, 50_000),
17
28
  };
18
29
  }