wicked-crew 0.16.12 → 0.16.13
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 -1
- package/dist/api/chat-history.d.ts +6 -0
- package/dist/api/chat-history.d.ts.map +1 -1
- package/dist/api/chat-history.js +21 -0
- package/dist/api/chat-history.js.map +1 -1
- package/dist/api/chat-proposals.d.ts +61 -6
- package/dist/api/chat-proposals.d.ts.map +1 -1
- package/dist/api/chat-proposals.js +252 -24
- package/dist/api/chat-proposals.js.map +1 -1
- package/dist/api/routes.d.ts +14 -2
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +119 -9
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-timing-index.d.ts +18 -0
- package/dist/api/run-timing-index.d.ts.map +1 -1
- package/dist/api/run-timing-index.js +34 -0
- package/dist/api/run-timing-index.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +100 -1
- package/dist/api/server.js.map +1 -1
- package/dist/apps/spec.d.ts +3009 -0
- package/dist/apps/spec.d.ts.map +1 -0
- package/dist/apps/spec.js +870 -0
- package/dist/apps/spec.js.map +1 -0
- package/dist/cli/index.js +3 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/core/run-boot-resume.d.ts +4 -1
- package/dist/core/run-boot-resume.d.ts.map +1 -1
- package/dist/core/run-boot-resume.js +6 -1
- package/dist/core/run-boot-resume.js.map +1 -1
- package/dist/editors/csp.d.ts +24 -4
- package/dist/editors/csp.d.ts.map +1 -1
- package/dist/editors/csp.js +35 -8
- package/dist/editors/csp.js.map +1 -1
- package/dist/editors/routes.d.ts +4 -1
- package/dist/editors/routes.d.ts.map +1 -1
- package/dist/editors/routes.js +30 -3
- package/dist/editors/routes.js.map +1 -1
- package/dist/editors/webrtc-guard.d.ts +94 -0
- package/dist/editors/webrtc-guard.d.ts.map +1 -0
- package/dist/editors/webrtc-guard.js +506 -0
- package/dist/editors/webrtc-guard.js.map +1 -0
- package/dist/interactive/bridge-pool.d.ts +11 -2
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +10 -1
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/interactive/doc-binding.d.ts +156 -0
- package/dist/interactive/doc-binding.d.ts.map +1 -0
- package/dist/interactive/doc-binding.js +277 -0
- package/dist/interactive/doc-binding.js.map +1 -0
- package/dist/interactive/doc-delete-routes.d.ts +3 -0
- package/dist/interactive/doc-delete-routes.d.ts.map +1 -1
- package/dist/interactive/doc-delete-routes.js +4 -0
- package/dist/interactive/doc-delete-routes.js.map +1 -1
- package/dist/interactive/draft-events.d.ts +89 -3
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +438 -27
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/edit-events.d.ts +2 -0
- package/dist/interactive/edit-events.d.ts.map +1 -1
- package/dist/interactive/edit-events.js +11 -0
- package/dist/interactive/edit-events.js.map +1 -1
- package/dist/interactive/proposal-create.d.ts +143 -0
- package/dist/interactive/proposal-create.d.ts.map +1 -0
- package/dist/interactive/proposal-create.js +318 -0
- package/dist/interactive/proposal-create.js.map +1 -0
- package/dist/interactive/proxy-routes.d.ts +60 -3
- package/dist/interactive/proxy-routes.d.ts.map +1 -1
- package/dist/interactive/proxy-routes.js +209 -3
- package/dist/interactive/proxy-routes.js.map +1 -1
- package/dist/interactive/source-packet.d.ts +126 -0
- package/dist/interactive/source-packet.d.ts.map +1 -0
- package/dist/interactive/source-packet.js +482 -0
- package/dist/interactive/source-packet.js.map +1 -0
- package/dist/studio/assets/{index-C_UIgTiy.js → index-u4O_7N-b.js} +1 -1
- package/dist/studio/editors/wicked-deck/editor.json +1 -1
- package/dist/studio/editors/wicked-deck/index.html +1 -1
- package/dist/studio/editors/wicked-doc/editor.json +1 -1
- package/dist/studio/editors/wicked-doc/index.html +1 -1
- package/dist/studio/editors/wicked-insights/editor.json +1 -1
- package/dist/studio/editors/wicked-insights/index.html +1 -1
- package/dist/studio/editors/wicked-page/editor.json +1 -1
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/editors/wicked-video/editor.json +1 -1
- package/dist/studio/editors/wicked-video/index.html +1 -1
- package/dist/studio/index.html +1 -1
- package/dist/studio/testid-inventory.json +1 -1
- package/endpoint-manifest.json +1 -1
- package/package.json +4 -3
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { judgeFallbackLabel } from '../core/deliver-text.js';
|
|
37
37
|
import { resolveProjectGraphBinding } from '../projects/graph.js';
|
|
38
|
-
import { mkdirSync,
|
|
38
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
39
|
+
import { tmpdir } from 'node:os';
|
|
40
|
+
import { createBeneath, listBeneath, mkdirBeneath, readBeneath, statBeneath } from '../api/beneath.js';
|
|
39
41
|
import { basename, dirname, join } from 'node:path';
|
|
40
|
-
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
41
43
|
import { InteractiveHandoffLedger } from './ledger.js';
|
|
42
44
|
import { reviewPartitionOf as projectPartitionOf } from './review-ledger.js';
|
|
43
45
|
import { draftQualityClause, logDraftSkillArmLine, pageBudgetFor, PROCESS_NOTES_WHERE, runDraftFloor, } from './draft-skill.js';
|
|
@@ -50,11 +52,17 @@ import { awaitingHumanLine, describeStep, resyncRunUnits, ResyncGate, RunUnits }
|
|
|
50
52
|
import { acpFallbackLine, ungatedGateNote, unitDistributedLine, workerToolCallDeniedLine, } from './council-outcome.js';
|
|
51
53
|
import { busSubscriberErrorReporter } from './bus-subscriber-errors.js';
|
|
52
54
|
import { emitOnBus, requireEngineBus, tapBus } from '../core/bus.js';
|
|
55
|
+
import { UNFILED_MOUNT } from '../api/chat-context.js';
|
|
56
|
+
import { packetClause, packetDirOf } from './source-packet.js';
|
|
53
57
|
// ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
|
|
54
58
|
export const INTERACTIVE_DOMAIN = 'wicked-interactive';
|
|
55
59
|
export const DOC_CREATED = 'wicked.interactive.doc.created';
|
|
56
60
|
export const DRAFT_COMPLETED = 'wicked.interactive.draft.completed';
|
|
57
61
|
export const STATUS_POSTED = 'wicked.interactive.status.posted';
|
|
62
|
+
/** interactive's announce of a landed version (`{document_id, project_id?, version, parent, kind, run_id?}`). */
|
|
63
|
+
export const VERSION_CREATED = 'wicked.interactive.version.created';
|
|
64
|
+
/** interactive ≥ the I1 contract: a `draft.completed` whose `expect_head` no longer held landed nothing. */
|
|
65
|
+
export const DRAFT_CONFLICT = 'wicked.interactive.draft.conflict';
|
|
58
66
|
/** Exact-type filter with a domain guard — no wildcard, one event type is the whole trigger. */
|
|
59
67
|
export const INTERACTIVE_BUS_FILTER = `${DOC_CREATED}@${INTERACTIVE_DOMAIN}`;
|
|
60
68
|
/** The producer identity stamped on every event crew's interactive seams emit (this module and
|
|
@@ -320,14 +328,29 @@ export function draftProblem(doc, outPath, snapshotDir, intent, grounding) {
|
|
|
320
328
|
`${subjectClause}${groundingClause}${recall}${propose}` +
|
|
321
329
|
`The finished draft MUST be written to exactly this absolute file path: ${outPath}`);
|
|
322
330
|
}
|
|
323
|
-
/**
|
|
324
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Deterministic bus idempotency key of ONE attempt's draft: a re-announce of the same run dedupes at the bus, while a
|
|
333
|
+
* second attempt at the same document (a proposal's Try again, DES-CHAT-ARTIFACTS-001 §4.3) announces under its own key.
|
|
334
|
+
* Before A3 the key was per document (`…:v1`), so the bus would have dropped a retry's landing as a duplicate.
|
|
335
|
+
*/
|
|
336
|
+
export function draftIdempotencyKey(documentId, projectId, runId) {
|
|
325
337
|
const partition = projectPartitionOf(projectId);
|
|
326
338
|
// crew#809: the bus resolves a repeated key to the existing row, so a same-slug document in
|
|
327
339
|
// ANOTHER project must announce under its own key or its draft.completed is silently dropped.
|
|
328
340
|
return partition === '_unfiled'
|
|
329
|
-
? `crew:interactive.draft:${documentId}
|
|
330
|
-
: `crew:interactive.draft:${documentId}:${partition}
|
|
341
|
+
? `crew:interactive.draft:${documentId}:${runId}`
|
|
342
|
+
: `crew:interactive.draft:${documentId}:${partition}:${runId}`;
|
|
343
|
+
}
|
|
344
|
+
/** The handoff-ledger key of a proposal-bound attempt (one per run: a document may have several attempts), with the
|
|
345
|
+
* project partition like every draft key (crew#809). It starts `<doc>:` like every handoff key, so the doc↔run index
|
|
346
|
+
* and the delete sweep see it, and {@link isAnotherProjectsDraftKey} keeps another project's. */
|
|
347
|
+
export function boundAttemptKey(documentId, projectId, runId) {
|
|
348
|
+
return `${documentId}:attempt:${projectPartitionOf(projectId)}:${runId}`;
|
|
349
|
+
}
|
|
350
|
+
/** The folder of one proposal-bound attempt under the document's run directory: its deliverable, `sources/` and
|
|
351
|
+
* `records/`. A fresh one per attempt, and an earlier attempt's is removed before a new one starts. */
|
|
352
|
+
export function attemptDirName(runId) {
|
|
353
|
+
return `attempt-${runId}`;
|
|
331
354
|
}
|
|
332
355
|
// ── Per-project identity of a draft (crew#809) ────────────────────────────────────────────────
|
|
333
356
|
//
|
|
@@ -354,6 +377,9 @@ export function isAnotherProjectsDraftKey(key, documentId, projectId) {
|
|
|
354
377
|
const own = draftHandoffKey(documentId, projectId);
|
|
355
378
|
if (key === own)
|
|
356
379
|
return false;
|
|
380
|
+
// A3: a proposal-bound attempt's row is this document's only when its partition is this project's.
|
|
381
|
+
if (key.startsWith(`${documentId}:attempt:`))
|
|
382
|
+
return !key.startsWith(`${documentId}:attempt:${projectPartitionOf(projectId)}:`);
|
|
357
383
|
if (key.startsWith(`${documentId}:draft:`))
|
|
358
384
|
return true;
|
|
359
385
|
return key === documentId && own !== documentId; // the unfiled document's row, when this one is filed
|
|
@@ -380,6 +406,137 @@ export function isStudioScratchDoc(doc) {
|
|
|
380
406
|
// this leg keys it by DOCUMENT ID — one first draft per document lifetime. Re-exported here so
|
|
381
407
|
// the seam's public surface stays one module.
|
|
382
408
|
export { InteractiveHandoffLedger } from './ledger.js';
|
|
409
|
+
/**
|
|
410
|
+
* A3 (§5.3): give one attempt's inbox crew's copy of its source packet, in a fresh attempt folder. The document's run
|
|
411
|
+
* folder is the trusted root (a writer can write only inside it, never rename it); everything below it, the attempt
|
|
412
|
+
* folder included, is made and opened with A1's `beneath`, following no link at any component.
|
|
413
|
+
*/
|
|
414
|
+
export function writePacketCopy(runDir, attempt, packet) {
|
|
415
|
+
const root = realpathSync(runDir);
|
|
416
|
+
mkdirBeneath(root, attempt);
|
|
417
|
+
for (const d of ['sources', 'records']) {
|
|
418
|
+
const st = (() => {
|
|
419
|
+
try {
|
|
420
|
+
return statBeneath(root, `${attempt}/${d}`);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
if (st === null)
|
|
427
|
+
mkdirSync(join(root, attempt, d), { mode: 0o700 });
|
|
428
|
+
if (statBeneath(root, `${attempt}/${d}`)?.isDirectory() !== true)
|
|
429
|
+
throw new Error(`${attempt}/${d} is not a real folder`);
|
|
430
|
+
}
|
|
431
|
+
for (const f of packet.files)
|
|
432
|
+
createBeneath(root, `${attempt}/${packetDirOf(f.name)}/${f.name}`, f.bytes, 0o600);
|
|
433
|
+
}
|
|
434
|
+
/** Remove one entry of the document's run folder (an attempt folder), never through a link: a link a writer put there
|
|
435
|
+
* is removed itself, never followed. Best effort: a leftover is a disk wart, never a correctness one. */
|
|
436
|
+
function removeEntry(runDir, name) {
|
|
437
|
+
let root;
|
|
438
|
+
try {
|
|
439
|
+
root = realpathSync(runDir);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return true; // no run folder: nothing of the attempt is there
|
|
443
|
+
}
|
|
444
|
+
const p = join(root, name);
|
|
445
|
+
try {
|
|
446
|
+
const st = lstatSync(p);
|
|
447
|
+
rmSync(p, st.isSymbolicLink() || !st.isDirectory() ? { force: true } : { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
if (err.code !== 'ENOENT')
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
lstatSync(p);
|
|
455
|
+
return false; // still there
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/** Remove the named attempts' folders under a document's run directory (A3): only attempts of THIS document's binding,
|
|
462
|
+
* so a same-named document elsewhere never loses its own. Returns the folders that could NOT be removed. */
|
|
463
|
+
export function removeAttemptDirs(runDir, runIds) {
|
|
464
|
+
const left = [];
|
|
465
|
+
for (const id of runIds) {
|
|
466
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id))
|
|
467
|
+
continue;
|
|
468
|
+
if (!removeEntry(runDir, attemptDirName(id)))
|
|
469
|
+
left.push(attemptDirName(id));
|
|
470
|
+
}
|
|
471
|
+
return left;
|
|
472
|
+
}
|
|
473
|
+
/** Drop an attempt's packet copy (crew keeps the canonical one); its deliverable stays for interactive to read. Best
|
|
474
|
+
* effort, never through a link (a writer could have replaced the attempt folder). */
|
|
475
|
+
export function removePacketCopy(runDir, attempt) {
|
|
476
|
+
let root;
|
|
477
|
+
try {
|
|
478
|
+
root = realpathSync(runDir);
|
|
479
|
+
const st = lstatSync(join(root, attempt));
|
|
480
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// Each folder on its own: one already gone never keeps the other.
|
|
487
|
+
for (const d of ['sources', 'records']) {
|
|
488
|
+
try {
|
|
489
|
+
const p = join(root, attempt, d);
|
|
490
|
+
const sd = lstatSync(p);
|
|
491
|
+
rmSync(p, sd.isSymbolicLink() || !sd.isDirectory() ? { force: true } : { recursive: true, force: true });
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
/* absent, or not removable: best effort */
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The first packet file whose bytes no longer match crew's record (missing, a link anywhere below the run folder, not
|
|
500
|
+
* a regular file, not its recorded size), or an entry crew never put there, else `null`. Each read is bounded by the
|
|
501
|
+
* recorded size and never blocks (a FIFO is refused).
|
|
502
|
+
*/
|
|
503
|
+
export function packetChanged(runDir, attempt, files) {
|
|
504
|
+
let root;
|
|
505
|
+
try {
|
|
506
|
+
root = realpathSync(runDir);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return files[0]?.name ?? null;
|
|
510
|
+
}
|
|
511
|
+
for (const f of files) {
|
|
512
|
+
try {
|
|
513
|
+
const { data } = readBeneath(root, `${attempt}/${packetDirOf(f.name)}/${f.name}`, f.bytes);
|
|
514
|
+
if (data.length !== f.bytes || createHash('sha256').update(data).digest('hex') !== f.sha256)
|
|
515
|
+
return f.name;
|
|
516
|
+
}
|
|
517
|
+
catch {
|
|
518
|
+
return f.name;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Nothing extra: a file the writer added to records/ would otherwise be a citable "record".
|
|
522
|
+
const want = new Set(files.map((f) => `${packetDirOf(f.name)}/${f.name}`));
|
|
523
|
+
for (const d of ['sources', 'records']) {
|
|
524
|
+
const listed = (() => {
|
|
525
|
+
try {
|
|
526
|
+
return listBeneath(root, `${attempt}/${d}`, 4096);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
})();
|
|
532
|
+
if (listed === null || listed.more)
|
|
533
|
+
return `${d}/`;
|
|
534
|
+
for (const n of listed.names)
|
|
535
|
+
if (!want.has(`${d}/${n}`))
|
|
536
|
+
return `${d}/${n}`;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
383
540
|
/** The seam's durable state (handoff ledger + working dirs) follows the daemon's state home —
|
|
384
541
|
* the `--db` parent when configured, `~/.wicked-crew` otherwise (crew#353): an isolated
|
|
385
542
|
* daemon's interactive handoffs must not land in the operator's real home. */
|
|
@@ -443,6 +600,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
443
600
|
// Keyed by the per-project handoff key (crew#809: two projects finalizing the same slug must not
|
|
444
601
|
// clear each other's marker); the VALUE is the document id `inFlightDocs()` reports.
|
|
445
602
|
const finalizing = new Map();
|
|
603
|
+
/** A3: runs whose finalize (floor, announce) is under way: a redelivered terminal frame is not an orphan's. */
|
|
604
|
+
const finalizingRuns = new Set();
|
|
446
605
|
let closed = false; // set by stop(): a handler mid-snapshot must never launch after shutdown
|
|
447
606
|
/** Emit onto interactive's vocabulary as the `wi-crew` producer. Never throws into the
|
|
448
607
|
* caller: narration/announce failures are logged — a lost status line must not kill the
|
|
@@ -518,6 +677,20 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
518
677
|
}
|
|
519
678
|
}
|
|
520
679
|
}
|
|
680
|
+
/** A3: record a proposal-bound attempt's outcome on its document's binding (the first outcome counts), then tell the
|
|
681
|
+
* daemon (release, retry proposal, `landing_failed` on the run). */
|
|
682
|
+
function reportBound(flight, runId, outcome) {
|
|
683
|
+
if (flight.bound === undefined)
|
|
684
|
+
return;
|
|
685
|
+
if (opts.bindings?.setOutcome(flight.bound.ref, runId, outcome) !== true)
|
|
686
|
+
return;
|
|
687
|
+
try {
|
|
688
|
+
opts.onBoundOutcome?.(flight.bound.ref, runId, outcome);
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
log(`[interactive-draft] recording the outcome of run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
521
694
|
/** Terminal-event fold: turn the governed run's own events into interactive narration, and
|
|
522
695
|
* close the loop with `draft.completed` when the run lands. */
|
|
523
696
|
const foldEvent = (event) => {
|
|
@@ -528,8 +701,25 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
528
701
|
if (planned?.observe(event) === true)
|
|
529
702
|
return;
|
|
530
703
|
const flight = inFlight.get(runId);
|
|
531
|
-
if (flight === undefined)
|
|
704
|
+
if (flight === undefined) {
|
|
705
|
+
// A3 (§6.2): a proposal-bound run this process holds no flight for (a restart lost it, and the boot reconcile
|
|
706
|
+
// could not see it end in time) still ends its attempt: released, Try again offered. Never relaunched.
|
|
707
|
+
if ((event.type === 'sessionCompleted' || event.type === 'sessionFailed' || event.type === 'runCancelled') && opts.bindings !== undefined && !finalizingRuns.has(runId)) {
|
|
708
|
+
const hit = opts.bindings.attemptOf(runId);
|
|
709
|
+
if (hit !== null && hit.attempt.outcome === undefined && hit.attempt.emittedAt === undefined) {
|
|
710
|
+
const outcome = { kind: 'failed', reason: 'interrupted by a restart' };
|
|
711
|
+
if (opts.bindings.setOutcome(hit.binding.ref, runId, outcome)) {
|
|
712
|
+
try {
|
|
713
|
+
opts.onBoundOutcome?.(hit.binding.ref, runId, outcome);
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
log(`[interactive-draft] recording the outcome of run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
532
721
|
return;
|
|
722
|
+
}
|
|
533
723
|
// F-4R2-005: every narration line and heartbeat from here carries the run id and the ord of the
|
|
534
724
|
// latest unit-scoped frame (`narrationStamps`), so a skin keys the thread per run and per unit.
|
|
535
725
|
flight.runId ??= runId;
|
|
@@ -636,10 +826,14 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
636
826
|
endFlight(runId);
|
|
637
827
|
// The doc stays busy until the floor and the announce are done (codex on crew#725).
|
|
638
828
|
finalizing.set(draftHandoffKey(flight.documentId, flight.projectId), flight.documentId);
|
|
829
|
+
finalizingRuns.add(runId);
|
|
639
830
|
// The announce awaits the bus writer; a throw is logged, as a synchronous one was.
|
|
640
831
|
finalize(flight, runId)
|
|
641
832
|
.catch((err) => log(`[interactive-draft] finalizing run ${runId} failed: ${err instanceof Error ? err.message : String(err)}`))
|
|
642
|
-
.finally(() =>
|
|
833
|
+
.finally(() => {
|
|
834
|
+
finalizing.delete(draftHandoffKey(flight.documentId, flight.projectId));
|
|
835
|
+
finalizingRuns.delete(runId);
|
|
836
|
+
});
|
|
643
837
|
return;
|
|
644
838
|
}
|
|
645
839
|
if (event.type === 'stepFailed') {
|
|
@@ -653,7 +847,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
653
847
|
if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
|
|
654
848
|
endFlight(runId);
|
|
655
849
|
removeSnapshots(flight); // the failure path cleans its snapshots too (CREW-UX-8 v4)
|
|
656
|
-
ledger.recordFailure(draftHandoffKey(flight.documentId, flight.projectId));
|
|
850
|
+
ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(flight.documentId, flight.projectId));
|
|
657
851
|
const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
|
|
658
852
|
emitStatus({
|
|
659
853
|
...docScope(flight.documentId, flight.projectId),
|
|
@@ -661,6 +855,12 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
661
855
|
message: `The crew run answering this document ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
|
|
662
856
|
`(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}); the assist loop can still take over.`,
|
|
663
857
|
});
|
|
858
|
+
if (flight.bound !== undefined)
|
|
859
|
+
removePacketCopy(flight.bound.runDir, flight.bound.attempt);
|
|
860
|
+
reportBound(flight, runId, {
|
|
861
|
+
kind: 'failed',
|
|
862
|
+
reason: `${event.type === 'runCancelled' ? 'the run was stopped' : 'the run failed'}${flight.failureDetail !== undefined ? `: ${oneLine(flight.failureDetail, 300)}` : ''}`,
|
|
863
|
+
});
|
|
664
864
|
log(`[interactive-draft] run ${runId} for doc ${flight.documentId} ended: ${event.type}`);
|
|
665
865
|
}
|
|
666
866
|
};
|
|
@@ -678,26 +878,68 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
678
878
|
if (!ok) {
|
|
679
879
|
// The run is terminal — its grounding snapshots are done serving reads.
|
|
680
880
|
removeSnapshots(flight);
|
|
681
|
-
ledger.recordFailure(draftHandoffKey(documentId, projectId));
|
|
881
|
+
ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
|
|
682
882
|
emitStatus({
|
|
683
883
|
...docScope(documentId, projectId),
|
|
684
884
|
state: 'error',
|
|
685
885
|
message: `The crew run completed but produced no draft file at ${outPath} (run ${runId}).`,
|
|
686
886
|
});
|
|
887
|
+
if (flight.bound !== undefined)
|
|
888
|
+
removePacketCopy(flight.bound.runDir, flight.bound.attempt);
|
|
889
|
+
reportBound(flight, runId, { kind: 'failed', reason: 'the run finished without writing the draft file' });
|
|
687
890
|
log(`[interactive-draft] run ${runId} completed but ${outPath} is missing/empty`);
|
|
688
891
|
return;
|
|
689
892
|
}
|
|
893
|
+
// A3 (§5.3): the source packet the run was given is checked against the hashes crew holds before anything lands —
|
|
894
|
+
// a changed or missing file fails the landing (the reviewing step was told to check them too).
|
|
895
|
+
if (flight.bound !== undefined) {
|
|
896
|
+
const changed = packetChanged(flight.bound.runDir, flight.bound.attempt, flight.bound.files);
|
|
897
|
+
if (changed !== null) {
|
|
898
|
+
removeSnapshots(flight);
|
|
899
|
+
ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
|
|
900
|
+
emitStatus({
|
|
901
|
+
...docScope(documentId, projectId),
|
|
902
|
+
state: 'error',
|
|
903
|
+
message: `The draft was not landed: its source file ${changed} changed during the run (run ${runId}), so crew cannot vouch for what it was made from.`,
|
|
904
|
+
});
|
|
905
|
+
removePacketCopy(flight.bound.runDir, flight.bound.attempt);
|
|
906
|
+
reportBound(flight, runId, { kind: 'failed', reason: `the source packet changed during the run (${changed})` });
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
let floorRecords = null;
|
|
911
|
+
if (flight.bound !== undefined && opts.bindings !== undefined) {
|
|
912
|
+
try {
|
|
913
|
+
floorRecords = mkdtempSync(join(tmpdir(), 'crew-floor-records-'));
|
|
914
|
+
for (const f of flight.bound.files) {
|
|
915
|
+
if (packetDirOf(f.name) !== 'records')
|
|
916
|
+
continue;
|
|
917
|
+
writeFileSync(join(floorRecords, f.name), opts.bindings.packetBytes(flight.bound.ref, f.name), { mode: 0o600, flag: 'wx' });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
log(`[interactive-draft] run ${runId}: crew's copy of the records for the floor failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
690
924
|
// THE DRAFT FLOOR (crew#621 / crew#504): the document's own verdict, re-derived by crew from
|
|
691
925
|
// the artifact before it is published — the same `wicked-garden-draft` self-check the worker
|
|
692
926
|
// was told to run, on the same inputs (hence BEFORE the snapshots go: the claims scan traces
|
|
693
927
|
// every number and URL to them). A breach fails the draft naming the rule; a floor that could
|
|
694
928
|
// not run is published with that said, never rounded to a pass. Every run is held to it: the
|
|
695
929
|
// preset always runs the skill, and a snapshot without it fails the run (crew#935).
|
|
696
|
-
const floorVerdict = await runDraftFloor(outPath, flight.floor?.budget ?? { pages: null, exact: false, source: 'unknown' },
|
|
930
|
+
const floorVerdict = await runDraftFloor(outPath, flight.floor?.budget ?? { pages: null, exact: false, source: 'unknown' },
|
|
931
|
+
// A3: the packet's records are a claims source too (the run briefs, documents and attachments a figure may cite),
|
|
932
|
+
// read from crew's OWN copy of exactly the verified files, never the writable inbox.
|
|
933
|
+
[...flight.snapshotDirs, ...(floorRecords !== null ? [floorRecords] : [])], { ...(flight.floor !== undefined ? { style: flight.floor.style } : {}) }, opts.draftFloorIo ?? {});
|
|
697
934
|
removeSnapshots(flight);
|
|
935
|
+
if (floorRecords !== null)
|
|
936
|
+
rmSync(floorRecords, { recursive: true, force: true });
|
|
937
|
+
// The packet served its last read: crew keeps the canonical copy, the inbox keeps only the deliverable.
|
|
938
|
+
if (flight.bound !== undefined)
|
|
939
|
+
removePacketCopy(flight.bound.runDir, flight.bound.attempt);
|
|
698
940
|
log(`[interactive-draft] run ${runId} draft floor: ${floorVerdict.verdict} — ${floorVerdict.summary}`);
|
|
699
941
|
if (floorVerdict.verdict === 'fail') {
|
|
700
|
-
ledger.recordFailure(draftHandoffKey(documentId, projectId));
|
|
942
|
+
ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
|
|
701
943
|
emitStatus({
|
|
702
944
|
...docScope(documentId, projectId),
|
|
703
945
|
state: 'error',
|
|
@@ -705,6 +947,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
705
947
|
`${floorVerdict.breaches.join(' ')} The file crew checked is at ${outPath}; ask again — ` +
|
|
706
948
|
`the brief's hard constraints are part of the deliverable, not a preference.`,
|
|
707
949
|
});
|
|
950
|
+
reportBound(flight, runId, { kind: 'failed', reason: `the draft did not meet the document floor: ${oneLine(floorVerdict.breaches.join(' '), 300)}` });
|
|
708
951
|
return;
|
|
709
952
|
}
|
|
710
953
|
if (floorVerdict.verdict === 'unverified' || floorVerdict.verdict === 'unavailable') {
|
|
@@ -717,22 +960,35 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
717
960
|
}
|
|
718
961
|
// ADR-0019 D5: announce by path — the service reads the file itself, so a large draft
|
|
719
962
|
// never rides the bus payload. The deterministic key makes a re-announce a no-op (the key resolves to the existing row).
|
|
720
|
-
|
|
963
|
+
// A3: the bound attempt is announced: from here only a landing (`version.created`) or a refusal ends it. Marked
|
|
964
|
+
// BEFORE the emit, so a restart in between is reconciled against the document's head (`server.ts`).
|
|
965
|
+
// An attempt that already has an outcome (recorded some other way meanwhile) is never announced after it.
|
|
966
|
+
if (flight.bound !== undefined && opts.bindings?.attemptOf(runId)?.attempt.outcome !== undefined) {
|
|
967
|
+
log(`[interactive-draft] run ${runId}: its attempt already has an outcome — not announced`);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (flight.bound !== undefined)
|
|
971
|
+
opts.bindings?.markEmitted(flight.bound.ref, runId);
|
|
972
|
+
const emitted = await emitInteractive(DRAFT_COMPLETED,
|
|
973
|
+
// `run_id` (DES §12): which attempt this draft is; interactive ≥ the I1 contract records it on the version. A bound
|
|
974
|
+
// attempt also says the head it started from (`expect_head`): that contract lands nothing on a moved head.
|
|
975
|
+
{ ...docScope(documentId, projectId), html_path: outPath, run_id: runId, ...(flight.bound !== undefined ? { expect_head: flight.bound.base } : {}) }, draftIdempotencyKey(documentId, projectId, runId));
|
|
721
976
|
if (!emitted) {
|
|
722
977
|
// The bus refused the announce: the draft exists on disk but never reached
|
|
723
978
|
// the service. Fail HONEST — leaving the ledger row launched-but-never-closed would
|
|
724
979
|
// silently eat every replay of this doc (the launch gate is `ledger.has`).
|
|
725
|
-
ledger.recordFailure(draftHandoffKey(documentId, projectId));
|
|
980
|
+
ledger.recordFailure(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
|
|
726
981
|
emitStatus({
|
|
727
982
|
...docScope(documentId, projectId),
|
|
728
983
|
state: 'error',
|
|
729
984
|
message: `Crew finished the draft but could not announce it on the bus (run ${runId}); ` +
|
|
730
985
|
`the draft file is at ${outPath}. Inspect the crew daemon log.`,
|
|
731
986
|
});
|
|
987
|
+
reportBound(flight, runId, { kind: 'failed', reason: 'crew could not announce the draft on the bus' });
|
|
732
988
|
log(`[interactive-draft] draft.completed emit FAILED for doc ${documentId} (run ${runId}) — recorded as failure`);
|
|
733
989
|
return;
|
|
734
990
|
}
|
|
735
|
-
ledger.recordEmitted(draftHandoffKey(documentId, projectId));
|
|
991
|
+
ledger.recordEmitted(flight.ledgerKey ?? draftHandoffKey(documentId, projectId));
|
|
736
992
|
emitStatus({
|
|
737
993
|
...docScope(documentId, projectId),
|
|
738
994
|
state: 'complete',
|
|
@@ -751,6 +1007,16 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
751
1007
|
`no draft run is launched for it (crew#811)`);
|
|
752
1008
|
return;
|
|
753
1009
|
}
|
|
1010
|
+
// A3: a document a chat proposal made is never answered with a fresh random-id launch. Its Go recorded the binding
|
|
1011
|
+
// BEFORE the create, so the trigger finds it and hands the document to the proposal's one launcher per run id,
|
|
1012
|
+
// which adopts the run Go already started (or starts the one a lost trigger never did).
|
|
1013
|
+
const boundRef = `${doc.projectId ?? UNFILED_MOUNT}/${doc.documentId}`;
|
|
1014
|
+
if (opts.bindings?.get(boundRef) != null) {
|
|
1015
|
+
log(`[interactive-draft] doc ${doc.documentId}${where} was made by a chat proposal — its launcher answers it`);
|
|
1016
|
+
// Not awaited: the launcher waits for Go's own launch (the run id's lock), which must not hold up other documents.
|
|
1017
|
+
void (opts.onBoundDocCreated?.(boundRef, doc.documentId) ?? Promise.resolve()).catch((err) => log(`[interactive-draft] doc ${doc.documentId}${where}: its proposal's launcher failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
754
1020
|
// Replay-dedup: the ledger is the durable gate (redelivery after crash/restart), the
|
|
755
1021
|
// in-flight scan the live one (redelivery inside a single process lifetime). Keyed per
|
|
756
1022
|
// PROJECT (crew#809): the same slug in another project is another document.
|
|
@@ -763,6 +1029,14 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
763
1029
|
if (f.documentId === doc.documentId && f.projectId === doc.projectId)
|
|
764
1030
|
return;
|
|
765
1031
|
}
|
|
1032
|
+
await launchDraft(doc, randomUUID(), key, undefined);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* One draft launch: the plain path (a random run id, the doc's create-time grounding) and a proposal-bound one (A3:
|
|
1036
|
+
* the derived run id, the chat's scope repositories and graph, the card's helpers, the source packet). Throws when
|
|
1037
|
+
* nothing was launched.
|
|
1038
|
+
*/
|
|
1039
|
+
async function launchDraft(doc, runId, key, bound) {
|
|
766
1040
|
// Per-run ISOLATION (Copilot, crew#313): every launch gets its OWN subdirectory holding
|
|
767
1041
|
// both the deliverable and (when grounded) the repo snapshot, and declares ONLY that
|
|
768
1042
|
// subdirectory as its extra write root below. Declaring the shared draftDir wholesale let
|
|
@@ -771,8 +1045,10 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
771
1045
|
// NOT created here (Copilot round 2): runDir is only mkdir'd after the snapshot helper's
|
|
772
1046
|
// containment check has ruled the location safe — see the pre-launch mkdir below.
|
|
773
1047
|
const runDir = join(draftDir, doc.documentId);
|
|
774
|
-
|
|
775
|
-
|
|
1048
|
+
// A3: a proposal-bound attempt works in its own folder: its deliverable and packet never share a name with an
|
|
1049
|
+
// earlier attempt's (or a same-named document's in another project, once that attempt ended).
|
|
1050
|
+
const attemptDir = bound !== undefined ? join(runDir, attemptDirName(runId)) : runDir;
|
|
1051
|
+
const outPath = join(attemptDir, `${doc.documentId}-v1.html`);
|
|
776
1052
|
// PRE-LAUNCH placeholder (Copilot round 2): the awaits below (repo resolution, snapshot)
|
|
777
1053
|
// open a window in which this doc has no `inFlight` entry — so the chat seam's `isDocBusy`
|
|
778
1054
|
// saw it idle (double-launch race) and stop()'s sweep could not find a half-made snapshot.
|
|
@@ -785,6 +1061,10 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
785
1061
|
snapshotDirs: [],
|
|
786
1062
|
narration: 'Crew run launched — working on your draft…',
|
|
787
1063
|
floor: { budget: pageBudgetFor(doc.brief, doc.style), style: doc.style },
|
|
1064
|
+
ledgerKey: key,
|
|
1065
|
+
...(bound !== undefined
|
|
1066
|
+
? { bound: { ref: bound.artifactRef, runDir, attempt: attemptDirName(runId), attemptDir, files: bound.packet.files.map((f) => ({ name: f.name, sha256: f.sha256, bytes: f.bytes.length })), base: bound.base } }
|
|
1067
|
+
: {}),
|
|
788
1068
|
};
|
|
789
1069
|
inFlight.set(runId, flight);
|
|
790
1070
|
// F-046: WHICH repositories is this document about? The create request's `repo_ref(s)` (the
|
|
@@ -797,13 +1077,18 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
797
1077
|
// frame goes unanswered — fail closed, like the sibling seams' docs-root reads. An UNFILED doc
|
|
798
1078
|
// reads its binding too (studio#302): the proxy records the create's seat choice and
|
|
799
1079
|
// provenance under the default mount, and only the repo grounding is project-only.
|
|
800
|
-
const binding = groundingStore !== undefined
|
|
1080
|
+
const binding = groundingStore !== undefined && bound === undefined
|
|
801
1081
|
? await groundingStore.waitFor(resolveDocsRoot(doc.projectId), doc.documentId, doc.projectId ?? DEFAULT_PROJECT_ID, GROUNDING_BINDING_WAIT_MS)
|
|
802
1082
|
: undefined;
|
|
803
|
-
const docClisJson = binding?.clis_json;
|
|
804
|
-
const docChannel = binding?.channel;
|
|
805
|
-
const docActor = binding?.actor;
|
|
806
|
-
if (
|
|
1083
|
+
const docClisJson = bound?.clisJson ?? binding?.clis_json;
|
|
1084
|
+
const docChannel = bound !== undefined ? 'studio' : binding?.channel;
|
|
1085
|
+
const docActor = bound?.actor ?? binding?.actor;
|
|
1086
|
+
if (bound !== undefined) {
|
|
1087
|
+
// DES §5.2: a chat-made document is grounded on the chat's scope repositories, filed OR Unfiled (the accepted
|
|
1088
|
+
// record is what lifted the Unfiled rule at the create).
|
|
1089
|
+
decision = { repos: bound.repos, source: 'named', missing: [], ambiguous: [], memberCount: bound.repos.length };
|
|
1090
|
+
}
|
|
1091
|
+
else if (doc.projectId !== undefined) {
|
|
807
1092
|
decision = await resolveGroundingRepos(adapter, doc.projectId, doc.brief, binding?.repo_refs, log);
|
|
808
1093
|
}
|
|
809
1094
|
emitStatus({
|
|
@@ -917,9 +1202,9 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
917
1202
|
// crew#512: the same facts as DATA — recorded beside the doc first (a skin that refetches the
|
|
918
1203
|
// docs list on this frame finds it there), then on the narration frame itself. The record is
|
|
919
1204
|
// written even when there is no line to say (a repo-less project): the list still answers.
|
|
920
|
-
if (decision !== undefined && doc.projectId !== undefined) {
|
|
1205
|
+
if (decision !== undefined && (doc.projectId !== undefined || bound !== undefined)) {
|
|
921
1206
|
const record = groundingRecord(decision, snapshotted);
|
|
922
|
-
if (groundingStore !== undefined) {
|
|
1207
|
+
if (groundingStore !== undefined && doc.projectId !== undefined) {
|
|
923
1208
|
try {
|
|
924
1209
|
groundingStore.recordGrounding(resolveDocsRoot(doc.projectId), doc.documentId, doc.projectId, record);
|
|
925
1210
|
}
|
|
@@ -939,10 +1224,35 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
939
1224
|
endFlight(runId);
|
|
940
1225
|
removeSnapshots(flight);
|
|
941
1226
|
log(`[interactive-draft] doc ${doc.documentId}: subscriber stopped before launch — abandoned (a replay retries)`);
|
|
1227
|
+
// A bound launch reports it: its proposal stays accepting, and Go again resumes the same identity.
|
|
1228
|
+
if (bound !== undefined)
|
|
1229
|
+
throw new Error('the daemon is stopping; nothing was launched');
|
|
942
1230
|
return;
|
|
943
1231
|
}
|
|
944
1232
|
// Containment is settled (any repo overlap refused above) — the run's write root may exist.
|
|
945
1233
|
mkdirSync(runDir, { recursive: true });
|
|
1234
|
+
let packetLine = '';
|
|
1235
|
+
if (bound !== undefined) {
|
|
1236
|
+
// A3: an earlier attempt's folder goes first (its deliverable can never pass this attempt's floor, and an
|
|
1237
|
+
// announce of it still on the bus can no longer land: its file is gone), then this attempt gets crew's copy of its
|
|
1238
|
+
// packet, through no link (§5.3).
|
|
1239
|
+
try {
|
|
1240
|
+
// (CreateLauncher fenced the earlier attempts before it read the base; this repeats it, idempotently, and clears
|
|
1241
|
+
// what an earlier try of THIS attempt left if the engine refused it: Go again starts from a fresh folder.)
|
|
1242
|
+
const left = removeAttemptDirs(runDir, (opts.bindings?.get(bound.artifactRef)?.attempts ?? []).map((a) => a.runId));
|
|
1243
|
+
if (left.length > 0)
|
|
1244
|
+
throw new Error(`an earlier attempt's folder could not be removed (${left.join(', ')})`);
|
|
1245
|
+
writePacketCopy(runDir, attemptDirName(runId), bound.packet);
|
|
1246
|
+
}
|
|
1247
|
+
catch (err) {
|
|
1248
|
+
endFlight(runId);
|
|
1249
|
+
removeSnapshots(flight);
|
|
1250
|
+
const message = `Crew could not give this draft its sources: ${err instanceof Error ? err.message : String(err)}.`;
|
|
1251
|
+
emitStatus({ ...docScope(doc.documentId, doc.projectId), state: 'error', message });
|
|
1252
|
+
throw new Error(message);
|
|
1253
|
+
}
|
|
1254
|
+
packetLine = ' ' + packetClause(attemptDir, bound.packet, PROCESS_NOTES_WHERE);
|
|
1255
|
+
}
|
|
946
1256
|
// Resolve the project's graph BEFORE the launch, never indexing (a refresh is
|
|
947
1257
|
// `wicked-estate index` per member, bounded at 600s EACH — doing that inside a launch turns
|
|
948
1258
|
// "start a draft" into an unannounced multi-repo job). Missing or stale degrades to no
|
|
@@ -955,7 +1265,11 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
955
1265
|
// but says so — a silent `catch(() => null)` would make a broken binding indistinguishable
|
|
956
1266
|
// from a project that simply has no graph yet.
|
|
957
1267
|
let projectGraphBinding = null;
|
|
958
|
-
if (
|
|
1268
|
+
if (bound !== undefined) {
|
|
1269
|
+
// The stored graph binding (DES §5.2), whatever the mount.
|
|
1270
|
+
projectGraphBinding = bound.graphDb !== null ? { dbPath: bound.graphDb } : null;
|
|
1271
|
+
}
|
|
1272
|
+
else if (doc.projectId !== undefined) {
|
|
959
1273
|
const decision = await resolveProjectGraphBinding(adapter, doc.projectId, undefined).catch((err) => ({
|
|
960
1274
|
binding: null,
|
|
961
1275
|
reason: `the project graph binding could not be resolved ` +
|
|
@@ -965,6 +1279,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
965
1279
|
projectGraphBinding = decision.binding;
|
|
966
1280
|
log(`run ${runId}: ${decision.reason}`);
|
|
967
1281
|
}
|
|
1282
|
+
if (bound !== undefined)
|
|
1283
|
+
opts.bindings?.markLaunching(bound.artifactRef, runId);
|
|
968
1284
|
try {
|
|
969
1285
|
unitsByRun.set(runId, new RunUnits()); // before the launch: its first plan is announced during it
|
|
970
1286
|
await adapter.launchRun({
|
|
@@ -980,7 +1296,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
980
1296
|
: undefined) +
|
|
981
1297
|
// The quality floor's inputs (draft-skill.ts): the preset's steps always run the skill.
|
|
982
1298
|
' ' +
|
|
983
|
-
draftQualityClause(outPath, pageBudgetFor(doc.brief, doc.style), subjects.flatMap((s) => (s.snapshotDir !== undefined ? [s.snapshotDir] : [])), { style: doc.style })
|
|
1299
|
+
draftQualityClause(outPath, pageBudgetFor(doc.brief, doc.style), [...subjects.flatMap((s) => (s.snapshotDir !== undefined ? [s.snapshotDir] : [])), ...(bound !== undefined ? [join(attemptDir, 'records')] : [])], { style: doc.style }) +
|
|
1300
|
+
packetLine,
|
|
984
1301
|
sessionId: runId,
|
|
985
1302
|
clisJson: docClisJson ?? opts.clisJson ?? JSON.stringify(rosterOf(adapter, opts.roster)),
|
|
986
1303
|
workflow: INTERACTIVE_DRAFT_WORKFLOW,
|
|
@@ -1019,6 +1336,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
1019
1336
|
}
|
|
1020
1337
|
catch (err) {
|
|
1021
1338
|
unitsByRun.delete(runId);
|
|
1339
|
+
if (bound !== undefined)
|
|
1340
|
+
opts.bindings?.clearLaunching(bound.artifactRef, runId);
|
|
1022
1341
|
// A launch that never happened keeps no flight and no snapshot (a replayed frame
|
|
1023
1342
|
// re-registers and re-snapshots fresh).
|
|
1024
1343
|
endFlight(runId);
|
|
@@ -1041,6 +1360,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
1041
1360
|
// delivery retries. The crash window between launch and this write is the reason the
|
|
1042
1361
|
// draft emit ALSO carries a deterministic idempotency key.
|
|
1043
1362
|
ledger.recordLaunch(key, runId);
|
|
1363
|
+
if (bound !== undefined)
|
|
1364
|
+
opts.bindings?.markLaunched(bound.artifactRef, runId);
|
|
1044
1365
|
if (closed) {
|
|
1045
1366
|
// stop() ran while the engine was accepting the launch: its sweep already dropped the
|
|
1046
1367
|
// placeholder and the snapshot, and the engine's workers die with the daemon — the
|
|
@@ -1053,6 +1374,8 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
1053
1374
|
deliver: 'none', // crew#762: a document draft publishes a document, it never delivers a PR
|
|
1054
1375
|
...(docChannel !== undefined ? { channel: docChannel } : {}),
|
|
1055
1376
|
...(docActor !== undefined ? { actor: docActor } : {}),
|
|
1377
|
+
// A3 (DES §5.2 "Filing"): the run is filed under the chat, and says which proposal and artifact it is.
|
|
1378
|
+
...(bound !== undefined ? { chatId: bound.chatId, proposalId: bound.proposalId, artifactRef: bound.artifactRef } : {}),
|
|
1056
1379
|
});
|
|
1057
1380
|
// Upgrade the placeholder to a live flight: the heartbeat starts once the run exists.
|
|
1058
1381
|
flight.heartbeat = setInterval(() => {
|
|
@@ -1085,11 +1408,98 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
1085
1408
|
pollIntervalMs: opts.pollIntervalMs ?? 2000,
|
|
1086
1409
|
}),
|
|
1087
1410
|
});
|
|
1411
|
+
// A3 (§6.2): a bound attempt's outcome is its LANDING, not the run's end: interactive's `version.created` for the
|
|
1412
|
+
// document after crew's announce (an interactive with the I1 contract also names the run; an older one is matched by
|
|
1413
|
+
// the document and its `generated` kind, as only one attempt per document is ever live), or its `draft.conflict`.
|
|
1414
|
+
async function handleLanding(event) {
|
|
1415
|
+
const bindings = opts.bindings;
|
|
1416
|
+
if (bindings === undefined || (event.event_type !== VERSION_CREATED && event.event_type !== DRAFT_CONFLICT))
|
|
1417
|
+
return;
|
|
1418
|
+
const p = (typeof event.payload === 'object' && event.payload !== null ? event.payload : {});
|
|
1419
|
+
const documentId = typeof p['document_id'] === 'string' ? p['document_id'] : '';
|
|
1420
|
+
if (!DOC_NAME.test(documentId))
|
|
1421
|
+
return;
|
|
1422
|
+
const projectId = typeof p['project_id'] === 'string' && p['project_id'] !== '' ? p['project_id'] : null;
|
|
1423
|
+
const runIdP = typeof p['run_id'] === 'string' && p['run_id'] !== '' ? p['run_id'] : null;
|
|
1424
|
+
const b = bindings.get(`${projectId ?? UNFILED_MOUNT}/${documentId}`);
|
|
1425
|
+
if (b === null)
|
|
1426
|
+
return;
|
|
1427
|
+
const version = typeof p['version'] === 'number' ? p['version'] : null;
|
|
1428
|
+
const ts = typeof p['ts'] === 'string' ? Date.parse(p['ts']) : NaN;
|
|
1429
|
+
const a = b.attempts.find((x) => x.outcome === undefined && x.emittedAt !== undefined &&
|
|
1430
|
+
(runIdP !== null
|
|
1431
|
+
? x.runId === runIdP
|
|
1432
|
+
// An interactive before the I1 contract names no run: only a generated version past the attempt's base, made
|
|
1433
|
+
// after crew announced it (a delayed event from before cannot settle it), is its landing.
|
|
1434
|
+
: event.event_type === VERSION_CREATED && p['kind'] === 'generated' && version !== null && version > x.base &&
|
|
1435
|
+
!Number.isNaN(ts) && ts >= x.emittedAt));
|
|
1436
|
+
if (a === undefined)
|
|
1437
|
+
return;
|
|
1438
|
+
const outcome = event.event_type === VERSION_CREATED
|
|
1439
|
+
? { kind: 'landed', version: version ?? 0 }
|
|
1440
|
+
: { kind: 'failed', reason: `the document changed while it was being drafted (it was at v${String(p['expect_head'] ?? '?')}, now v${String(p['actual_head'] ?? '?')}), so nothing landed` };
|
|
1441
|
+
if (!bindings.setOutcome(b.ref, a.runId, outcome))
|
|
1442
|
+
return;
|
|
1443
|
+
log(`[interactive-draft] run ${a.runId} for ${b.ref}: ${outcome.kind === 'landed' ? `landed v${outcome.version}` : outcome.reason}`);
|
|
1444
|
+
try {
|
|
1445
|
+
opts.onBoundOutcome?.(b.ref, a.runId, outcome);
|
|
1446
|
+
}
|
|
1447
|
+
catch (err) {
|
|
1448
|
+
log(`[interactive-draft] recording the outcome of run ${a.runId} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const landingTap = opts.bindings === undefined
|
|
1452
|
+
? null
|
|
1453
|
+
: await tapBus({
|
|
1454
|
+
dbPath: busDbPath,
|
|
1455
|
+
filter: 'wicked.interactive.**@wicked-interactive',
|
|
1456
|
+
pollIntervalMs: opts.pollIntervalMs ?? 2000,
|
|
1457
|
+
handler: (event) => handleLanding(event),
|
|
1458
|
+
onError: busSubscriberErrorReporter({
|
|
1459
|
+
describe: (err, event) => `[interactive-draft] landing handler error on event ${String(event?.event_id ?? '?')}: ${err.message}`,
|
|
1460
|
+
log,
|
|
1461
|
+
logError: opts.logError,
|
|
1462
|
+
pollIntervalMs: opts.pollIntervalMs ?? 2000,
|
|
1463
|
+
}),
|
|
1464
|
+
}).catch((err) => {
|
|
1465
|
+
log(`[interactive-draft] could not watch landings (bound drafts settle at the next restart): ${err instanceof Error ? err.message : String(err)}`);
|
|
1466
|
+
return null;
|
|
1467
|
+
});
|
|
1088
1468
|
return {
|
|
1089
1469
|
ledger,
|
|
1090
1470
|
inFlightDocs: () => [
|
|
1091
|
-
...new Set([
|
|
1471
|
+
...new Set([
|
|
1472
|
+
...[...inFlight.values()].map((f) => f.documentId),
|
|
1473
|
+
...finalizing.values(),
|
|
1474
|
+
// A3 (§6.2): a proposal-bound document stays busy until its attempt's outcome is recorded (announced and not
|
|
1475
|
+
// yet landed, or reconciling at boot), for every seam that serializes on this list.
|
|
1476
|
+
...(opts.bindings?.all() ?? []).filter((b) => b.attempts.some((a) => a.outcome === undefined && (a.launchingAt !== undefined || a.launchedAt !== undefined || a.emittedAt !== undefined))).map((b) => b.doc),
|
|
1477
|
+
]),
|
|
1092
1478
|
],
|
|
1479
|
+
fenceBound: (documentId, runIds) => {
|
|
1480
|
+
const left = removeAttemptDirs(join(draftDir, documentId), runIds);
|
|
1481
|
+
if (left.length > 0)
|
|
1482
|
+
throw new Error(`an earlier attempt of this document could not be fenced (${left.join(', ')} is still there)`);
|
|
1483
|
+
},
|
|
1484
|
+
launchBound: async (spec) => {
|
|
1485
|
+
if (closed)
|
|
1486
|
+
throw new Error('the draft seam is stopping');
|
|
1487
|
+
for (const f of inFlight.values()) {
|
|
1488
|
+
if (f.documentId === spec.documentId && f.projectId === spec.projectId) {
|
|
1489
|
+
throw new Error(`a draft of ${spec.artifactRef} is already running (run ${f.runId ?? 'starting'})`);
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (finalizing.has(draftHandoffKey(spec.documentId, spec.projectId)))
|
|
1493
|
+
throw new Error(`a draft of ${spec.artifactRef} is still landing`);
|
|
1494
|
+
const doc = {
|
|
1495
|
+
documentId: spec.documentId,
|
|
1496
|
+
brief: spec.brief,
|
|
1497
|
+
sourcePaths: [],
|
|
1498
|
+
style: spec.style,
|
|
1499
|
+
...(spec.projectId !== undefined ? { projectId: spec.projectId } : {}),
|
|
1500
|
+
};
|
|
1501
|
+
await launchDraft(doc, spec.runId, boundAttemptKey(spec.documentId, spec.projectId, spec.runId), spec);
|
|
1502
|
+
},
|
|
1093
1503
|
stop: async () => {
|
|
1094
1504
|
closed = true; // a handler mid-snapshot sees this and never launches (Copilot round 2)
|
|
1095
1505
|
offCoreEvents();
|
|
@@ -1106,6 +1516,7 @@ export async function startInteractiveDraftSubscriber(adapter, opts = {}) {
|
|
|
1106
1516
|
removeSnapshots(flight);
|
|
1107
1517
|
}
|
|
1108
1518
|
finalizing.clear(); // nothing is finalizing after the subscriber detaches
|
|
1519
|
+
await landingTap?.stop();
|
|
1109
1520
|
await sub.stop();
|
|
1110
1521
|
},
|
|
1111
1522
|
};
|