wicked-crew 0.6.0 → 0.7.0
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/api/audit.d.ts +13 -0
- package/dist/api/audit.d.ts.map +1 -1
- package/dist/api/audit.js +18 -2
- package/dist/api/audit.js.map +1 -1
- package/dist/api/guidance-index.d.ts +39 -0
- package/dist/api/guidance-index.d.ts.map +1 -0
- package/dist/api/guidance-index.js +67 -0
- package/dist/api/guidance-index.js.map +1 -0
- package/dist/api/open-path.d.ts +16 -0
- package/dist/api/open-path.d.ts.map +1 -1
- package/dist/api/open-path.js +22 -0
- package/dist/api/open-path.js.map +1 -1
- package/dist/api/retry-index.d.ts +30 -0
- package/dist/api/retry-index.d.ts.map +1 -0
- package/dist/api/retry-index.js +45 -0
- package/dist/api/retry-index.js.map +1 -0
- package/dist/api/routes.d.ts +53 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +352 -23
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-files.d.ts +63 -0
- package/dist/api/run-files.d.ts.map +1 -0
- package/dist/api/run-files.js +271 -0
- package/dist/api/run-files.js.map +1 -0
- package/dist/api/server.d.ts +79 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +135 -6
- package/dist/api/server.js.map +1 -1
- package/dist/api/stall-watchdog.d.ts +62 -0
- package/dist/api/stall-watchdog.d.ts.map +1 -0
- package/dist/api/stall-watchdog.js +138 -0
- package/dist/api/stall-watchdog.js.map +1 -0
- package/dist/cli/index.js +78 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +24 -10
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +191 -30
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts +134 -0
- package/dist/core/bridge-reaper.d.ts.map +1 -0
- package/dist/core/bridge-reaper.js +286 -0
- package/dist/core/bridge-reaper.js.map +1 -0
- package/dist/core/deliver.d.ts +118 -0
- package/dist/core/deliver.d.ts.map +1 -0
- package/dist/core/deliver.js +241 -0
- package/dist/core/deliver.js.map +1 -0
- package/dist/core/deliverable-floor.d.ts +103 -0
- package/dist/core/deliverable-floor.d.ts.map +1 -0
- package/dist/core/deliverable-floor.js +173 -0
- package/dist/core/deliverable-floor.js.map +1 -0
- package/dist/core/exec.d.ts +2 -0
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/types.d.ts +79 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +28 -0
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +67 -10
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/interactive/chat-events.d.ts +207 -0
- package/dist/interactive/chat-events.d.ts.map +1 -0
- package/dist/interactive/chat-events.js +769 -0
- package/dist/interactive/chat-events.js.map +1 -0
- package/dist/interactive/demo-events.d.ts +283 -0
- package/dist/interactive/demo-events.d.ts.map +1 -0
- package/dist/interactive/demo-events.js +889 -0
- package/dist/interactive/demo-events.js.map +1 -0
- package/dist/interactive/draft-events.d.ts +87 -7
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +352 -49
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/edit-events.d.ts +22 -0
- package/dist/interactive/edit-events.d.ts.map +1 -1
- package/dist/interactive/edit-events.js +73 -2
- package/dist/interactive/edit-events.js.map +1 -1
- package/dist/interactive/repo-snapshot.d.ts +100 -0
- package/dist/interactive/repo-snapshot.d.ts.map +1 -0
- package/dist/interactive/repo-snapshot.js +289 -0
- package/dist/interactive/repo-snapshot.js.map +1 -0
- package/dist/projects/graph-paths.d.ts +92 -0
- package/dist/projects/graph-paths.d.ts.map +1 -0
- package/dist/projects/graph-paths.js +130 -0
- package/dist/projects/graph-paths.js.map +1 -0
- package/dist/projects/graph.d.ts +179 -0
- package/dist/projects/graph.d.ts.map +1 -0
- package/dist/projects/graph.js +775 -0
- package/dist/projects/graph.js.map +1 -0
- package/dist/projects/routes.d.ts +7 -0
- package/dist/projects/routes.d.ts.map +1 -1
- package/dist/projects/routes.js +122 -0
- package/dist/projects/routes.js.map +1 -1
- package/dist/studio/assets/index-8p8uwCxG.js +530 -0
- package/dist/studio/assets/index-D6S9zUtO.css +32 -0
- package/dist/studio/index.html +5 -3
- package/package.json +3 -3
- package/dist/studio/assets/index-CCwXa1cn.js +0 -428
- package/dist/studio/assets/index-HWxo0h41.css +0 -32
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in governed answering of wicked-interactive's conversational ITERATION asks (CREW-UX-5 —
|
|
3
|
+
* the third interactive leg, beside draft-events.ts and edit-events.ts).
|
|
4
|
+
*
|
|
5
|
+
* A finished doc's thread send in wicked-studio does `postFork` (mints a version) and injects
|
|
6
|
+
* `wicked.interactive.chat.posted` — and NOTHING consumed that topic since the ad-hoc assist
|
|
7
|
+
* agent retired: crew answers `doc.created` (the first draft) and `feedback.processed` (the
|
|
8
|
+
* structural handoff), but the plain conversational ask ("make the intro punchier") had no
|
|
9
|
+
* answerer, so BRIEF-UX-001 J3's "iterate twice" was impossible. This module makes a
|
|
10
|
+
* crew-governed run the answerer: understand-the-ask → revise, announced back on the SAME
|
|
11
|
+
* `draft.completed` wire the first draft rides — the service lands the revised full HTML as a
|
|
12
|
+
* generated version (`materializeDraft` → `applyGeneratedHtml`), which is exactly what an
|
|
13
|
+
* iteration is.
|
|
14
|
+
*
|
|
15
|
+
* Shape mirrors draft-events.ts (dynamic wicked-bus import, graceful degradation, durable
|
|
16
|
+
* cursor `cursor_init: 'latest'` under a dedicated plugin name, durable replay-dedup ledger,
|
|
17
|
+
* `wi-crew` narration via status.posted). The chat-specific deltas:
|
|
18
|
+
*
|
|
19
|
+
* - THE TRIGGER IS A CONVERSATION LINE, so the actionable filter is layered (the contract):
|
|
20
|
+
* (a) `role: "user"` only — agent narration echoes also ride chat.posted;
|
|
21
|
+
* (b) the doc must EXIST under the resolved docs root and not belong to a foreign loop —
|
|
22
|
+
* and the disk truth here is subtle: interactive's `initManifest` only ever RECORDS a
|
|
23
|
+
* `kind` for demo docs (a real `kind: "source"` doc's versions.json carries NO kind
|
|
24
|
+
* field at all — the "source" spelling rides the doc.created EVENT only; verified
|
|
25
|
+
* against interactive 0.8.0 and main, src/service/server.js `initWorkspace(dir, html)`).
|
|
26
|
+
* So the gate accepts `source` (future manifests may record it) and the absent-default
|
|
27
|
+
* `doc`, and rejects explicit foreign kinds (`demo`) — a doc we cannot see is not ours
|
|
28
|
+
* to answer either;
|
|
29
|
+
* (c) per-doc serialization — an ask on a doc whose draft/edit/chat run is still in flight
|
|
30
|
+
* is QUEUED (FIFO per doc), never raced: two concurrent revisions of one doc would
|
|
31
|
+
* land as two forks of the same parent and the second would silently drop the first;
|
|
32
|
+
* (d) the text must be an ASK, not an echo of a machine-composed message — the feedback
|
|
33
|
+
* overlay injects its batch as a chat.posted TOO (same `source_message_id` as its
|
|
34
|
+
* `feedback.submitted`), and that batch is already the edit seam's business.
|
|
35
|
+
* - VERSION SNAPSHOT AT LAUNCH: the seam copies the doc's CURRENT HEAD html into the chat
|
|
36
|
+
* inbox and names the COPY in the task, so (1) the worker never needs read access to the
|
|
37
|
+
* doc workspace (the one declared write root covers input + deliverable — crew#263 /
|
|
38
|
+
* wicked-core#259: write roots are readable) and (2) a queued second ask snapshots the
|
|
39
|
+
* head AFTER the first revision landed — which is what "iterate twice" means.
|
|
40
|
+
* - THE LANDING GATE: `draft.completed` is announced by path and the service lands the new
|
|
41
|
+
* version ASYNCHRONOUSLY. A queued ask drained the instant our run completes would snapshot
|
|
42
|
+
* the stale head and silently drop the revision the user just watched land. So a successful
|
|
43
|
+
* completion arms a per-doc gate — drain only once the manifest head ADVANCES past the head
|
|
44
|
+
* we launched from, or a timeout passes (the service may be down; waiting forever would
|
|
45
|
+
* strand the queue).
|
|
46
|
+
* - IDEMPOTENCY KEYING: one doc legitimately produces many asks over its life, so the dedupe
|
|
47
|
+
* unit is the ASK — `source_message_id` when the frame carries one (the studio reuses the
|
|
48
|
+
* SAME message id on a user-driven resend, so a retry that reached the bus twice dedupes),
|
|
49
|
+
* else the bus `event_id` (pure redelivery). Never the doc lifetime.
|
|
50
|
+
*/
|
|
51
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
52
|
+
import { basename, join } from 'node:path';
|
|
53
|
+
import { homedir } from 'node:os';
|
|
54
|
+
import { randomUUID } from 'node:crypto';
|
|
55
|
+
import { DOC_NAME, DRAFT_COMPLETED, INTERACTIVE_DOMAIN, INTERACTIVE_PRODUCER, STATUS_POSTED, oneLine, } from './draft-events.js';
|
|
56
|
+
import { InteractiveHandoffLedger } from './ledger.js';
|
|
57
|
+
import { resolveInteractiveRoot } from './bridge-root.js';
|
|
58
|
+
import { DELIVERABLE_FLOOR_PHASE_ID } from '../core/deliverable-floor.js';
|
|
59
|
+
// ── Vocabulary constants (interactive's, verbatim — src/service/events.js is the truth) ──────
|
|
60
|
+
export const CHAT_POSTED = 'wicked.interactive.chat.posted';
|
|
61
|
+
/** Exact-type filter with a domain guard — no wildcard, one event type is the whole trigger. */
|
|
62
|
+
export const INTERACTIVE_CHAT_BUS_FILTER = `${CHAT_POSTED}@${INTERACTIVE_DOMAIN}`;
|
|
63
|
+
/** Dedicated durable-cursor identity — NOT the draft/edit seams', so the three interactive
|
|
64
|
+
* seams advance independent cursors and stopping one never strands another. */
|
|
65
|
+
export const INTERACTIVE_CHAT_BUS_PLUGIN = 'wicked-crew-interactive-chat';
|
|
66
|
+
// ── The workflow (workflows-as-data) ─────────────────────────────────────────────────────────
|
|
67
|
+
export const INTERACTIVE_CHAT_WORKFLOW = 'interactive-chat';
|
|
68
|
+
/**
|
|
69
|
+
* The governed workflow that fulfils an iteration ask. Two agent phases — understand (recon)
|
|
70
|
+
* then revise (build, creator role) — the draft leg's sibling: the reviser builds on a stated
|
|
71
|
+
* plan instead of one-shotting a rewrite, and the run narrates a real phase transition.
|
|
72
|
+
*
|
|
73
|
+
* The phase `instructions` carry the ITERATION contract: start from the CURRENT document (the
|
|
74
|
+
* snapshot path named in the task), change only what the ask touches, KEEP every existing
|
|
75
|
+
* `data-wid` on kept elements (interactive's instrument pass preserves pre-existing anchors —
|
|
76
|
+
* INV-1 — so feedback deep-links survive the iteration) and mint none on added ones. They are
|
|
77
|
+
* SINGLE-LINE by contract (PTY seat runner, wicked-core FINDING-011).
|
|
78
|
+
*
|
|
79
|
+
* All gates are `auto` with `validator_pin: null` — same rationale as the draft leg: the
|
|
80
|
+
* acceptance gate for a revision is interactive's instrument+theme pipeline and the user's own
|
|
81
|
+
* eyes on the canvas.
|
|
82
|
+
*/
|
|
83
|
+
export const INTERACTIVE_CHAT_WORKFLOW_DEF = {
|
|
84
|
+
id: INTERACTIVE_CHAT_WORKFLOW,
|
|
85
|
+
is_system: true,
|
|
86
|
+
phases: [
|
|
87
|
+
{
|
|
88
|
+
id: 'understand',
|
|
89
|
+
kind: 'recon',
|
|
90
|
+
instructions: 'Understand the revision ask, do not write anything yet: read the CURRENT document — the absolute HTML file path named in the task — and the user\'s ask, then produce a concise revision plan as plain text: which sections change, what gets added or removed, and what stays untouched (the default is untouched — this is an iteration on a document the user already accepted, not a rewrite). Never invent facts, numbers, or claims the current document and the ask do not support; where the ask needs material the document lacks, plan honest placeholder copy that says what belongs there. Do NOT write HTML and do NOT create any files in this phase.',
|
|
91
|
+
gate_type: 'value',
|
|
92
|
+
gate: 'auto',
|
|
93
|
+
executes_code: false,
|
|
94
|
+
verified_evidence: false,
|
|
95
|
+
required_deliverables: [],
|
|
96
|
+
depends_on: [],
|
|
97
|
+
role: 'neutral',
|
|
98
|
+
skill_ref: null,
|
|
99
|
+
allowed_skills: [],
|
|
100
|
+
validator_pin: null,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'revise',
|
|
104
|
+
kind: 'build',
|
|
105
|
+
instructions: 'Using the plan from the prior phase, write the COMPLETE revised HTML document and SAVE it to the absolute output file named in the task (create parent directories if needed, overwrite if present) — the file on disk is the deliverable, so write it before you finish and end your reply with the absolute path you wrote. Contract: start from the CURRENT document (the absolute input path named in the task) and apply the user\'s ask — change only what the ask touches and keep everything else, including the document\'s style and structure; produce a full self-contained HTML document (inline CSS, no external network resources, no build step); KEEP every existing data-wid attribute byte-for-byte on elements you keep, and add NO data-wid to elements you create (the wicked-interactive service instruments its own anchors); never fabricate facts or figures; keep the markup semantic and well-formed (balanced tags) so the instrumentation pass lands cleanly.',
|
|
106
|
+
gate_type: 'execution',
|
|
107
|
+
gate: 'auto',
|
|
108
|
+
executes_code: false,
|
|
109
|
+
verified_evidence: false,
|
|
110
|
+
required_deliverables: [],
|
|
111
|
+
depends_on: ['understand'],
|
|
112
|
+
role: 'creator',
|
|
113
|
+
skill_ref: null,
|
|
114
|
+
allowed_skills: [],
|
|
115
|
+
validator_pin: null,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Parse a bus frame into a {@link ChatAsk}, or `null` when it is not an actionable ask:
|
|
121
|
+
* wrong type, malformed payload, non-`user` role (contract (a) — agent narration and any
|
|
122
|
+
* transcript echo ride the same topic), slug-invalid document_id, or empty text. Kind and
|
|
123
|
+
* existence checks (contract (b)) happen against the filesystem, not the payload — the frame
|
|
124
|
+
* does not carry `kind`.
|
|
125
|
+
*/
|
|
126
|
+
export function parseChatPosted(eventType, payload) {
|
|
127
|
+
if (eventType !== CHAT_POSTED)
|
|
128
|
+
return null;
|
|
129
|
+
if (typeof payload !== 'object' || payload === null)
|
|
130
|
+
return null;
|
|
131
|
+
const p = payload;
|
|
132
|
+
if (p['role'] !== 'user')
|
|
133
|
+
return null;
|
|
134
|
+
const documentId = typeof p['document_id'] === 'string' ? p['document_id'] : '';
|
|
135
|
+
if (!DOC_NAME.test(documentId))
|
|
136
|
+
return null;
|
|
137
|
+
const text = typeof p['text'] === 'string' ? p['text'].trim() : '';
|
|
138
|
+
if (text.length === 0)
|
|
139
|
+
return null;
|
|
140
|
+
const sourceMessageId = typeof p['source_message_id'] === 'string' && p['source_message_id'].length > 0
|
|
141
|
+
? p['source_message_id']
|
|
142
|
+
: undefined;
|
|
143
|
+
const projectId = typeof p['project_id'] === 'string' && p['project_id'].length > 0 ? p['project_id'] : undefined;
|
|
144
|
+
return {
|
|
145
|
+
documentId,
|
|
146
|
+
text,
|
|
147
|
+
...(sourceMessageId !== undefined ? { sourceMessageId } : {}),
|
|
148
|
+
...(projectId !== undefined ? { projectId } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** The feedback overlay's machine-composed batch header (studio `composeBatchMessage`): the
|
|
152
|
+
* batch is injected as a chat.posted for TRANSCRIPT fidelity, but the document work it names
|
|
153
|
+
* already rides `feedback.submitted` → the deterministic apply / the edit seam. */
|
|
154
|
+
const FEEDBACK_BATCH_ECHO = /^feedback on \d+ places? in /i;
|
|
155
|
+
/** Contract (d): `true` when the text is a conversational ask this seam should answer —
|
|
156
|
+
* not the feedback overlay's batch echo (already answered elsewhere). */
|
|
157
|
+
export function isIterationAsk(text) {
|
|
158
|
+
return !FEEDBACK_BATCH_ECHO.test(text.trim());
|
|
159
|
+
}
|
|
160
|
+
/** The one dedupe unit of this seam: the ASK — the studio's message id when the frame carries
|
|
161
|
+
* one (a resend reuses it), else the bus event id (pure redelivery). Never the doc lifetime. */
|
|
162
|
+
export function chatKey(documentId, eventId, sourceMessageId) {
|
|
163
|
+
return sourceMessageId !== undefined ? `${documentId}:m:${sourceMessageId}` : `${documentId}:e:${eventId}`;
|
|
164
|
+
}
|
|
165
|
+
/** Deterministic bus idempotency key for the one revision this seam may land per ask. */
|
|
166
|
+
export function chatIdempotencyKey(documentId, eventId, sourceMessageId) {
|
|
167
|
+
return `crew:interactive.chat:${chatKey(documentId, eventId, sourceMessageId)}`;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Read a doc workspace's manifest (interactive's `versions.json`, fsstore.js shape) and resolve
|
|
171
|
+
* its head html path. `null` when the doc does not exist under this root or the manifest is
|
|
172
|
+
* unreadable — not ours to answer, per contract (b). Follows interactive's own tolerant read:
|
|
173
|
+
* `kind` defaults to `"doc"` when absent (listDocs does the same) — and ABSENT is what a real
|
|
174
|
+
* source doc looks like on disk (interactive records kind only for demo docs), so the caller
|
|
175
|
+
* gates through {@link isAnswerableDocKind}, never a `=== 'source'` comparison.
|
|
176
|
+
*/
|
|
177
|
+
export function readDocHead(docsRoot, documentId) {
|
|
178
|
+
if (!DOC_NAME.test(documentId))
|
|
179
|
+
return null;
|
|
180
|
+
const dir = join(docsRoot, documentId);
|
|
181
|
+
try {
|
|
182
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'versions.json'), 'utf8'));
|
|
183
|
+
const head = manifest.head;
|
|
184
|
+
if (typeof head !== 'number' || !Number.isInteger(head) || head < 0)
|
|
185
|
+
return null;
|
|
186
|
+
const entry = Array.isArray(manifest.versions)
|
|
187
|
+
? manifest.versions.find((v) => v?.version === head)
|
|
188
|
+
: undefined;
|
|
189
|
+
// Containment (Copilot, #310): `html_file` comes from a manifest on disk — a corrupted or
|
|
190
|
+
// hostile value carrying path separators or `..` must never escape the doc dir. Only a plain
|
|
191
|
+
// basename is accepted; anything else falls back to the version's canonical spelling.
|
|
192
|
+
const rawHtmlFile = typeof entry?.html_file === 'string' && entry.html_file.length > 0
|
|
193
|
+
? entry.html_file
|
|
194
|
+
: `_v${head}.html`;
|
|
195
|
+
const htmlFile = basename(rawHtmlFile) === rawHtmlFile && !rawHtmlFile.includes('..')
|
|
196
|
+
? rawHtmlFile
|
|
197
|
+
: `_v${head}.html`;
|
|
198
|
+
return {
|
|
199
|
+
kind: typeof manifest.kind === 'string' && manifest.kind.length > 0 ? manifest.kind : 'doc',
|
|
200
|
+
head,
|
|
201
|
+
headHtmlPath: join(dir, htmlFile),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Contract (b)'s kind half: `true` when a doc of this manifest kind is this seam's to answer.
|
|
210
|
+
* `source` is the spec's spelling (recorded by no released interactive yet, accepted for the
|
|
211
|
+
* day a manifest carries it); `doc` is the absent-default every REAL source (and plain html)
|
|
212
|
+
* doc reads as — interactive's initManifest keeps non-demo kinds implicit. Explicit foreign
|
|
213
|
+
* kinds (`demo`, anything future) belong to their own loops.
|
|
214
|
+
*/
|
|
215
|
+
export function isAnswerableDocKind(kind) {
|
|
216
|
+
return kind === 'source' || kind === 'doc';
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The run's problem statement (the engine scopes it per phase and folds each phase's
|
|
220
|
+
* instructions on top). Carries everything ask-specific: identity, the flattened ask, the
|
|
221
|
+
* CURRENT-version snapshot to read, and the absolute path the revised HTML must land at.
|
|
222
|
+
*
|
|
223
|
+
* CREW-UX-8 SPLIT — the revision leg carries NO repo grounding, deliberately. The draft leg
|
|
224
|
+
* (draft-events.ts) grounds its v1 in a launch-scoped repo snapshot and that shape is proven
|
|
225
|
+
* live; the SAME shape on the revise turn wedged 2/2 on the real engine (crew#288 comment —
|
|
226
|
+
* the repo-grounded revise turn hits the known second-turn wedge), while UNGROUNDED revisions
|
|
227
|
+
* are proven to land (the CREW-UX-5 verification). So bound asks launch exactly like unfiled
|
|
228
|
+
* ones — head-copy in the external inbox, one write root, no repoRef (wicked-core#293), no
|
|
229
|
+
* live-repo path in the task (wicked-core#294). Revision grounding returns when either of
|
|
230
|
+
* those is fixed.
|
|
231
|
+
*/
|
|
232
|
+
export function chatProblem(ask, currentPath, outPath) {
|
|
233
|
+
return (`Revise the wicked-interactive document "${ask.documentId}" per the user's ask. ` +
|
|
234
|
+
`The user's ask: ${oneLine(ask.text, 2000)} ` +
|
|
235
|
+
`The document's CURRENT version is the HTML file at this absolute path — read it first: ${currentPath} ` +
|
|
236
|
+
`The revised COMPLETE document MUST be written to exactly this absolute file path: ${outPath}`);
|
|
237
|
+
}
|
|
238
|
+
function defaultStateDir() {
|
|
239
|
+
return join(homedir(), '.wicked-crew');
|
|
240
|
+
}
|
|
241
|
+
/** The production council roster, resolved lazily through the adapter's own class so this module
|
|
242
|
+
* never imports the native addon at runtime (unit tests pass `clisJson` and a fake adapter). */
|
|
243
|
+
function rosterOf(adapter) {
|
|
244
|
+
return adapter.constructor.roster();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Arm the seam: register the `interactive-chat` workflow, open a durable
|
|
248
|
+
* `wicked.interactive.chat.posted` subscription, and answer each user ask on an existing
|
|
249
|
+
* answerable doc (contract (b)) with a governed run that ends in `wicked.interactive.draft.completed`
|
|
250
|
+
* (the service lands the revised full HTML as a generated version).
|
|
251
|
+
*
|
|
252
|
+
* Graceful degradation mirrors the sibling seams: a missing wicked-bus package or an
|
|
253
|
+
* unopenable db LOGS and returns `null` — the daemon must still boot on a machine whose bus
|
|
254
|
+
* is broken.
|
|
255
|
+
*/
|
|
256
|
+
export async function startInteractiveChatSubscriber(adapter, opts = {}) {
|
|
257
|
+
const log = opts.log ?? ((m) => console.error(m));
|
|
258
|
+
let bus;
|
|
259
|
+
try {
|
|
260
|
+
bus = await import('wicked-bus');
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
log(`[interactive-chat] wicked-bus is not importable — governed iteration disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
let db;
|
|
267
|
+
let config;
|
|
268
|
+
try {
|
|
269
|
+
config = bus.loadConfig(opts.dbPath !== undefined ? { db_path: opts.dbPath } : {});
|
|
270
|
+
db = bus.openDb(opts.dbPath !== undefined ? { db_path: opts.dbPath } : {});
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
log(`[interactive-chat] could not open the bus db${opts.dbPath !== undefined ? ` at ${opts.dbPath}` : ''} — governed iteration disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
// The workflow rides the normal registration path — core validates the def BEFORE it is
|
|
277
|
+
// persisted/hot-registered (FINDING-002 ordering), so a drifted def fails the arm loudly
|
|
278
|
+
// instead of failing the first launch obscurely.
|
|
279
|
+
try {
|
|
280
|
+
await adapter.registerWorkflow(INTERACTIVE_CHAT_WORKFLOW_DEF);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
log(`[interactive-chat] could not register the '${INTERACTIVE_CHAT_WORKFLOW}' workflow — ` +
|
|
284
|
+
`governed iteration disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const ledger = new InteractiveHandoffLedger(opts.ledgerPath ?? join(defaultStateDir(), 'interactive-chat-ledger.json'));
|
|
288
|
+
const chatDir = opts.chatDir ?? join(defaultStateDir(), 'interactive-chats');
|
|
289
|
+
const heartbeatMs = opts.heartbeatMs ?? 15_000;
|
|
290
|
+
const landingGateMs = opts.landingGateMs ?? 60_000;
|
|
291
|
+
const resolveDocsRoot = opts.resolveDocsRoot ?? (() => resolveInteractiveRoot(null));
|
|
292
|
+
// +1 for the crew#311 deliverable floor the adapter appends per-run (`requireDeliverables`):
|
|
293
|
+
// `agentPhaseCount` drives the council/worker branches, `phaseCount` is the run's real length.
|
|
294
|
+
const agentPhaseCount = INTERACTIVE_CHAT_WORKFLOW_DEF.phases.length;
|
|
295
|
+
const phaseCount = agentPhaseCount + 1;
|
|
296
|
+
const inFlight = new Map(); // runId → live state
|
|
297
|
+
const queues = new Map(); // documentId → parked asks, FIFO
|
|
298
|
+
const landingGates = new Map(); // documentId → post-completion gate
|
|
299
|
+
/** Emit onto interactive's vocabulary as the `wi-crew` producer. Never throws into the
|
|
300
|
+
* caller: narration/announce failures are logged — a lost status line must not kill the
|
|
301
|
+
* subscription, and a duplicate announce (WB-002) is the idempotency key WORKING. */
|
|
302
|
+
function emitInteractive(type, payload, idempotencyKey) {
|
|
303
|
+
try {
|
|
304
|
+
bus.emit(db, config, {
|
|
305
|
+
event_type: type,
|
|
306
|
+
domain: INTERACTIVE_DOMAIN,
|
|
307
|
+
subdomain: type === DRAFT_COMPLETED ? 'generation' : 'status',
|
|
308
|
+
payload: { ts: new Date().toISOString(), ...payload },
|
|
309
|
+
producer_id: INTERACTIVE_PRODUCER,
|
|
310
|
+
...(idempotencyKey !== undefined ? { idempotency_key: idempotencyKey } : {}),
|
|
311
|
+
});
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
const code = err.error;
|
|
316
|
+
if (code === 'WB-002') {
|
|
317
|
+
// Duplicate idempotency key — the emit already happened (redelivery race). Success.
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
log(`[interactive-chat] emit ${type} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function narrate(flight, message) {
|
|
325
|
+
flight.narration = message;
|
|
326
|
+
emitInteractive(STATUS_POSTED, {
|
|
327
|
+
document_id: flight.documentId,
|
|
328
|
+
state: 'working',
|
|
329
|
+
message,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function endFlight(runId) {
|
|
333
|
+
const flight = inFlight.get(runId);
|
|
334
|
+
if (flight) {
|
|
335
|
+
clearInterval(flight.heartbeat);
|
|
336
|
+
inFlight.delete(runId);
|
|
337
|
+
}
|
|
338
|
+
return flight;
|
|
339
|
+
}
|
|
340
|
+
function docHasFlight(documentId) {
|
|
341
|
+
for (const f of inFlight.values()) {
|
|
342
|
+
if (f.documentId === documentId)
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
/** Contract (c): the doc is busy while ANY governed run is working it — our own chat runs
|
|
348
|
+
* plus whatever the sibling draft/edit seams report through `isDocBusy`. */
|
|
349
|
+
function docBusy(documentId) {
|
|
350
|
+
return docHasFlight(documentId) || opts.isDocBusy?.(documentId) === true;
|
|
351
|
+
}
|
|
352
|
+
/** Terminal-event fold: turn the governed run's own events into interactive narration, and
|
|
353
|
+
* close the loop with `draft.completed` when the run lands. */
|
|
354
|
+
const offCoreEvents = adapter.onEvent((event) => {
|
|
355
|
+
const runId = typeof event.session === 'string' ? event.session : undefined;
|
|
356
|
+
if (runId === undefined)
|
|
357
|
+
return;
|
|
358
|
+
const flight = inFlight.get(runId);
|
|
359
|
+
if (flight === undefined)
|
|
360
|
+
return;
|
|
361
|
+
// Narration ladder — same rationale as the draft fold: the heartbeat repeats the LATEST
|
|
362
|
+
// line and the transcript dedups repeats, so advancing the line = visible progress.
|
|
363
|
+
const phaseName = (ord) => ord > agentPhaseCount
|
|
364
|
+
? DELIVERABLE_FLOOR_PHASE_ID
|
|
365
|
+
: (INTERACTIVE_CHAT_WORKFLOW_DEF.phases[ord - 1]?.id ?? `phase ${ord}`);
|
|
366
|
+
// The crew#311 deliverable floor is a DETERMINISTIC tool phase — no seat, no council. Core
|
|
367
|
+
// still emits the seat-selection events for it (its `cli` is the node interpreter's absolute
|
|
368
|
+
// path), so narrating them verbatim put "Council picked /opt/homebrew/.../node…" in the
|
|
369
|
+
// reader's thread. Drop those two lines for the floor ord.
|
|
370
|
+
const isFloorOrd = (e) => typeof e.ord === 'number' && e.ord > agentPhaseCount;
|
|
371
|
+
if (event.type === 'councilConvened') {
|
|
372
|
+
if (isFloorOrd(event))
|
|
373
|
+
return;
|
|
374
|
+
const ord = typeof event.ord === 'number' ? event.ord : 0;
|
|
375
|
+
const seats = Array.isArray(event.clis) ? event.clis.length : 0;
|
|
376
|
+
const council = seats > 0 ? `a ${seats}-seat council` : 'a council';
|
|
377
|
+
narrate(flight, `Convening ${council} to pick who ${ord >= agentPhaseCount ? 'revises the document' : 'reads your ask'}…`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (event.type === 'unitDistributed') {
|
|
381
|
+
if (isFloorOrd(event))
|
|
382
|
+
return;
|
|
383
|
+
const ord = typeof event.ord === 'number' ? event.ord : 0;
|
|
384
|
+
const who = typeof event.cli === 'string' ? event.cli : 'a worker';
|
|
385
|
+
const pct = typeof event.agreement_pct === 'number' ? ` (${event.agreement_pct}% agreement)` : '';
|
|
386
|
+
narrate(flight, `Council picked ${who} for ${phaseName(ord)}${pct}…`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (event.type === 'unitDispatched') {
|
|
390
|
+
const ord = typeof event.ord === 'number' ? event.ord : 0;
|
|
391
|
+
const phase = phaseName(ord);
|
|
392
|
+
narrate(flight, ord > agentPhaseCount
|
|
393
|
+
? `Crew phase ${ord}/${phaseCount}: checking the revised document was actually written (${phase})…`
|
|
394
|
+
: ord >= agentPhaseCount
|
|
395
|
+
? `Crew phase ${ord}/${phaseCount}: revising the document (${phase})…`
|
|
396
|
+
: `Crew phase ${ord}/${phaseCount}: ${phase} — reading the current version and your ask…`);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (event.type === 'toolInvoked') {
|
|
400
|
+
const tools = Array.isArray(event.tools) ? [...new Set(event.tools)].join(', ') : '';
|
|
401
|
+
if (tools)
|
|
402
|
+
narrate(flight, `Worker is using ${tools} on your document…`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (event.type === 'unitOutputCaptured') {
|
|
406
|
+
const ord = typeof event.ord === 'number' ? event.ord : 0;
|
|
407
|
+
narrate(flight, `${phaseName(ord)} finished — the governance gate is reviewing it…`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (event.type === 'gateDecided' && event.allow === true) {
|
|
411
|
+
const ord = typeof event.ord === 'number' ? event.ord : 0;
|
|
412
|
+
narrate(flight, ord > agentPhaseCount
|
|
413
|
+
? 'Revised document verified on disk — landing it now…'
|
|
414
|
+
: ord >= agentPhaseCount
|
|
415
|
+
? 'Gate approved the revision — checking the file landed…'
|
|
416
|
+
: `Gate approved ${phaseName(ord)} — moving on…`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (event.type === 'acpFallback') {
|
|
420
|
+
const who = typeof event.cliKey === 'string' ? event.cliKey : 'the worker';
|
|
421
|
+
narrate(flight, `${who}'s live session dropped — continuing in single-shot mode…`);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (event.type === 'sessionCompleted') {
|
|
425
|
+
endFlight(runId);
|
|
426
|
+
finalize(flight, runId);
|
|
427
|
+
drainDoc(flight.documentId);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (event.type === 'stepFailed') {
|
|
431
|
+
// Remember the engine's own reason (crew#311) so the terminal status can name it.
|
|
432
|
+
const detail = typeof event.detail === 'string' ? event.detail.trim() : '';
|
|
433
|
+
if (detail.length > 0)
|
|
434
|
+
flight.failureDetail = detail;
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (event.type === 'sessionFailed' || event.type === 'runCancelled') {
|
|
438
|
+
endFlight(runId);
|
|
439
|
+
ledger.recordFailure(flight.key);
|
|
440
|
+
const why = flight.failureDetail !== undefined ? ` Reason: ${oneLine(flight.failureDetail, 600)}` : '';
|
|
441
|
+
emitInteractive(STATUS_POSTED, {
|
|
442
|
+
document_id: flight.documentId,
|
|
443
|
+
state: 'error',
|
|
444
|
+
message: `The crew run answering your ask ${event.type === 'runCancelled' ? 'was cancelled' : 'failed'} ` +
|
|
445
|
+
`(run ${runId}).${why} Inspect it via the crew API (GET /api/v1/runs/${runId}), then resend the message.`,
|
|
446
|
+
});
|
|
447
|
+
log(`[interactive-chat] run ${runId} for ask ${flight.key} ended: ${event.type}`);
|
|
448
|
+
// No landing gate on failure — nothing new is landing; the next queued ask (if any)
|
|
449
|
+
// proceeds on the head that is there.
|
|
450
|
+
drainDoc(flight.documentId);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
function finalize(flight, runId) {
|
|
454
|
+
const { key, documentId, outPath } = flight;
|
|
455
|
+
let ok = false;
|
|
456
|
+
try {
|
|
457
|
+
ok = existsSync(outPath) && statSync(outPath).size > 0;
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
ok = false;
|
|
461
|
+
}
|
|
462
|
+
if (!ok) {
|
|
463
|
+
ledger.recordFailure(key);
|
|
464
|
+
emitInteractive(STATUS_POSTED, {
|
|
465
|
+
document_id: documentId,
|
|
466
|
+
state: 'error',
|
|
467
|
+
message: `The crew run completed but produced no revised document at ${outPath} (run ${runId}). Resend the message to retry.`,
|
|
468
|
+
});
|
|
469
|
+
log(`[interactive-chat] run ${runId} completed but ${outPath} is missing/empty`);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
// Announce on the SAME wire the first draft rides (ADR-0019 D5: by path — the service
|
|
473
|
+
// reads the file and lands it as a generated version). The deterministic per-ask key
|
|
474
|
+
// makes a re-announce a WB-002 no-op.
|
|
475
|
+
const emitted = emitInteractive(DRAFT_COMPLETED, { document_id: documentId, html_path: outPath }, `crew:interactive.chat:${key}`);
|
|
476
|
+
if (!emitted) {
|
|
477
|
+
// The bus refused the announce (non-WB-002): the revision exists on disk but never
|
|
478
|
+
// reached the service. Fail HONEST — leaving the row launched-but-never-closed would
|
|
479
|
+
// silently eat a redelivery of this ask (the launch gate is `ledger.has`).
|
|
480
|
+
ledger.recordFailure(key);
|
|
481
|
+
emitInteractive(STATUS_POSTED, {
|
|
482
|
+
document_id: documentId,
|
|
483
|
+
state: 'error',
|
|
484
|
+
message: `Crew finished the revision but could not announce it on the bus (run ${runId}); ` +
|
|
485
|
+
`the file is at ${outPath}. Inspect the crew daemon log, then resend the message.`,
|
|
486
|
+
});
|
|
487
|
+
log(`[interactive-chat] draft.completed emit FAILED for ask ${key} (run ${runId}) — recorded as failure`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
ledger.recordEmitted(key);
|
|
491
|
+
// The landing gate (see module header): the SERVICE lands the announced file as a new
|
|
492
|
+
// version asynchronously — a queued ask drained right now would snapshot the pre-revision
|
|
493
|
+
// head and silently drop the revision that just finished. Hold this doc's queue until the
|
|
494
|
+
// manifest head advances past what this run launched from, or the timeout passes.
|
|
495
|
+
landingGates.set(documentId, {
|
|
496
|
+
minHead: flight.headAtLaunch + 1,
|
|
497
|
+
until: Date.now() + landingGateMs,
|
|
498
|
+
});
|
|
499
|
+
emitInteractive(STATUS_POSTED, {
|
|
500
|
+
document_id: documentId,
|
|
501
|
+
state: 'complete',
|
|
502
|
+
message: 'Revision is in — landing the new version on the canvas now.',
|
|
503
|
+
});
|
|
504
|
+
log(`[interactive-chat] draft.completed emitted for ask ${key} (run ${runId})`);
|
|
505
|
+
}
|
|
506
|
+
/** Launch the governed run for one ask. The head snapshot happens HERE — launch time, not
|
|
507
|
+
* parse time — so a queued ask iterates on the version its predecessor landed. Throws on
|
|
508
|
+
* launch failure (the caller decides: dead-letter for a live delivery, log for a drain). */
|
|
509
|
+
async function launchAsk(queued) {
|
|
510
|
+
const { ask, key } = queued;
|
|
511
|
+
const docsRoot = resolveDocsRoot(ask.projectId);
|
|
512
|
+
const doc = readDocHead(docsRoot, ask.documentId);
|
|
513
|
+
if (doc === null || !isAnswerableDocKind(doc.kind)) {
|
|
514
|
+
// The doc moved out from under a parked ask (deleted / never ours). Not an error the
|
|
515
|
+
// thread needs — contract (b) simply stopped holding.
|
|
516
|
+
log(`[interactive-chat] ask ${key} dropped at launch: doc ${ask.documentId} ${doc === null ? 'not found' : `has kind '${doc.kind}'`} under ${docsRoot}`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
let headOk = false;
|
|
520
|
+
try {
|
|
521
|
+
headOk = existsSync(doc.headHtmlPath) && statSync(doc.headHtmlPath).size > 0;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
headOk = false;
|
|
525
|
+
}
|
|
526
|
+
if (!headOk) {
|
|
527
|
+
emitInteractive(STATUS_POSTED, {
|
|
528
|
+
document_id: ask.documentId,
|
|
529
|
+
state: 'error',
|
|
530
|
+
message: `Crew could not read the document's current version (missing ${doc.headHtmlPath}) — the ask was not answered.`,
|
|
531
|
+
});
|
|
532
|
+
log(`[interactive-chat] ask ${key}: head html ${doc.headHtmlPath} is missing/empty`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
// Both files live under the ONE declared write root — the run's OWN per-ask subdirectory,
|
|
536
|
+
// never the shared chatDir (per-run isolation — Copilot, crew#313: the wholesale
|
|
537
|
+
// declaration let any chat worker read every other doc's head snapshot and revision): the
|
|
538
|
+
// worker reads the snapshot and writes the deliverable without ever touching the doc
|
|
539
|
+
// workspace (crew#263 shape; write roots are readable, wicked-core#259). Names derive from
|
|
540
|
+
// the validated slug + our own key grammar — never from free bus text.
|
|
541
|
+
const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
542
|
+
const runDir = join(chatDir, safeKey);
|
|
543
|
+
mkdirSync(runDir, { recursive: true });
|
|
544
|
+
const currentPath = join(runDir, 'current.html');
|
|
545
|
+
const outPath = join(runDir, 'revised.html');
|
|
546
|
+
copyFileSync(doc.headHtmlPath, currentPath);
|
|
547
|
+
const runId = randomUUID();
|
|
548
|
+
// The studio's 90s silence budget: this pickup line is what keeps the thread honest, so
|
|
549
|
+
// it fires BEFORE the launch resolves.
|
|
550
|
+
emitInteractive(STATUS_POSTED, {
|
|
551
|
+
document_id: ask.documentId,
|
|
552
|
+
state: 'processing',
|
|
553
|
+
message: 'A governed crew picked up your ask — revising the document…',
|
|
554
|
+
});
|
|
555
|
+
try {
|
|
556
|
+
await adapter.launchRun({
|
|
557
|
+
problem: chatProblem(ask, currentPath, outPath),
|
|
558
|
+
sessionId: runId,
|
|
559
|
+
clisJson: opts.clisJson ?? JSON.stringify(rosterOf(adapter)),
|
|
560
|
+
workflow: INTERACTIVE_CHAT_WORKFLOW,
|
|
561
|
+
// A project-bound doc's governed revision is FILED (same contract as the sibling
|
|
562
|
+
// seams); an unbound doc launches with the key OMITTED — an unfiled governed run,
|
|
563
|
+
// never a fabricated 'default' membership.
|
|
564
|
+
...(ask.projectId !== undefined ? { projectId: ask.projectId } : {}),
|
|
565
|
+
// CREW-UX-8: deliberately NO `repoRef`, even when the project has one — a repoRef-bound
|
|
566
|
+
// run's tool-permission stream closes on the first prompt-needing call, so no write
|
|
567
|
+
// destination works (wicked-core#293) — and NO live-repo path in the task either: the
|
|
568
|
+
// unbound boundary denies those reads (wicked-core#294).
|
|
569
|
+
//
|
|
570
|
+
// AND — the CREW-UX-8 split — deliberately NO repo snapshot either, unlike the draft
|
|
571
|
+
// leg. The draft seam's snapshot grounding is proven live; the SAME grounded prompt on
|
|
572
|
+
// the REVISE turn wedged 2/2 on the real engine (crew#288 comment: the repo-grounded
|
|
573
|
+
// revise turn hits the known second-turn wedge), while ungrounded revisions are proven
|
|
574
|
+
// to land (the CREW-UX-5 verification). So bound asks launch exactly like unfiled ones:
|
|
575
|
+
// head-copy in the external inbox, the ONE write root below, projectId still filed.
|
|
576
|
+
// Revision grounding returns when wicked-core#293 or #294 is fixed.
|
|
577
|
+
extraWriteRoots: [runDir],
|
|
578
|
+
// THE DELIVERABLE FLOOR (crew#311): the revised document IS the deliverable. Without
|
|
579
|
+
// it the engine's substance floor passes a worker whose Write was denied as long as it
|
|
580
|
+
// narrated ~200 characters first; with it the run FAILS naming this path.
|
|
581
|
+
requireDeliverables: [outPath],
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
// The 'processing' status is already on the thread — close it out honestly so the
|
|
586
|
+
// canvas never sits in an in-between state on a launch that went nowhere.
|
|
587
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
588
|
+
emitInteractive(STATUS_POSTED, {
|
|
589
|
+
document_id: ask.documentId,
|
|
590
|
+
state: 'error',
|
|
591
|
+
message: `Crew could not start a run for your ask: ${reason}. Resend the message to retry.`,
|
|
592
|
+
});
|
|
593
|
+
// DELIBERATELY no ledger write: only an answered ask earns a row, so a replay of the
|
|
594
|
+
// frame (live path: dead-letter + operator redrive) gets a real retry.
|
|
595
|
+
throw err;
|
|
596
|
+
}
|
|
597
|
+
// Record AFTER the launch resolved: a failed launch leaves no ledger row, so a replayed
|
|
598
|
+
// delivery retries. The crash window between launch and this write is the reason the
|
|
599
|
+
// announce ALSO carries a deterministic idempotency key.
|
|
600
|
+
ledger.recordLaunch(key, runId);
|
|
601
|
+
if (ask.projectId !== undefined)
|
|
602
|
+
opts.onRunFiled?.(runId, ask.projectId);
|
|
603
|
+
const flight = {
|
|
604
|
+
key,
|
|
605
|
+
documentId: ask.documentId,
|
|
606
|
+
outPath,
|
|
607
|
+
headAtLaunch: doc.head,
|
|
608
|
+
narration: 'Crew run launched — working on your revision…',
|
|
609
|
+
heartbeat: setInterval(() => {
|
|
610
|
+
// Repeat the last real narration so the ~20s status.requested window is always fed,
|
|
611
|
+
// even mid-phase when the engine is quiet.
|
|
612
|
+
emitInteractive(STATUS_POSTED, {
|
|
613
|
+
document_id: flight.documentId,
|
|
614
|
+
state: 'working',
|
|
615
|
+
message: flight.narration,
|
|
616
|
+
});
|
|
617
|
+
}, heartbeatMs),
|
|
618
|
+
// Do not keep the daemon alive for narration alone.
|
|
619
|
+
};
|
|
620
|
+
flight.heartbeat.unref?.();
|
|
621
|
+
inFlight.set(runId, flight);
|
|
622
|
+
log(`[interactive-chat] ask ${key} → governed run ${runId} (v${doc.head} → ${outPath})`);
|
|
623
|
+
}
|
|
624
|
+
/** `true` when the doc's landing gate (if any) still holds its queue shut. A satisfied or
|
|
625
|
+
* expired gate is removed. `projectId` (when the caller has an ask in hand) beats the
|
|
626
|
+
* queue-head lookup — an empty queue must still resolve the right per-project root. */
|
|
627
|
+
function gateHolds(documentId, projectId) {
|
|
628
|
+
const gate = landingGates.get(documentId);
|
|
629
|
+
if (gate === undefined)
|
|
630
|
+
return false;
|
|
631
|
+
if (Date.now() > gate.until) {
|
|
632
|
+
landingGates.delete(documentId);
|
|
633
|
+
log(`[interactive-chat] landing gate for ${documentId} timed out — draining on the current head`);
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
// Resolve the root through the ask at hand, else the FIRST queued ask (all asks on one
|
|
637
|
+
// doc resolve the same way).
|
|
638
|
+
const next = queues.get(documentId)?.[0];
|
|
639
|
+
const docsRoot = resolveDocsRoot(projectId ?? next?.ask.projectId);
|
|
640
|
+
const doc = readDocHead(docsRoot, documentId);
|
|
641
|
+
if (doc !== null && doc.head >= gate.minHead) {
|
|
642
|
+
landingGates.delete(documentId);
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
/** Drain one doc's queue head if nothing holds it (busy run, landing gate). Fire-and-forget:
|
|
648
|
+
* a drain-time launch failure is narrated + logged, never thrown (there is no live bus
|
|
649
|
+
* frame left to dead-letter). */
|
|
650
|
+
function drainDoc(documentId) {
|
|
651
|
+
const queue = queues.get(documentId);
|
|
652
|
+
if (queue === undefined || queue.length === 0) {
|
|
653
|
+
queues.delete(documentId);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (docBusy(documentId) || gateHolds(documentId))
|
|
657
|
+
return;
|
|
658
|
+
const next = queue.shift();
|
|
659
|
+
if (queue.length === 0)
|
|
660
|
+
queues.delete(documentId);
|
|
661
|
+
void launchAsk(next).catch((err) => {
|
|
662
|
+
log(`[interactive-chat] drained ask ${next.key} failed to launch: ${err instanceof Error ? err.message : String(err)}`);
|
|
663
|
+
// The failed drain must not strand the rest of the queue.
|
|
664
|
+
drainDoc(documentId);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
// The sweep: foreign runs (draft/edit seams) and landing gates clear OUTSIDE our event flow,
|
|
668
|
+
// so a timer retries parked queues. Unref'd — parked asks never keep the daemon alive.
|
|
669
|
+
// Expired landing gates are reaped here too (Copilot, #310): gateHolds() only removes a
|
|
670
|
+
// gate when a queued ask drains, so a doc revised once and never asked again would hold
|
|
671
|
+
// its entry forever — the map must stay bounded by each gate's own timeout.
|
|
672
|
+
const sweep = setInterval(() => {
|
|
673
|
+
const now = Date.now();
|
|
674
|
+
for (const [documentId, gate] of landingGates) {
|
|
675
|
+
if (now > gate.until && (queues.get(documentId)?.length ?? 0) === 0) {
|
|
676
|
+
landingGates.delete(documentId);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
for (const documentId of [...queues.keys()])
|
|
680
|
+
drainDoc(documentId);
|
|
681
|
+
}, opts.queueSweepMs ?? 1000);
|
|
682
|
+
sweep.unref?.();
|
|
683
|
+
async function handleChatPosted(event) {
|
|
684
|
+
const ask = parseChatPosted(event.event_type, event.payload);
|
|
685
|
+
if (ask === null)
|
|
686
|
+
return;
|
|
687
|
+
// Contract (d): the feedback overlay's batch echo is transcript fidelity, not an ask —
|
|
688
|
+
// its document work already rides feedback.submitted (deterministic apply / edit seam).
|
|
689
|
+
if (!isIterationAsk(ask.text)) {
|
|
690
|
+
log(`[interactive-chat] chat.posted on ${ask.documentId} is a feedback-batch echo — not this seam's`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const key = chatKey(ask.documentId, event.event_id, ask.sourceMessageId);
|
|
694
|
+
// Replay-dedup: the ledger is the durable gate (redelivery after crash/restart, and a
|
|
695
|
+
// studio resend that reused its message id), the in-flight/queue scan the live one.
|
|
696
|
+
if (ledger.has(key)) {
|
|
697
|
+
log(`[interactive-chat] ask ${key} already answered (run ${ledger.get(key)?.runId}) — replay ignored`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
for (const f of inFlight.values()) {
|
|
701
|
+
if (f.key === key)
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
for (const q of queues.values()) {
|
|
705
|
+
if (q.some((entry) => entry.key === key))
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
// Contract (b): the doc must exist under the resolved root with an answerable kind. Checked at
|
|
709
|
+
// DELIVERY so a demo doc's chat or an unknown doc never even parks; re-checked at launch
|
|
710
|
+
// (the snapshot read) because a parked ask can outlive its doc.
|
|
711
|
+
const docsRoot = resolveDocsRoot(ask.projectId);
|
|
712
|
+
const doc = readDocHead(docsRoot, ask.documentId);
|
|
713
|
+
if (doc === null) {
|
|
714
|
+
log(`[interactive-chat] chat.posted for unknown doc ${ask.documentId} under ${docsRoot} — ignored`);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (!isAnswerableDocKind(doc.kind)) {
|
|
718
|
+
log(`[interactive-chat] doc ${ask.documentId} has kind '${doc.kind}' — demo (and other foreign-kind) docs are not this seam's to answer`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const queued = { key, ask, eventId: event.event_id };
|
|
722
|
+
// Contract (c): per-doc serialization, FIFO. A busy doc parks the ask — with an IMMEDIATE
|
|
723
|
+
// narration so the thread never sits silent inside the studio's 90s budget.
|
|
724
|
+
if (docBusy(ask.documentId) || gateHolds(ask.documentId, ask.projectId) || queues.has(ask.documentId)) {
|
|
725
|
+
const queue = queues.get(ask.documentId) ?? [];
|
|
726
|
+
queue.push(queued);
|
|
727
|
+
queues.set(ask.documentId, queue);
|
|
728
|
+
emitInteractive(STATUS_POSTED, {
|
|
729
|
+
document_id: ask.documentId,
|
|
730
|
+
state: 'processing',
|
|
731
|
+
message: 'Crew has your ask — a run is already working this document, so it is queued and will start as soon as the current work lands.',
|
|
732
|
+
});
|
|
733
|
+
log(`[interactive-chat] ask ${key} queued behind busy doc ${ask.documentId} (${queue.length} waiting)`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Re-throwing a launch failure lets the bus (maxRetries 0) dead-letter the frame —
|
|
737
|
+
// visible, replayable, incapable of hot-looping.
|
|
738
|
+
await launchAsk(queued);
|
|
739
|
+
}
|
|
740
|
+
const sub = bus.subscribe({
|
|
741
|
+
db,
|
|
742
|
+
plugin: INTERACTIVE_CHAT_BUS_PLUGIN,
|
|
743
|
+
filter: INTERACTIVE_CHAT_BUS_FILTER,
|
|
744
|
+
// Live triggers only: replaying a bus backlog would answer asks whose moment has passed.
|
|
745
|
+
cursor_init: 'latest',
|
|
746
|
+
pollIntervalMs: opts.pollIntervalMs ?? 2000,
|
|
747
|
+
// Our own ledger + idempotency key are the dedupe; a bus-level retry of a failed launch
|
|
748
|
+
// would double-launch precisely because the ledger row is only written on success.
|
|
749
|
+
maxRetries: 0,
|
|
750
|
+
handler: (event) => handleChatPosted(event),
|
|
751
|
+
onError: (err, event) => {
|
|
752
|
+
log(`[interactive-chat] handler error on event ${String(event?.event_id ?? '?')}: ${err.message}`);
|
|
753
|
+
},
|
|
754
|
+
});
|
|
755
|
+
return {
|
|
756
|
+
ledger,
|
|
757
|
+
inFlightDocs: () => [...new Set([...inFlight.values()].map((f) => f.documentId))],
|
|
758
|
+
queuedCount: (documentId) => queues.get(documentId)?.length ?? 0,
|
|
759
|
+
stop: async () => {
|
|
760
|
+
offCoreEvents();
|
|
761
|
+
clearInterval(sweep);
|
|
762
|
+
for (const runId of [...inFlight.keys()])
|
|
763
|
+
endFlight(runId);
|
|
764
|
+
queues.clear();
|
|
765
|
+
await sub.stop();
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
//# sourceMappingURL=chat-events.js.map
|