@maolon/pi-watcher 0.0.0-stage → 0.1.1

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +255 -2
  4. package/dist/cli.d.ts +8 -0
  5. package/dist/cli.js +369 -0
  6. package/dist/contracts/interfaces.d.ts +262 -0
  7. package/dist/contracts/interfaces.js +1 -0
  8. package/dist/contracts/policy-defaults.json +61 -0
  9. package/dist/contracts/relay-next.interfaces.d.ts +160 -0
  10. package/dist/contracts/relay-next.interfaces.js +1 -0
  11. package/dist/engine/cards.d.ts +68 -0
  12. package/dist/engine/cards.js +76 -0
  13. package/dist/engine/engine.d.ts +178 -0
  14. package/dist/engine/engine.js +1162 -0
  15. package/dist/engine/hard-rules.d.ts +53 -0
  16. package/dist/engine/hard-rules.js +96 -0
  17. package/dist/engine/semantic.d.ts +49 -0
  18. package/dist/engine/semantic.js +125 -0
  19. package/dist/engine/service.d.ts +62 -0
  20. package/dist/engine/service.js +587 -0
  21. package/dist/engine/tool-actions.d.ts +35 -0
  22. package/dist/engine/tool-actions.js +348 -0
  23. package/dist/engine/widget.d.ts +29 -0
  24. package/dist/engine/widget.js +52 -0
  25. package/dist/ipc/client.d.ts +26 -0
  26. package/dist/ipc/client.js +106 -0
  27. package/dist/ipc/server.d.ts +78 -0
  28. package/dist/ipc/server.js +105 -0
  29. package/dist/jev/client.d.ts +38 -0
  30. package/dist/jev/client.js +239 -0
  31. package/dist/jev/consent.d.ts +12 -0
  32. package/dist/jev/consent.js +37 -0
  33. package/dist/jev/index.d.ts +9 -0
  34. package/dist/jev/index.js +9 -0
  35. package/dist/jev/mock.d.ts +30 -0
  36. package/dist/jev/mock.js +77 -0
  37. package/dist/jev/pi-registry.d.ts +66 -0
  38. package/dist/jev/pi-registry.js +127 -0
  39. package/dist/jev/questions.d.ts +15 -0
  40. package/dist/jev/questions.js +76 -0
  41. package/dist/jev/sanitizer.d.ts +10 -0
  42. package/dist/jev/sanitizer.js +59 -0
  43. package/dist/jev/types.d.ts +78 -0
  44. package/dist/jev/types.js +54 -0
  45. package/dist/pi-extension.d.ts +123 -0
  46. package/dist/pi-extension.js +687 -0
  47. package/dist/relay/managed.d.ts +120 -0
  48. package/dist/relay/managed.js +482 -0
  49. package/dist/relay/negotiate.d.ts +39 -0
  50. package/dist/relay/negotiate.js +112 -0
  51. package/dist/runtime.d.ts +74 -0
  52. package/dist/runtime.js +246 -0
  53. package/dist/source/agent-check/adapter.d.ts +57 -0
  54. package/dist/source/agent-check/adapter.js +224 -0
  55. package/dist/source/agent-file/adapter.d.ts +57 -0
  56. package/dist/source/agent-file/adapter.js +217 -0
  57. package/dist/source/task-status-v1/adapter.d.ts +36 -0
  58. package/dist/source/task-status-v1/adapter.js +263 -0
  59. package/dist/source/task-status-v1/producer.d.ts +81 -0
  60. package/dist/source/task-status-v1/producer.js +127 -0
  61. package/dist/storage/lock.d.ts +14 -0
  62. package/dist/storage/lock.js +66 -0
  63. package/dist/storage/schema.sql +166 -0
  64. package/dist/storage/store.d.ts +352 -0
  65. package/dist/storage/store.js +555 -0
  66. package/dist/util/clock.d.ts +23 -0
  67. package/dist/util/clock.js +34 -0
  68. package/dist/util/ids.d.ts +8 -0
  69. package/dist/util/ids.js +29 -0
  70. package/dist/util/result.d.ts +28 -0
  71. package/dist/util/result.js +54 -0
  72. package/package.json +101 -4
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * WatchEngine: inspection pipeline (deterministic V1 subset of the design 5.2 processing order).
3
+ * 1) validate active/validity -> 2) read events/state (T2 ingest) -> 3) hard rules
4
+ * -> 4) episode supersede + result card (durable outbox + local display copy).
5
+ * The Jev semantic layer is wired in at V2 (JudgePort is ready); V1 has deterministic facts only.
6
+ */
7
+ import { emptySnapshot } from '../storage/store.js';
8
+ import { newId, digestJson } from '../util/ids.js';
9
+ import { statusPayloadToTaskState, isTerminalTaskState, isTerminalFact, deadlineFact, silenceFact, completionLevel } from './hard-rules.js';
10
+ import { buildResultCard, buildGroupResultCard } from './cards.js';
11
+ import { windowFingerprint, buildSemanticState, applyThresholds, dayPeriodKey } from './semantic.js';
12
+ export const DEFAULT_SEMANTIC_CONFIG = {
13
+ thresholds: {
14
+ contextSufficient: 0.75,
15
+ needsHostDecision: 0.85,
16
+ unresolvedBlocker: 0.85,
17
+ repeating: 0.9,
18
+ claimConflict: 0.85
19
+ },
20
+ maxJudgeRequestsPerRootDay: 2000,
21
+ maxProbesPerInspection: 1,
22
+ maxProbesPerEpisode: 2,
23
+ maxEvidenceEvents: 16,
24
+ stateMaxBytes: 24576
25
+ };
26
+ /** Default attention envelope validity: real wake latency includes safety-net ticks / long turns,
27
+ * 300s is not enough; also bounds the fast-close line. */
28
+ export const DEFAULT_ATTENTION_TTL_MS = 1_800_000;
29
+ const SEMANTIC_UNAVAILABLE = 'semantic review unavailable; hard rules continue';
30
+ const NO_JEV_CREDENTIALS = 'no Jev credentials (set JEV_API_KEY or TYPESAFE_API_KEY, or add a Jev provider via /login)';
31
+ /** Relay error codes that will not change on retry: record the control op as rejected. */
32
+ const DETERMINISTIC_RELAY_CODES = new Set(['stale_scope_revision', 'invalid_state', 'invalid_payload', 'not_found', 'unauthorized', 'id_conflict']);
33
+ export class WatchEngine {
34
+ clock;
35
+ store;
36
+ adapters;
37
+ requiredCheckIds;
38
+ judge;
39
+ displayNote;
40
+ semanticConfig;
41
+ semanticConsentRef;
42
+ judgeRequiresConsent;
43
+ deliveryRef;
44
+ onAttention;
45
+ onJudgment;
46
+ /** Single-flight per Watch (design 5.6) */
47
+ semanticInFlight = new Set();
48
+ /** After 401/403, egress is disabled within this process (design 5.6) */
49
+ judgeDisabled = false;
50
+ constructor(options) {
51
+ this.clock = options.clock;
52
+ this.store = options.store;
53
+ this.adapters = options.adapters;
54
+ this.requiredCheckIds = options.requiredCheckIds ?? new Map();
55
+ this.judge = options.judge;
56
+ this.displayNote = options.displayNote ?? 'local-display projection; not a relay delivery fact';
57
+ this.semanticConfig = options.semantic ?? DEFAULT_SEMANTIC_CONFIG;
58
+ const consent = options.semanticConsent ?? false;
59
+ this.semanticConsentRef = typeof consent === 'function' ? consent : () => consent;
60
+ this.judgeRequiresConsent = options.judgeRequiresConsent ?? true;
61
+ const d = options.delivery;
62
+ this.deliveryRef = typeof d === 'function' ? d : () => d;
63
+ this.onAttention = options.onAttention;
64
+ this.onJudgment = options.onJudgment;
65
+ }
66
+ /** Consent is read live: the user may grant or revoke it mid-session (/watcher jev consent). */
67
+ get semanticConsent() {
68
+ return this.semanticConsentRef();
69
+ }
70
+ get delivery() {
71
+ return this.deliveryRef();
72
+ }
73
+ /** Run due inspections: returns the list of executed inspectionIds. */
74
+ async runDue(limit = 32) {
75
+ // Durable control outbox first (design 7.7: control precedes ordinary publish).
76
+ try {
77
+ await this.drainControl();
78
+ }
79
+ catch {
80
+ /* intents stay durable; retried next sweep */
81
+ }
82
+ const now = this.clock.wallNow();
83
+ // Sweep: archive expired attention; supersede open episodes left on disabled watches (honest state, never leave pending forever)
84
+ this.store.transaction(tx => {
85
+ tx.expireStaleAttentions(now);
86
+ tx.supersedeEpisodesOfInactiveWatches(now);
87
+ });
88
+ const due = this.store.transaction(tx => tx.listAllWatches(1000))
89
+ .filter(w => w.lifecycle === 'active')
90
+ .filter(w => w.nextDueAt === null || w.nextDueAt <= now)
91
+ .slice(0, limit);
92
+ const done = [];
93
+ for (const row of due) {
94
+ const outcome = await this.inspectWatch(row.watchId);
95
+ if (outcome)
96
+ done.push(outcome.inspectionId);
97
+ }
98
+ return done;
99
+ }
100
+ async inspectWatch(watchId, signal) {
101
+ const inspectionId = newId('insp');
102
+ const row = this.store.transaction(tx => tx.getWatchRow(watchId));
103
+ if (!row)
104
+ return null;
105
+ if (row.lifecycle !== 'active') {
106
+ return {
107
+ inspectionId, watchId, ingested: 0,
108
+ produced: { episodes: [], resultCards: [] },
109
+ health: row.health, lifecycleAfter: row.lifecycle
110
+ };
111
+ }
112
+ // limits.expiresAt: observation authorization expired -> lifecycle expired (does not imply business failure)
113
+ const now = this.clock.wallNow();
114
+ if (row.spec.limits.expiresAt && now > Date.parse(row.spec.limits.expiresAt)) {
115
+ this.store.transaction(tx => {
116
+ tx.updateWatch(watchId, { lifecycle: 'expired' }, now);
117
+ });
118
+ return {
119
+ inspectionId, watchId, ingested: 0,
120
+ produced: { episodes: [], resultCards: [] },
121
+ health: row.health, lifecycleAfter: 'expired'
122
+ };
123
+ }
124
+ const target = row.spec.target;
125
+ if (target.kind === 'group') {
126
+ return this.inspectGroup(watchId, row, inspectionId, now);
127
+ }
128
+ if (target.kind === 'obligation') {
129
+ return this.inspectObligation(watchId, row, inspectionId, now);
130
+ }
131
+ const adapter = this.adapters.get(target.sourceId);
132
+ if (!adapter) {
133
+ // Source unreachable: keep last known, health=degraded (design 4.5)
134
+ this.degrade(watchId, `source adapter unavailable: ${target.sourceId}`, now);
135
+ this.ensureMonitorEpisode(watchId, row, 'monitor.degraded', `source adapter unavailable: ${target.sourceId}`, now);
136
+ return {
137
+ inspectionId, watchId, ingested: 0,
138
+ produced: { episodes: [], resultCards: [] },
139
+ health: 'degraded', lifecycleAfter: row.lifecycle
140
+ };
141
+ }
142
+ let readResult;
143
+ try {
144
+ const cursor = this.store.transaction(tx => tx.getCursor(watchId, target.sourceId, row.generation));
145
+ readResult = await adapter.read(target, cursor, signal ?? new AbortController().signal);
146
+ }
147
+ catch (e) {
148
+ this.degrade(watchId, `source read failed: ${e instanceof Error ? e.message : String(e)}`, now);
149
+ this.ensureMonitorEpisode(watchId, row, 'monitor.degraded', `source read failed: ${e instanceof Error ? e.message : String(e)}`, now);
150
+ return {
151
+ inspectionId, watchId, ingested: 0,
152
+ produced: { episodes: [], resultCards: [] },
153
+ health: 'degraded', lifecycleAfter: row.lifecycle
154
+ };
155
+ }
156
+ // T2 ingest: save observation, update snapshot/cursor, advance next_due (cursor advances only after the whole batch commits)
157
+ const ingest = this.store.transaction(tx => {
158
+ let inserted = 0;
159
+ let snapshot = row.snapshot ?? emptySnapshot();
160
+ let newestStatusAtMs = snapshot.lastObservedAtMs ?? 0;
161
+ for (const obs of readResult.observations) {
162
+ const localSeq = tx.bumpObservationSeq(watchId);
163
+ const did = tx.insertObservation({
164
+ observationId: obs.observationId,
165
+ watchId,
166
+ generation: row.generation,
167
+ sourceId: target.sourceId,
168
+ attemptId: obs.target.attemptId,
169
+ sourceSeq: obs.sourceSeq,
170
+ localSeq,
171
+ observedAt: Date.parse(obs.observedAt) || now,
172
+ digest: digestJson(obs.payload),
173
+ payload: obs.payload
174
+ });
175
+ if (did)
176
+ inserted += 1;
177
+ // Identity check: evidence from different attempts is not merged (I01)
178
+ if (obs.target.attemptId === target.attemptId && obs.target.runId === target.runId) {
179
+ const applied = applyObservationToSnapshot(snapshot, obs, now);
180
+ snapshot = applied.snapshot;
181
+ if (applied.activityAtMs !== null && applied.activityAtMs > newestStatusAtMs)
182
+ newestStatusAtMs = applied.activityAtMs;
183
+ }
184
+ }
185
+ snapshot.lastObservedAtMs = Math.max(newestStatusAtMs, snapshot.lastObservedAtMs ?? 0) || snapshot.lastObservedAtMs;
186
+ snapshot.coverage = {
187
+ truncated: false,
188
+ sourceGap: readResult.gap || (snapshot.coverage?.sourceGap ?? false)
189
+ };
190
+ // Backoff: new facts reset to pollMin; without new facts grow by x2 up to pollMax
191
+ const { pollMinMs, pollMaxMs } = row.spec.limits;
192
+ let backoffMs;
193
+ if (inserted > 0) {
194
+ backoffMs = pollMinMs;
195
+ }
196
+ else {
197
+ const prev = snapshot.backoffMs || pollMinMs;
198
+ backoffMs = Math.min(Math.max(prev * 2, pollMinMs), pollMaxMs);
199
+ }
200
+ // After terminal facts the source no longer changes: inspection backs off to a slow liveness cadence (wait for auto-close / explicit control, no idle burn)
201
+ if (isTerminalFact(snapshot)) {
202
+ backoffMs = Math.max(backoffMs, 60_000);
203
+ }
204
+ snapshot.backoffMs = backoffMs;
205
+ let health = readResult.gap ? 'degraded' : 'healthy';
206
+ if (readResult.gap) {
207
+ snapshot.degradedReason = 'source gap (journal rotation/truncation or unparseable line)';
208
+ }
209
+ else if (health === 'healthy') {
210
+ snapshot.degradedReason = null;
211
+ }
212
+ tx.updateWatch(watchId, { snapshot, nextDueAt: now + backoffMs, health }, now);
213
+ tx.putCursor(watchId, target.sourceId, row.generation, readResult.nextCursor);
214
+ return { inserted, snapshot, health };
215
+ });
216
+ // Hard rules (deterministic facts first)
217
+ const produced = { episodes: [], resultCards: [] };
218
+ const spec = row.spec;
219
+ const snapshot = ingest.snapshot;
220
+ const requiredChecks = this.requiredCheckIds.get(target.sourceId) ?? [];
221
+ // 3a. Terminal facts: explicit terminal state, or "exited but result unknown" (exited evidence -> task.exited-unknown)
222
+ if (isTerminalFact(snapshot) && !snapshot.terminalAt) {
223
+ const completion = completionLevel(spec, snapshot, requiredChecks);
224
+ const kind = snapshot.taskState === 'failed'
225
+ ? 'task.failed'
226
+ : snapshot.taskState === 'unknown'
227
+ ? 'task.exited-unknown'
228
+ : 'task.terminal';
229
+ const episodeId = this.openEpisode(watchId, row, kind, {
230
+ reasonCode: kind,
231
+ taskState: snapshot.taskState,
232
+ exitCode: snapshot.exitCode ?? null,
233
+ summary: snapshot.summary ?? null,
234
+ overall: completion.overall,
235
+ firstSeenAt: new Date(now).toISOString()
236
+ }, now);
237
+ if (episodeId) {
238
+ produced.episodes.push(episodeId);
239
+ await this.publishEpisodeAttention(watchId, row, episodeId, kind, `${snapshot.taskState}${snapshot.exitCode !== undefined && snapshot.exitCode !== null ? ` exitCode=${snapshot.exitCode}` : ''}: ${snapshot.summary ?? 'no summary'}`, now);
240
+ }
241
+ const resultId = `result-${watchId}-${row.generation}-${snapshot.lastSourceSeq ?? 0}`;
242
+ const card = buildResultCard({
243
+ resultId,
244
+ spec: { ...spec, watchId },
245
+ snapshot: { ...snapshot, terminalAt: now },
246
+ requiredCheckIds: requiredChecks,
247
+ health: ingest.health,
248
+ observedAtMs: now,
249
+ deliveryNote: this.displayNote
250
+ });
251
+ this.commitResultCard(watchId, row, episodeId, resultId, card, now);
252
+ produced.resultCards.push(resultId);
253
+ this.store.transaction(tx => {
254
+ const fresh = tx.getWatchRow(watchId);
255
+ if (fresh) {
256
+ tx.updateWatch(watchId, {
257
+ snapshot: { ...fresh.snapshot, terminalAt: now, lastResultId: resultId }
258
+ }, now);
259
+ }
260
+ });
261
+ }
262
+ // 3b. Business deadline crossed (a new fact even when observations are unchanged).
263
+ // Crossing is an edge: at most one deadline episode per slot (design 3.6). Once the
264
+ // host has closed it, it is not reopened/re-sent on every inspection (I18: no
265
+ // self-wake loop without new facts).
266
+ const dl = deadlineFact(spec.mission, now);
267
+ const deadlineAlreadyRaised = dl.crossed && this.store.transaction(tx => tx.nextEpisodeOrdinal(watchId, row.generation, 'deadline.exceeded', spec.mission.checkpointId) > 1);
268
+ if (dl.crossed && !deadlineAlreadyRaised && !isTerminalFact(snapshot)) {
269
+ const episodeId = this.openEpisode(watchId, row, 'deadline.exceeded', {
270
+ reasonCode: 'deadline.exceeded',
271
+ deadlineAt: spec.mission.deadlineAt ?? null,
272
+ taskState: snapshot.taskState,
273
+ firstSeenAt: new Date(now).toISOString()
274
+ }, now);
275
+ if (episodeId) {
276
+ produced.episodes.push(episodeId);
277
+ await this.publishEpisodeAttention(watchId, row, episodeId, 'deadline.exceeded', `deadline ${spec.mission.deadlineAt ?? ''} crossed; taskState=${snapshot.taskState}`, now);
278
+ }
279
+ }
280
+ // 3c. Silence over limit -> monitoring health degraded (not task failure); after terminal facts the source no longer produces output, so the silence rule does not apply
281
+ if (!isTerminalFact(snapshot)) {
282
+ const silence = silenceFact(snapshot, spec.limits.maxSilenceMs, now);
283
+ if (silence.exceeded) {
284
+ this.store.transaction(tx => {
285
+ const fresh = tx.getWatchRow(watchId);
286
+ if (fresh && fresh.health === 'healthy') {
287
+ tx.updateWatch(watchId, { health: 'degraded' }, now);
288
+ }
289
+ });
290
+ this.ensureMonitorEpisode(watchId, row, 'monitor.degraded', `silence exceeded maxSilenceMs=${spec.limits.maxSilenceMs}`, now);
291
+ }
292
+ }
293
+ // 3d. Terminal-state auto-close (a watch the host already responded to should not keep occupying the TTL window):
294
+ // a) all episodes resolved/superseded (host responded) -> close after a short grace once terminal
295
+ // b) otherwise, if still unanswered after attention TTL + grace -> supersede episodes and shut down (clean up after watch completes)
296
+ // Delivery guard: when the wake is unconfirmed (publish not done / still in flight in relay)
297
+ // do not discard on TTL cleanup -- undelivered != host has seen; the watch stays until limits.expiresAt, visible in the widget.
298
+ const attentionTtlMs = spec.policy.attentionTtlMs || DEFAULT_ATTENTION_TTL_MS;
299
+ if (isTerminalFact(snapshot) && snapshot.terminalAt) {
300
+ const ttlElapsed = now > snapshot.terminalAt + attentionTtlMs + 60_000;
301
+ let hostResponded = false;
302
+ if (!ttlElapsed) {
303
+ const episodes = this.store.transaction(tx => tx.listEpisodes(watchId, 50));
304
+ hostResponded = episodes.length > 0
305
+ && episodes.every(e => e.state === 'resolved' || e.state === 'superseded');
306
+ }
307
+ if ((ttlElapsed && !this.hasUndeliveredWake(watchId, now)) || (hostResponded && now > snapshot.terminalAt + 5_000)) {
308
+ this.autoCloseTerminal(watchId, now);
309
+ }
310
+ }
311
+ // 3e. Delivery-layer health: attention still unknown and episode undecided -> make loud + bounded retry within envelope TTL
312
+ await this.checkDeliveryHealth(watchId, now);
313
+ // 5.2 steps 3-5: semantic layer (call Jev only if the window changed and egress is permitted; shadow only records candidates)
314
+ if (spec.policy.semanticMode !== 'off' && !isTerminalFact(snapshot)) {
315
+ // A judge may be configured but not usable yet (credentials resolved lazily from Pi).
316
+ const readiness = !this.judge
317
+ ? { ready: false, reason: NO_JEV_CREDENTIALS }
318
+ : this.judge.ready ? await this.judge.ready().catch(() => ({ ready: false, reason: 'judge readiness check failed' })) : { ready: true };
319
+ if (readiness.ready) {
320
+ await this.runSemanticPass(watchId, now, signal);
321
+ }
322
+ else {
323
+ // Say so instead of silently tracking facts only. A mock judge is never substituted
324
+ // outside tests; its scores are not model output.
325
+ const reason = `${SEMANTIC_UNAVAILABLE}${'reason' in readiness && readiness.reason ? `: ${readiness.reason}` : ''}`;
326
+ this.store.transaction(tx => {
327
+ const fresh = tx.getWatchRow(watchId);
328
+ if (!fresh || fresh.snapshot.semantic?.error === reason)
329
+ return;
330
+ tx.updateWatch(watchId, {
331
+ snapshot: { ...fresh.snapshot, semantic: { ...(fresh.snapshot.semantic ?? { mode: spec.policy.semanticMode === 'active' ? 'active' : 'shadow', repeatingStreak: 0 }), error: reason } }
332
+ }, now);
333
+ });
334
+ }
335
+ }
336
+ return {
337
+ inspectionId,
338
+ watchId,
339
+ ingested: ingest.inserted,
340
+ produced,
341
+ health: ingest.health,
342
+ lifecycleAfter: 'active'
343
+ };
344
+ }
345
+ /**
346
+ * V2 semantic inspection (design 5.2 steps 3-5 / 5.5 / 5.6).
347
+ * Network calls do not hold a SQL write transaction; single-flight per Watch; late results are discarded after version check.
348
+ */
349
+ async runSemanticPass(watchId, now, signal) {
350
+ if (this.judgeDisabled || this.semanticInFlight.has(watchId))
351
+ return;
352
+ this.semanticInFlight.add(watchId);
353
+ try {
354
+ const row0 = this.store.transaction(tx => tx.getWatchRow(watchId));
355
+ if (!row0 || row0.lifecycle !== 'active' || row0.spec.target.kind !== 'run')
356
+ return;
357
+ const observations = this.store.transaction(tx => tx.listObservations(watchId, 32));
358
+ const fp = windowFingerprint(row0, now);
359
+ const sem0 = row0.snapshot.semantic;
360
+ const model = 'jev-1.13.0';
361
+ const questionSet = 'watcher-q1';
362
+ // Window unchanged and an accepted judgment exists -> cache hit (cache key includes model/question set/policy version, 5.2 step 3)
363
+ const cached = this.store.transaction(tx => tx.getJudgmentByWindow(watchId, row0.generation, fp, model, questionSet));
364
+ if (sem0?.lastWindowDigest === fp && cached?.status === 'accepted')
365
+ return;
366
+ const isLiveJudge = this.judgeRequiresConsent;
367
+ if (isLiveJudge && !this.semanticConsent) {
368
+ this.store.transaction(tx => {
369
+ const fresh = tx.getWatchRow(watchId);
370
+ if (fresh)
371
+ tx.updateWatch(watchId, {
372
+ snapshot: { ...fresh.snapshot, semantic: { ...(fresh.snapshot.semantic ?? { mode: 'shadow', repeatingStreak: 0 }), consentMissing: true, budgetExhausted: false } }
373
+ }, now);
374
+ });
375
+ return;
376
+ }
377
+ // Budget (5.6): hard limit by request count; per-watch daily limit + per-root daily limit
378
+ const day = dayPeriodKey(now);
379
+ const watchUsed = this.store.transaction(tx => tx.countBudgetReservations('judge', `watch:${watchId}:${day}`, watchId));
380
+ const rootUsed = this.store.transaction(tx => tx.countBudgetReservations('judge', `root:${day}`, undefined));
381
+ const watchCap = row0.spec.limits.maxJudgeRequestsPerDay;
382
+ if (watchUsed >= watchCap || rootUsed >= this.semanticConfig.maxJudgeRequestsPerRootDay) {
383
+ this.store.transaction(tx => {
384
+ const fresh = tx.getWatchRow(watchId);
385
+ if (!fresh)
386
+ return;
387
+ tx.updateWatch(watchId, {
388
+ health: 'degraded',
389
+ snapshot: { ...fresh.snapshot, degradedReason: 'semantic budget exhausted; hard rules continue (design 5.6)', semantic: { ...(fresh.snapshot.semantic ?? { mode: 'shadow', repeatingStreak: 0 }), budgetExhausted: true } }
390
+ }, now);
391
+ });
392
+ return;
393
+ }
394
+ const reservationId = newId('bud');
395
+ this.store.transaction(tx => {
396
+ tx.insertBudgetReservation({ reservationId, watchId, category: 'judge', periodKey: `watch:${watchId}:${day}`, units: 1, state: 'reserved', createdAt: now });
397
+ tx.insertBudgetReservation({ reservationId: reservationId + '-root', watchId, category: 'judge', periodKey: `root:${day}`, units: 1, state: 'reserved', createdAt: now });
398
+ });
399
+ const basis = {
400
+ watchId,
401
+ generation: row0.generation,
402
+ missionRevision: row0.missionRevision,
403
+ controlRevision: row0.controlRevision,
404
+ observationSeq: row0.observationSeq,
405
+ windowDigest: fp
406
+ };
407
+ const state = buildSemanticState(row0, observations, this.semanticConfig);
408
+ let judgment;
409
+ try {
410
+ judgment = await this.judge.evaluate(basis, state, signal ?? new AbortController().signal);
411
+ this.store.transaction(tx => tx.setBudgetReservationState(reservationId, 'spent'));
412
+ this.store.transaction(tx => tx.setBudgetReservationState(reservationId + '-root', 'spent'));
413
+ }
414
+ catch (e) {
415
+ // Timeout is treated as possibly billed; conservatively consume the reservation (5.6)
416
+ this.store.transaction(tx => tx.setBudgetReservationState(reservationId, 'unknown-cost'));
417
+ this.store.transaction(tx => tx.setBudgetReservationState(reservationId + '-root', 'unknown-cost'));
418
+ const msg = e instanceof Error ? e.message : String(e);
419
+ const auth = e instanceof Error && (e.name === 'JevAuthenticationError' || /401|403|authentication/i.test(msg));
420
+ if (auth && !this.judge?.ready)
421
+ this.judgeDisabled = true; // host-resolved judges re-check credentials each pass
422
+ this.store.transaction(tx => {
423
+ const fresh = tx.getWatchRow(watchId);
424
+ if (!fresh)
425
+ return;
426
+ tx.insertJudgment({
427
+ judgmentId: newId('jd'), watchId, generation: row0.generation, missionRevision: row0.missionRevision,
428
+ controlRevision: row0.controlRevision, windowDigest: fp, model, questionSet, status: 'failed',
429
+ data: { error: msg, authDisabled: auth }, createdAt: now
430
+ });
431
+ tx.updateWatch(watchId, {
432
+ health: 'degraded',
433
+ snapshot: { ...fresh.snapshot, degradedReason: `jev failed: ${msg}`, semantic: { ...(fresh.snapshot.semantic ?? { mode: 'shadow', repeatingStreak: 0 }), error: msg } }
434
+ }, now);
435
+ });
436
+ return;
437
+ }
438
+ // Validate response completeness and probability range (5.2 step 4); late results are discarded after version check (5.6)
439
+ const keys = ['meaningful_progress', 'unresolved_blocker', 'needs_host_decision', 'repeating_without_new_information', 'claim_conflicts_with_evidence', 'context_sufficient'];
440
+ const valid = keys.every(k => typeof judgment.probabilities[k] === 'number' && judgment.probabilities[k] >= 0 && judgment.probabilities[k] <= 1);
441
+ const stillCurrent = this.store.transaction(tx => {
442
+ const fresh = tx.getWatchRow(watchId);
443
+ return !!fresh && fresh.missionRevision === row0.missionRevision && fresh.controlRevision === row0.controlRevision;
444
+ });
445
+ if (!valid || !stillCurrent) {
446
+ this.store.transaction(tx => tx.insertJudgment({
447
+ judgmentId: newId('jd'), watchId, generation: row0.generation, missionRevision: row0.missionRevision,
448
+ controlRevision: row0.controlRevision, windowDigest: fp, model: judgment.model, questionSet, status: 'discarded',
449
+ data: { reason: !valid ? 'invalid probabilities' : 'stale basis', probabilities: judgment.probabilities }, createdAt: now
450
+ }));
451
+ return;
452
+ }
453
+ // "Window with new observations" (5.5) is judged by the local monotonic observationSeq,
454
+ // counting every observation kind from every source. lastSourceSeq only advances on
455
+ // status, so log-only sources would otherwise never count as new.
456
+ const hadNewObservations = sem0?.lastJudgedObservationSeq === undefined || sem0.lastJudgedObservationSeq === null
457
+ ? (row0.snapshot.lastSourceSeq ?? -1) !== (sem0?.lastJudgedSourceSeq ?? -1)
458
+ : row0.observationSeq !== sem0.lastJudgedObservationSeq;
459
+ const prevStreak = row0.snapshot.semantic?.repeatingStreak ?? 0;
460
+ const applied = applyThresholds(judgment.probabilities, this.semanticConfig.thresholds, hadNewObservations, prevStreak);
461
+ let followUpJudged = false;
462
+ let candidates = applied.candidates;
463
+ let probabilities = judgment.probabilities;
464
+ // Context insufficient and budget available: pick one bounded probe; after updating evidence, judge at most once more (5.2 step 5)
465
+ if (applied.contextLow) {
466
+ const probes = await this.listProbes(watchId);
467
+ const probeBudgetOk = this.store.transaction(tx => tx.countBudgetReservations('probe', `watch:${watchId}:${day}`, watchId) < row0.spec.limits.maxProbesPerEpisode);
468
+ if (probes.length > 0 && probeBudgetOk) {
469
+ const probeRunId = newId('prb');
470
+ const chosen = await this.judge.chooseProbe(basis, state, probes, signal ?? new AbortController().signal);
471
+ this.store.transaction(tx => tx.insertProbeRun({
472
+ probeRunId, watchId, episodeId: null, generation: row0.generation, controlRevision: row0.controlRevision,
473
+ probeId: chosen === 'none' ? 'none' : chosen, status: 'inflight',
474
+ data: { reason: 'context_sufficient below threshold', windowDigest: fp }, createdAt: now
475
+ }));
476
+ if (chosen !== 'none') {
477
+ const probe = probes.find(p => p.probeId === chosen);
478
+ if (probe) {
479
+ const budgetId = newId('bud');
480
+ this.store.transaction(tx => tx.insertBudgetReservation({ reservationId: budgetId, watchId, category: 'probe', periodKey: `watch:${watchId}:${day}`, units: 1, state: 'spent', createdAt: now }));
481
+ const got = await this.executeProbe(watchId, chosen, signal);
482
+ // Record probe observation (dedup relies on UNIQUE constraint)
483
+ const ingested2 = this.store.transaction(tx => {
484
+ const fresh = tx.getWatchRow(watchId);
485
+ if (!fresh)
486
+ return 0;
487
+ let n = 0;
488
+ let snap = { ...fresh.snapshot };
489
+ const t = fresh.spec.target;
490
+ for (const obs of got) {
491
+ const localSeq = tx.bumpObservationSeq(watchId);
492
+ const did = tx.insertObservation({
493
+ observationId: obs.observationId, watchId, generation: fresh.generation,
494
+ sourceId: fresh.spec.target.sourceId, attemptId: obs.target.attemptId,
495
+ sourceSeq: obs.sourceSeq, localSeq, observedAt: Date.parse(obs.observedAt) || now,
496
+ digest: digestJson(obs.payload), payload: obs.payload
497
+ });
498
+ if (did)
499
+ n += 1;
500
+ // Same projection and identity check as ingest (I01): probe evidence also updates tail/state
501
+ if (obs.target.attemptId === t.attemptId && obs.target.runId === t.runId) {
502
+ snap = applyObservationToSnapshot(snap, obs, now).snapshot;
503
+ }
504
+ }
505
+ if (n > 0)
506
+ snap.lastSourceSeq = Math.max(snap.lastSourceSeq ?? 0, ...got.map(o => o.sourceSeq));
507
+ tx.updateWatch(watchId, { snapshot: snap }, now);
508
+ return n;
509
+ });
510
+ this.store.transaction(tx => tx.setProbeRunStatus(probeRunId, 'done', { ingested: ingested2, probeId: chosen }));
511
+ // Judge once more (new window)
512
+ const row1 = this.store.transaction(tx => tx.getWatchRow(watchId));
513
+ if (row1) {
514
+ const fp2 = windowFingerprint(row1, now);
515
+ if (fp2 !== fp) {
516
+ const basis2 = { ...basis, windowDigest: fp2, observationSeq: row1.observationSeq };
517
+ const state2 = buildSemanticState(row1, this.store.transaction(tx => tx.listObservations(watchId, 32)), this.semanticConfig);
518
+ try {
519
+ const j2 = await this.judge.evaluate(basis2, state2, signal ?? new AbortController().signal);
520
+ const valid2 = keys.every(k => typeof j2.probabilities[k] === 'number' && j2.probabilities[k] >= 0 && j2.probabilities[k] <= 1);
521
+ if (valid2) {
522
+ const applied2 = applyThresholds(j2.probabilities, this.semanticConfig.thresholds, true, applied.nextRepeatingStreak);
523
+ candidates = applied2.candidates.length > 0 ? applied2.candidates : (applied2.contextLow ? [{ reason: 'evidence.insufficient', probability: j2.probabilities.context_sufficient, note: 'context still insufficient (after probe)' }] : []);
524
+ probabilities = j2.probabilities;
525
+ }
526
+ followUpJudged = true;
527
+ }
528
+ catch {
529
+ followUpJudged = true;
530
+ }
531
+ }
532
+ }
533
+ }
534
+ else {
535
+ this.store.transaction(tx => tx.setProbeRunStatus(probeRunId, 'discarded', { reason: 'chosen probe not found' }));
536
+ }
537
+ }
538
+ else {
539
+ this.store.transaction(tx => tx.setProbeRunStatus(probeRunId, 'discarded', { reason: 'judge chose none' }));
540
+ }
541
+ }
542
+ if (candidates.length === 0 && !followUpJudged) {
543
+ candidates = [{ reason: 'evidence.insufficient', probability: judgment.probabilities.context_sufficient, note: 'context insufficient and no probe available or budget exhausted' }];
544
+ }
545
+ }
546
+ // Record judgment (accepted) + candidate projection (shadow only records, no episode/outbox, design 5.5)
547
+ const mode = row0.spec.policy.semanticMode === 'active' ? 'active' : 'shadow';
548
+ const candidateMap = {};
549
+ for (const c of candidates)
550
+ candidateMap[c.reason] = { probability: c.probability, note: c.note ?? null };
551
+ this.store.transaction(tx => {
552
+ tx.insertJudgment({
553
+ judgmentId: newId('jd'), watchId, generation: row0.generation, missionRevision: row0.missionRevision,
554
+ controlRevision: row0.controlRevision, windowDigest: fp, model: judgment.model, questionSet, status: 'accepted',
555
+ data: {
556
+ probabilities: probabilities,
557
+ inputTokens: judgment.inputTokens,
558
+ mode,
559
+ candidates: candidateMap,
560
+ followUpJudged
561
+ }, createdAt: now
562
+ });
563
+ const fresh = tx.getWatchRow(watchId);
564
+ if (fresh) {
565
+ tx.updateWatch(watchId, {
566
+ snapshot: {
567
+ ...fresh.snapshot,
568
+ semantic: {
569
+ mode,
570
+ lastWindowDigest: fp,
571
+ lastJudgedAtMs: now,
572
+ lastJudgedSourceSeq: row0.snapshot.lastSourceSeq ?? null,
573
+ lastJudgedObservationSeq: row0.observationSeq,
574
+ repeatingStreak: applied.nextRepeatingStreak,
575
+ meaningfulProgress: probabilities.meaningful_progress,
576
+ candidates: candidateMap,
577
+ consentMissing: false,
578
+ budgetExhausted: false,
579
+ error: null
580
+ }
581
+ }
582
+ }, now);
583
+ }
584
+ });
585
+ // Judgment-complete notice (must be visible during judge review); display failure does not block the engine
586
+ try {
587
+ this.onJudgment?.({
588
+ watchId,
589
+ ownerSession: row0.ownerSession,
590
+ objective: row0.spec.mission.objective,
591
+ mode,
592
+ candidates: candidates.map(c => ({ reason: c.reason, probability: c.probability, note: c.note ?? null })),
593
+ probabilities: probabilities
594
+ });
595
+ }
596
+ catch { /* display-layer failure does not affect the engine */ }
597
+ // Only in active mode create episode + attention (and only with egress consent); relay not ready -> local outbox (design 5.5/9.3)
598
+ if (mode === 'active' && this.semanticConsent) {
599
+ for (const c of candidates) {
600
+ const episodeId = this.openEpisode(watchId, row0, c.reason, {
601
+ reasonCode: c.reason,
602
+ probability: c.probability,
603
+ note: c.note ?? null,
604
+ windowDigest: fp,
605
+ source: 'jev',
606
+ firstSeenAt: new Date(now).toISOString()
607
+ }, now);
608
+ if (episodeId) {
609
+ // Without delivery, likewise land in the local outbox (handled inside publishEpisodeAttention)
610
+ await this.publishEpisodeAttention(watchId, row0, episodeId, c.reason, `jev ${c.reason}${c.note ? ` (${c.note})` : ''}`, now, c.probability);
611
+ }
612
+ }
613
+ }
614
+ }
615
+ finally {
616
+ this.semanticInFlight.delete(watchId);
617
+ }
618
+ }
619
+ /** V3: group (design 3.x watch-group) -- reads committed child Watch state. */
620
+ inspectGroup(watchId, row, inspectionId, now) {
621
+ const target = row.spec.target;
622
+ const produced = { episodes: [], resultCards: [] };
623
+ const children = target.members.map(m => ({ ref: m, row: this.store.transaction(tx => tx.getWatchRow(m.watchId)) }));
624
+ const missing = children.filter(c => !c.row || c.row.generation !== c.ref.generation);
625
+ if (missing.length > 0) {
626
+ this.degrade(watchId, `group member missing or generation mismatch: ${missing.map(m => m.ref.watchId).join(', ')}`, now);
627
+ this.ensureMonitorEpisode(watchId, row, 'monitor.degraded', `group member missing or generation mismatch: ${missing.map(m => m.ref.watchId).join(', ')}`, now);
628
+ }
629
+ const present = children.filter(c => c.row);
630
+ const states = present.map(c => ({ watchId: c.ref.watchId, taskState: c.row.snapshot.taskState, lifecycle: c.row.lifecycle }));
631
+ const allTerminal = present.length > 0 && present.every(c => isTerminalTaskState(c.row.snapshot.taskState));
632
+ const allSucceeded = present.length > 0 && present.every(c => c.row.snapshot.taskState === 'succeeded');
633
+ const ready = target.readyWhen === 'all_succeeded' ? allSucceeded : allTerminal;
634
+ const { pollMinMs, pollMaxMs } = row.spec.limits;
635
+ const backoffMs = ready ? pollMaxMs : Math.min((row.snapshot.backoffMs || pollMinMs) * 2, pollMaxMs);
636
+ const snapshot = { ...row.snapshot, backoffMs, members: states, groupReadyAt: ready ? (row.snapshot.groupReadyAt ?? now) : row.snapshot.groupReadyAt ?? null };
637
+ this.store.transaction(tx => tx.updateWatch(watchId, { snapshot, nextDueAt: now + backoffMs, health: missing.length > 0 ? 'degraded' : 'healthy' }, now));
638
+ if (ready && !row.snapshot.groupReadyAt) {
639
+ const episodeId = this.openEpisode(watchId, row, 'dependency.ready', {
640
+ reasonCode: 'dependency.ready',
641
+ readyWhen: target.readyWhen,
642
+ members: states,
643
+ firstSeenAt: new Date(now).toISOString()
644
+ }, now);
645
+ if (episodeId)
646
+ produced.episodes.push(episodeId);
647
+ const resultId = `result-${watchId}-${row.generation}-group`;
648
+ const card = buildGroupResultCard({
649
+ resultId,
650
+ spec: { ...row.spec, watchId },
651
+ members: states,
652
+ readyWhen: target.readyWhen,
653
+ health: missing.length > 0 ? 'degraded' : 'healthy',
654
+ observedAtMs: now,
655
+ deliveryNote: this.displayNote
656
+ });
657
+ this.commitResultCard(watchId, row, episodeId, resultId, card, now);
658
+ produced.resultCards.push(resultId);
659
+ }
660
+ return { inspectionId, watchId, ingested: 0, produced, health: missing.length > 0 ? 'degraded' : 'healthy', lifecycleAfter: 'active' };
661
+ }
662
+ /** V3: explicit follow-up obligation (pi-obligation-v1 semantics; pure time reminder = obligation without dependencies). */
663
+ async inspectObligation(watchId, row, inspectionId, now) {
664
+ const target = row.spec.target;
665
+ const deps = target.dependencies.map(d => ({ ref: d, row: this.store.transaction(tx => tx.getWatchRow(d.watchId)) }));
666
+ const missing = deps.filter(d => !d.row || d.row.generation !== d.ref.generation);
667
+ const present = deps.filter(d => d.row);
668
+ const depsSatisfied = target.dependencies.length === 0
669
+ ? true // pure time obligation: no dependencies, fires when due
670
+ : present.length === target.dependencies.length &&
671
+ (target.readyWhen === 'all_succeeded'
672
+ ? present.every(d => d.row.snapshot.taskState === 'succeeded')
673
+ : present.every(d => isTerminalTaskState(d.row.snapshot.taskState)));
674
+ const dl = deadlineFact(row.spec.mission, now);
675
+ const due = depsSatisfied && dl.crossed;
676
+ const open = this.store.transaction(tx => tx.getActiveEpisodeBySlot(watchId, row.generation, 'dependency.ready', row.spec.mission.checkpointId));
677
+ const snoozedUntil = open?.snoozeUntil ?? null;
678
+ const recheckDue = snoozedUntil !== null && snoozedUntil <= now;
679
+ const produced = { episodes: [], resultCards: [] };
680
+ if (missing.length > 0) {
681
+ this.degrade(watchId, `obligation dependency missing: ${missing.map(m => m.ref.watchId).join(', ')}`, now);
682
+ }
683
+ // If the same slot already has a terminal episode (resolved/superseded), do not reopen -- no permanent looping alarm (design 6.4)
684
+ const priorForSlot = this.store.transaction(tx => tx.listEpisodes(watchId, 100))
685
+ .some(e => e.kind === 'dependency.ready' && e.checkpointId === row.spec.mission.checkpointId);
686
+ if (due && !open && !priorForSlot) {
687
+ // Due and no open/snoozed episode -> open the dependency-ready question (needs explicit ACK; expiry does not auto-swap ID to extend, design 6.4)
688
+ const episodeId = this.openEpisode(watchId, row, 'dependency.ready', {
689
+ reasonCode: 'dependency.ready',
690
+ hostAction: target.hostAction,
691
+ dueAt: row.spec.mission.deadlineAt ?? null,
692
+ dependenciesSatisfied: depsSatisfied,
693
+ firstSeenAt: new Date(now).toISOString()
694
+ }, now);
695
+ if (episodeId) {
696
+ produced.episodes.push(episodeId);
697
+ await this.publishEpisodeAttention(watchId, row, episodeId, 'dependency.ready', `obligation due: ${target.hostAction}`, now);
698
+ }
699
+ }
700
+ else if (open && open.state === 'snoozed' && recheckDue) {
701
+ // On defer expiry, recheck first: reopen (no permanent looping alarm, design 6.4)
702
+ this.store.transaction(tx => {
703
+ tx.updateEpisode(open.episodeId, open.revision, { state: 'open' }, now);
704
+ });
705
+ produced.episodes.push(open.episodeId);
706
+ }
707
+ // Next due: snooze expiry, deadline, or poll cap
708
+ const { pollMinMs, pollMaxMs } = row.spec.limits;
709
+ let nextDue;
710
+ if (open?.state === 'snoozed' && snoozedUntil)
711
+ nextDue = snoozedUntil;
712
+ else if (!due && row.spec.mission.deadlineAt)
713
+ nextDue = Math.min(Date.parse(row.spec.mission.deadlineAt), now + pollMaxMs);
714
+ else
715
+ nextDue = now + pollMinMs;
716
+ const snapshot = { ...row.snapshot, backoffMs: pollMaxMs };
717
+ this.store.transaction(tx => tx.updateWatch(watchId, { snapshot, nextDueAt: nextDue, health: missing.length > 0 ? 'degraded' : 'healthy' }, now));
718
+ // Delivery-layer health check too (an obligation's dependency.ready attention may likewise hang as unknown)
719
+ await this.checkDeliveryHealth(watchId, now);
720
+ return { inspectionId, watchId, ingested: 0, produced, health: missing.length > 0 ? 'degraded' : 'healthy', lifecycleAfter: 'active' };
721
+ }
722
+ /** Unified attention publishing for hard facts / semantic candidates: truth lands in the outbox first, then publishes via relay managed delivery (I6). */
723
+ async publishEpisodeAttention(watchId, row, episodeId, reasonCode, summary, now, probability) {
724
+ const envelopeId = newId('att');
725
+ const envelope = {
726
+ schemaVersion: 1,
727
+ envelopeId,
728
+ episodeId,
729
+ episodeRevision: 1,
730
+ watchId,
731
+ generation: row.generation,
732
+ missionRevision: row.missionRevision,
733
+ controlRevision: row.controlRevision,
734
+ ownerBindingEpoch: row.ownerBindingEpoch,
735
+ target: row.spec.target,
736
+ reasonCode: reasonCode,
737
+ summary: probability !== undefined ? `${summary} (jev p=${probability.toFixed(2)})` : summary,
738
+ evidenceRefs: [],
739
+ requiredNextStep: 'inspect-current-watch-before-acting',
740
+ occurredAt: new Date(now).toISOString(),
741
+ validUntil: new Date(now + (row.spec.policy.attentionTtlMs || DEFAULT_ATTENTION_TTL_MS)).toISOString()
742
+ };
743
+ const bytes = Buffer.from(JSON.stringify(envelope), 'utf8');
744
+ const validUntilMs = now + (row.spec.policy.attentionTtlMs || DEFAULT_ATTENTION_TTL_MS);
745
+ const relayReady = !!this.delivery;
746
+ let transport = relayReady ? 'relay-managed' : 'local-display';
747
+ let relayError;
748
+ this.store.transaction(tx => {
749
+ if (tx.getOutbox(envelopeId))
750
+ return;
751
+ tx.insertOutbox({
752
+ eventId: envelopeId, watchId, episodeId, generation: row.generation, eventType: 'watcher.attention.v1',
753
+ eventBytes: bytes, eventDigest: digestJson(envelope),
754
+ validUntil: validUntilMs,
755
+ admission: relayReady ? 'pending' : 'empty-audience',
756
+ admissionJson: relayReady
757
+ ? { transport: 'relay-managed', pending: true }
758
+ : { transport: 'local-display', note: 'relay managed path not ready; displayed locally (widget/notify); no auto wake (I05/I21)' },
759
+ now
760
+ });
761
+ });
762
+ if (relayReady) {
763
+ // Per-watch scope (design 9.6). Publishing is blocked while a scope advance for this
764
+ // watch is unconfirmed (design 7.7): the old scope must not publish, the new one
765
+ // needs a relay receipt first.
766
+ const scope = await this.ensureRelayScope(watchId, now);
767
+ const r = scope.ok
768
+ ? await this.publishAttention(envelopeId, bytes, now, scope.scope)
769
+ : this.deferAttention(envelopeId, scope.reason, now);
770
+ if (!r.ok) {
771
+ // Delivery failure made loud : truth already landed in outbox (admission=unknown),
772
+ // but a broken channel must be visible -- otherwise the toast still fires while the wake leg is dead (truth layer and delivery layer silently split-brain)
773
+ transport = 'relay-failed';
774
+ relayError = r.error;
775
+ this.degrade(watchId, `relay publish failed: ${r.error}`, now);
776
+ }
777
+ }
778
+ // Notification hook: regardless of transport, broadcast to the display layer when a fact needs host attention (toast/widget; relay wake takes a separate managed path)
779
+ try {
780
+ this.onAttention?.({
781
+ watchId,
782
+ ownerSession: row.ownerSession,
783
+ episodeId,
784
+ reasonCode,
785
+ summary,
786
+ transport,
787
+ relayError,
788
+ validUntil: validUntilMs
789
+ });
790
+ }
791
+ catch { /* display-layer failure does not affect the engine */ }
792
+ }
793
+ /**
794
+ * Publishes an attention through the relay managed path; only advances admission, never the
795
+ * frozen truth (I06). The scope used is recorded in admission_json so a retry re-sends the
796
+ * identical (event, options) pair. Never throws: returns status for loud failure handling.
797
+ */
798
+ async publishAttention(eventId, bytes, now, scope) {
799
+ if (scope) {
800
+ this.store.transaction(tx => tx.mergeOutboxAdmissionJson(eventId, {
801
+ scope: { scopeId: scope.scopeId, revision: scope.revision }, deferred: null
802
+ }, now));
803
+ }
804
+ try {
805
+ const receipt = await this.delivery.publish(new Uint8Array(bytes), scope);
806
+ const admission = receipt.sourceState === 'captured'
807
+ ? (receipt.routes.some(r => r.admission === 'accepted') ? 'source-staged' : 'empty-audience')
808
+ : receipt.sourceState === 'empty_audience' ? 'empty-audience'
809
+ : receipt.sourceState === 'rejected' ? 'rejected'
810
+ : 'unknown';
811
+ this.store.transaction(tx => {
812
+ const prev = tx.getOutbox(eventId)?.admissionJson ?? {};
813
+ tx.setOutboxAdmission(eventId, admission, { ...prev, receipt: receipt, error: null }, now);
814
+ });
815
+ return { ok: true };
816
+ }
817
+ catch (e) {
818
+ const error = e instanceof Error ? e.message : String(e);
819
+ const code = e.code;
820
+ this.store.transaction(tx => {
821
+ const prev = tx.getOutbox(eventId)?.admissionJson ?? {};
822
+ // 'cancelled' = the event id was tombstoned by a withdraw before capture (I20):
823
+ // that is an authoritative negative, not an unknown admission.
824
+ tx.setOutboxAdmission(eventId, code === 'cancelled' ? 'rejected' : 'unknown', { ...prev, error, errorCode: code ?? null }, now);
825
+ });
826
+ return { ok: false, error };
827
+ }
828
+ }
829
+ /** Records that an attention could not be published yet (scope control unconfirmed). */
830
+ deferAttention(eventId, reason, now) {
831
+ this.store.transaction(tx => tx.mergeOutboxAdmissionJson(eventId, { deferred: reason }, now));
832
+ return { ok: false, error: reason };
833
+ }
834
+ /**
835
+ * Returns the watch's relay scope, creating it on first use (expectedRevision=0 ->
836
+ * nextRevision=1, design 9.6). Unconfirmed control ops for the watch are drained first;
837
+ * if any remain open, or the scope is not active, publishing is blocked (design 7.7).
838
+ * scopeId = scope-<watchId>-g<generation>-e<bindingEpoch>: a generation or owner-binding
839
+ * change yields a new scope, and the old one is never reused.
840
+ */
841
+ async ensureRelayScope(watchId, now) {
842
+ await this.drainControl(watchId);
843
+ const open = this.store.transaction(tx => tx.listOpenControlOutbox(watchId));
844
+ if (open.length > 0) {
845
+ return { ok: false, reason: `scope control ${open[0].operationId} unconfirmed (${open[0].status}); publish deferred` };
846
+ }
847
+ const row = this.store.transaction(tx => tx.getWatchRow(watchId));
848
+ if (!row)
849
+ return { ok: false, reason: 'watch not found' };
850
+ const scopeId = `scope-${watchId}-g${row.generation}-e${row.ownerBindingEpoch}`;
851
+ const current = row.snapshot.relayScope;
852
+ if (current && current.scopeId === scopeId) {
853
+ return current.state === 'active'
854
+ ? { ok: true, scope: { scopeId, revision: current.revision } }
855
+ : { ok: false, reason: `relay scope ${current.state}` };
856
+ }
857
+ // Create: durable intent first, then the relay call.
858
+ const operationId = newId('ctl');
859
+ this.store.transaction(tx => {
860
+ tx.insertControlOutbox({ operationId, watchId, scopeId, expectedRevision: 0, nextRevision: 1, requestedState: 'active', now });
861
+ const fresh = tx.getWatchRow(watchId);
862
+ if (fresh) {
863
+ tx.updateWatch(watchId, {
864
+ snapshot: { ...fresh.snapshot, relayScope: { scopeId, revision: 1, state: 'active' } }
865
+ }, now);
866
+ }
867
+ });
868
+ await this.drainControl(watchId);
869
+ const stillOpen = this.store.transaction(tx => tx.listOpenControlOutbox(watchId));
870
+ if (stillOpen.length > 0) {
871
+ return { ok: false, reason: `scope create ${operationId} unconfirmed; publish deferred` };
872
+ }
873
+ return { ok: true, scope: { scopeId, revision: 1 } };
874
+ }
875
+ /**
876
+ * Drains the durable control outbox (design 7.3 T6 / 7.7): scope advances FIFO per watch,
877
+ * then pending event withdraws. Each op is retried under its original operationId; the
878
+ * relay dedupes by operationId + request digest. Deterministic relay refusals are recorded
879
+ * as rejected (no retry); anything else stays unknown and is retried on the next sweep.
880
+ */
881
+ async drainControl(watchId) {
882
+ const delivery = this.delivery;
883
+ if (!delivery)
884
+ return;
885
+ const ops = this.store.transaction(tx => tx.listOpenControlOutbox(watchId));
886
+ const blocked = new Set();
887
+ for (const op of ops) {
888
+ if (blocked.has(op.scopeId))
889
+ continue; // FIFO per scope
890
+ try {
891
+ const result = await delivery.advanceScope(op.operationId, op.scopeId, op.expectedRevision, op.nextRevision, op.requestedState);
892
+ this.store.transaction(tx => tx.completeControlOutbox(op.operationId, 'source-applied', { relay: result }));
893
+ }
894
+ catch (e) {
895
+ const code = e.code ?? null;
896
+ const error = e instanceof Error ? e.message : String(e);
897
+ const deterministic = code !== null && DETERMINISTIC_RELAY_CODES.has(code);
898
+ this.store.transaction(tx => tx.completeControlOutbox(op.operationId, deterministic ? 'rejected' : 'unknown', { error, code }));
899
+ blocked.add(op.scopeId);
900
+ }
901
+ }
902
+ const withdraws = this.store.transaction(tx => tx.listOutboxAwaitingWithdraw(64))
903
+ .filter(o => watchId === undefined || o.watchId === watchId);
904
+ for (const o of withdraws) {
905
+ const w = o.admissionJson.withdraw;
906
+ if (!w)
907
+ continue;
908
+ try {
909
+ const result = await delivery.withdraw(w.operationId, o.eventId, w.reason);
910
+ this.store.transaction(tx => tx.mergeOutboxAdmissionJson(o.eventId, {
911
+ withdraw: { ...w, result: result, error: null }
912
+ }, this.clock.wallNow()));
913
+ }
914
+ catch (e) {
915
+ const error = e instanceof Error ? e.message : String(e);
916
+ this.store.transaction(tx => tx.mergeOutboxAdmissionJson(o.eventId, {
917
+ withdraw: { ...w, result: null, error }
918
+ }, this.clock.wallNow()));
919
+ }
920
+ }
921
+ }
922
+ /**
923
+ * Delivery health: for attentions that are unknown (publish failed)
924
+ * or deferred (scope control unconfirmed) while the episode is still open, each sweep
925
+ * (a) retries once if the envelope is still valid, re-sending the same (event, options);
926
+ * (b) otherwise only reports loudly (the host must inspect).
927
+ * Withdrawn events are never re-published; an event whose recorded scope has since
928
+ * advanced is fenced and marked rejected instead of being re-sent under a new scope.
929
+ */
930
+ async checkDeliveryHealth(watchId, now) {
931
+ if (!this.delivery)
932
+ return;
933
+ const pending = this.store.transaction(tx => tx.listOutboxByWatch(watchId, 20))
934
+ .filter(o => o.eventType === 'watcher.attention.v1'
935
+ && (o.admission === 'unknown' || (o.admission === 'pending' && typeof o.admissionJson.deferred === 'string'))
936
+ && !o.admissionJson.withdraw);
937
+ for (const o of pending) {
938
+ if (o.createdAt >= now)
939
+ continue; // attempted this cycle already; retry from the next one
940
+ const epId = o.episodeId;
941
+ if (!epId)
942
+ continue;
943
+ const ep = this.store.transaction(tx => tx.getEpisode(epId));
944
+ if (!ep || (ep.state !== 'open' && ep.state !== 'snoozed' && ep.state !== 'acknowledged'))
945
+ continue;
946
+ if (o.validUntil <= now) {
947
+ this.degrade(watchId, `relay publish failed (envelope expired ${new Date(o.validUntil).toISOString()}); host must inspect`, now);
948
+ continue;
949
+ }
950
+ const recorded = o.admissionJson.scope;
951
+ let r;
952
+ if (recorded) {
953
+ const live = this.store.transaction(tx => tx.getWatchRow(watchId))?.snapshot.relayScope;
954
+ if (!live || live.scopeId !== recorded.scopeId || live.revision !== recorded.revision) {
955
+ this.store.transaction(tx => {
956
+ const prev = tx.getOutbox(o.eventId)?.admissionJson ?? {};
957
+ tx.setOutboxAdmission(o.eventId, 'rejected', { ...prev, note: 'fenced: scope advanced after first publish attempt; not re-sent' }, now);
958
+ });
959
+ continue;
960
+ }
961
+ r = await this.publishAttention(o.eventId, o.eventBytes, now, recorded);
962
+ }
963
+ else if (typeof o.admissionJson.deferred === 'string') {
964
+ const scope = await this.ensureRelayScope(watchId, now);
965
+ r = scope.ok ? await this.publishAttention(o.eventId, o.eventBytes, now, scope.scope) : this.deferAttention(o.eventId, scope.reason, now);
966
+ }
967
+ else {
968
+ // Legacy row first published under the source-wide scope: same options on retry.
969
+ r = await this.publishAttention(o.eventId, o.eventBytes, now);
970
+ }
971
+ if (!r.ok)
972
+ this.degrade(watchId, `relay publish failed: ${r.error}`, now);
973
+ }
974
+ }
975
+ /** Terminal-state watch auto-close: supersede open episodes -> superseded, lifecycle -> closed (does not touch controlRevision CAS semantics). */
976
+ autoCloseTerminal(watchId, now) {
977
+ this.store.transaction(tx => {
978
+ const fresh = tx.getWatchRow(watchId);
979
+ if (!fresh || fresh.lifecycle !== 'active')
980
+ return;
981
+ if (!isTerminalTaskState(fresh.snapshot.taskState))
982
+ return;
983
+ tx.supersedeEpisodesOfWatch(watchId, now);
984
+ tx.updateWatch(watchId, { lifecycle: 'closed' }, now);
985
+ });
986
+ }
987
+ degrade(watchId, reason, now) {
988
+ this.store.transaction(tx => {
989
+ const fresh = tx.getWatchRow(watchId);
990
+ if (!fresh)
991
+ return;
992
+ tx.updateWatch(watchId, {
993
+ health: 'degraded',
994
+ snapshot: { ...fresh.snapshot, degradedReason: reason }
995
+ }, now);
996
+ });
997
+ }
998
+ /**
999
+ * Delivery guard: there is a wake the host never confirmed -- episode still open and its
1000
+ * attention admission is in {unknown, pending, publishing} (publish unconfirmed) or =source-staged
1001
+ * and the envelope has not expired (relay delivery in flight) -> returns true, blocking ttlElapsed auto-close.
1002
+ */
1003
+ hasUndeliveredWake(watchId, now) {
1004
+ const rows = this.store.transaction(tx => tx.listOutboxByWatch(watchId, 50))
1005
+ .filter(o => o.eventType === 'watcher.attention.v1'
1006
+ && (o.admission === 'unknown' || o.admission === 'pending' || o.admission === 'publishing'
1007
+ || (o.admission === 'source-staged' && o.validUntil > now)));
1008
+ if (rows.length === 0)
1009
+ return false;
1010
+ const openEpisodes = new Set(this.store.transaction(tx => tx.listEpisodes(watchId, 100))
1011
+ .filter(e => e.state === 'open').map(e => e.episodeId));
1012
+ return rows.some(o => o.episodeId !== null && openEpisodes.has(o.episodeId));
1013
+ }
1014
+ /** slot = (watchId, generation, kind, checkpointId); at most one active episode per slot (design 3.3). */
1015
+ openEpisode(watchId, row, kind, body, now) {
1016
+ const checkpointId = row.spec.mission.checkpointId;
1017
+ return this.store.transaction(tx => {
1018
+ const existing = tx.getActiveEpisodeBySlot(watchId, row.generation, kind, checkpointId);
1019
+ if (existing) {
1020
+ // Repeated evidence for the same episode only updates lastSeen, does not open a new issue
1021
+ const merged = { ...existing.body, lastSeenAt: new Date(now).toISOString() };
1022
+ tx.updateEpisode(existing.episodeId, existing.revision, { body: merged }, now);
1023
+ return null;
1024
+ }
1025
+ const episodeId = newId('ep');
1026
+ const ordinal = tx.nextEpisodeOrdinal(watchId, row.generation, kind, checkpointId);
1027
+ tx.insertEpisode({
1028
+ episodeId, watchId, generation: row.generation, kind, checkpointId, ordinal,
1029
+ body: { ...body, lastSeenAt: new Date(now).toISOString() }, now
1030
+ });
1031
+ return episodeId;
1032
+ });
1033
+ }
1034
+ ensureMonitorEpisode(watchId, row, kind, detail, now) {
1035
+ const opened = this.openEpisode(watchId, row, kind, {
1036
+ reasonCode: kind,
1037
+ detail,
1038
+ firstSeenAt: new Date(now).toISOString()
1039
+ }, now);
1040
+ void opened;
1041
+ }
1042
+ /** Result card: truth = durable outbox (I6: event bytes/ID/expiry frozen); the display copy is persisted separately (I16). */
1043
+ commitResultCard(watchId, row, episodeId, resultId, card, now) {
1044
+ const bytes = Buffer.from(JSON.stringify(card), 'utf8');
1045
+ const validUntil = now + 24 * 3600 * 1000; // initial result display retention 24h (design 9.3)
1046
+ this.store.transaction(tx => {
1047
+ const existing = tx.getOutbox(resultId);
1048
+ if (existing)
1049
+ return;
1050
+ tx.insertOutbox({
1051
+ eventId: resultId,
1052
+ watchId,
1053
+ episodeId,
1054
+ generation: row.generation,
1055
+ eventType: 'watcher.result.v1',
1056
+ eventBytes: bytes,
1057
+ eventDigest: digestJson(card),
1058
+ validUntil,
1059
+ admissionJson: {
1060
+ transport: 'local-display',
1061
+ note: 'relay managed path not negotiated; no auto-resume (I05/I21)'
1062
+ },
1063
+ now
1064
+ });
1065
+ });
1066
+ this.store.writeResultCard(watchId, resultId, card);
1067
+ }
1068
+ /** probes delegation (trusted candidates, design 4.4) */
1069
+ async listProbes(watchId) {
1070
+ const row = this.store.transaction(tx => tx.getWatchRow(watchId));
1071
+ if (!row || row.spec.target.kind !== 'run')
1072
+ return [];
1073
+ const adapter = this.adapters.get(row.spec.target.sourceId);
1074
+ if (!adapter)
1075
+ return [];
1076
+ const basis = {
1077
+ watchId: row.watchId,
1078
+ generation: row.generation,
1079
+ missionRevision: row.missionRevision,
1080
+ controlRevision: row.controlRevision,
1081
+ observationSeq: row.observationSeq,
1082
+ windowDigest: digestJson(row.snapshot)
1083
+ };
1084
+ return adapter.probes(row.spec.target, basis);
1085
+ }
1086
+ async executeProbe(watchId, probeId, signal) {
1087
+ const probes = await this.listProbes(watchId);
1088
+ const probe = probes.find(p => p.probeId === probeId);
1089
+ if (!probe)
1090
+ return [];
1091
+ const row = this.store.transaction(tx => tx.getWatchRow(watchId));
1092
+ if (!row)
1093
+ return [];
1094
+ const adapter = this.adapters.get(row.spec.target.sourceId);
1095
+ if (!adapter)
1096
+ return [];
1097
+ // Re-check revision/expiry before execution (design 4.4)
1098
+ if (Date.parse(probe.expiresAt) < this.clock.wallNow())
1099
+ return [];
1100
+ return adapter.execute(probe, signal ?? new AbortController().signal);
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Projects one identity-checked observation onto the snapshot. Shared by ingest and probe
1105
+ * ingestion so both paths derive the same state from the same evidence. activityAtMs is set
1106
+ * when the observation counts as task activity (status/heartbeat/log_delta) for the silence
1107
+ * rule; validation/artifact observations do not.
1108
+ */
1109
+ export function applyObservationToSnapshot(snapshot, obs, now) {
1110
+ const atMs = Date.parse(obs.observedAt) || now;
1111
+ let next = snapshot;
1112
+ let activityAtMs = null;
1113
+ if (obs.kind === 'status') {
1114
+ const st = statusPayloadToTaskState(obs.payload);
1115
+ next = { ...next, ...st, lastSourceSeq: obs.sourceSeq };
1116
+ activityAtMs = atMs;
1117
+ }
1118
+ else if (obs.kind === 'validation') {
1119
+ const p = (obs.payload ?? {});
1120
+ const checkId = typeof p.checkId === 'string' ? p.checkId : null;
1121
+ if (checkId) {
1122
+ const checks = [...(next.checks ?? [])];
1123
+ const idx = checks.findIndex(c => c.checkId === checkId);
1124
+ const entry = {
1125
+ checkId,
1126
+ outcome: String(p.outcome ?? 'unknown'),
1127
+ artifactDigest: typeof p.artifactDigest === 'string' ? p.artifactDigest : null
1128
+ };
1129
+ if (idx >= 0)
1130
+ checks[idx] = entry;
1131
+ else
1132
+ checks.push(entry);
1133
+ next = { ...next, checks };
1134
+ }
1135
+ }
1136
+ else if (obs.kind === 'artifact_manifest') {
1137
+ const p = (obs.payload ?? {});
1138
+ const arts = Array.isArray(p.artifacts) ? p.artifacts : [];
1139
+ const ids = arts
1140
+ .filter((a) => !!a && typeof a === 'object')
1141
+ .map(a => String(a.artifactId ?? ''))
1142
+ .filter(x => x !== '');
1143
+ next = { ...next, artifactIds: ids };
1144
+ }
1145
+ else if (obs.kind === 'heartbeat') {
1146
+ activityAtMs = atMs;
1147
+ }
1148
+ else if (obs.kind === 'log_delta') {
1149
+ const p = (obs.payload ?? {});
1150
+ const deltaLines = Array.isArray(p.lines)
1151
+ ? p.lines.filter((l) => typeof l === 'string')
1152
+ : [];
1153
+ if (deltaLines.length > 0) {
1154
+ next = { ...next, tailLines: [...(next.tailLines ?? []), ...deltaLines].slice(-20) };
1155
+ }
1156
+ activityAtMs = atMs;
1157
+ }
1158
+ if (atMs > (next.lastObservedAtMs ?? 0)) {
1159
+ next = { ...next, lastObservedAtMs: atMs };
1160
+ }
1161
+ return { snapshot: next, activityAtMs };
1162
+ }