@zhuxixi/pi-agent-board 0.6.2 → 0.8.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/CHANGELOG.md +43 -0
- package/README.md +6 -3
- package/VERIFY.md +2 -1
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +3 -1
- package/docs/superpowers/plans/2026-09-08-issue-11-attach-runtime-desync-heal.md +917 -0
- package/docs/superpowers/plans/2026-09-09-harden-runner-architecture.md +603 -0
- package/docs/superpowers/plans/2026-09-09-single-writer-completion.md +252 -0
- package/docs/superpowers/plans/2026-09-10-reader-consistency.md +115 -0
- package/docs/superpowers/plans/2026-09-14-attach-cursor-dectcem-gate.md +469 -0
- package/docs/superpowers/plans/2026-09-14-attach-snapshot.md +92 -0
- package/docs/superpowers/plans/2026-09-14-host-meta-orphan-lock.md +771 -0
- package/docs/superpowers/plans/2026-09-14-issue-106-terminal-frame-cognition.md +299 -0
- package/docs/superpowers/plans/2026-09-14-issue-113-foreground-preview-race.md +609 -0
- package/docs/superpowers/plans/2026-09-14-terminal-model.md +145 -0
- package/docs/superpowers/plans/2026-09-15-coordinator-pipe-root-normalize.md +224 -0
- package/docs/superpowers/plans/2026-09-15-lease-publish-eprem-reclaim.md +341 -0
- package/docs/superpowers/plans/2026-09-18-detach-anchor-reporter-endpoint.md +875 -0
- package/docs/superpowers/plans/2026-09-20-control-lifecycle.md +116 -0
- package/docs/superpowers/plans/2026-09-20-issue-121-perf-gate-out-of-coverage.md +517 -0
- package/docs/superpowers/specs/2026-09-07-issue-11-attach-runtime-desync-heal-design.md +130 -0
- package/docs/superpowers/specs/2026-09-09-harden-runner-architecture-design.md +298 -0
- package/docs/superpowers/specs/2026-09-14-attach-cursor-dectcem-gate-design.md +114 -0
- package/docs/superpowers/specs/2026-09-14-host-meta-orphan-lock-design.md +120 -0
- package/docs/superpowers/specs/2026-09-14-issue-106-terminal-frame-cognition-design.md +146 -0
- package/docs/superpowers/specs/2026-09-14-issue-113-foreground-preview-race-design.md +116 -0
- package/docs/superpowers/specs/2026-09-15-coordinator-pipe-root-normalize-design.md +84 -0
- package/docs/superpowers/specs/2026-09-15-lease-publish-eprem-reclaim-design.md +92 -0
- package/docs/superpowers/specs/2026-09-18-detach-anchor-reporter-endpoint-design.md +123 -0
- package/docs/superpowers/specs/2026-09-20-issue-121-perf-gate-out-of-coverage-design.md +204 -0
- package/package.json +3 -2
- package/runner/job-runner-legacy.mjs +68 -0
- package/runner/job-runner.mjs +371 -67
- package/runner/pty-runner-legacy.mjs +50 -0
- package/runner/pty-runner.mjs +685 -58
- package/runner/state-coordinator.mjs +429 -0
- package/runner/state-runner.mjs +90 -15
- package/scripts/run-perf-gate.mjs +40 -0
- package/src/commands/agent-board.ts +8 -8
- package/src/commands/attach-flow.ts +5 -5
- package/src/commands/bg.ts +2 -1
- package/src/core/control-protocol.mjs +482 -0
- package/src/core/coordinator-client.mjs +313 -0
- package/src/core/coordinator-journal.mjs +282 -0
- package/src/core/coordinator-protocol.mjs +12 -0
- package/src/core/editor-state-reporter.mjs +11 -1
- package/src/core/foreground-preview-cache.mjs +117 -0
- package/src/core/host-protocol.mjs +24 -0
- package/src/core/launch.mjs +15 -0
- package/src/core/locks.mjs +68 -14
- package/src/core/paths.mjs +48 -0
- package/src/core/pid.mjs +32 -1
- package/src/core/pty-attach-jiggle-controller.mjs +83 -6
- package/src/core/pty-attach-reconnect.mjs +13 -6
- package/src/core/pty-attach-render.mjs +50 -0
- package/src/core/state-commands.mjs +699 -0
- package/src/core/status-consistency.mjs +98 -0
- package/src/core/store.mjs +59 -13
- package/src/core/terminal-attach-client.mjs +803 -0
- package/src/core/terminal-attach-protocol.mjs +252 -0
- package/src/core/terminal-model.mjs +222 -0
- package/src/core/terminal-snapshot.mjs +440 -0
- package/src/core/types.mjs +2 -0
- package/src/index.ts +12 -4
- package/src/runtime/service.mjs +694 -121
- package/src/ui/dashboard.ts +67 -92
- package/src/ui/pty-attach.ts +298 -72
- package/src/core/pty-input.mjs +0 -47
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision layer for View State Coordinator commands (issue #91, spec D3).
|
|
3
|
+
*
|
|
4
|
+
* This module is the single source of truth for "which semantic-state mutations
|
|
5
|
+
* are allowed". The coordinator process shell (runner/state-coordinator.mjs)
|
|
6
|
+
* owns all side effects — journal, socket, file writes — and calls into this
|
|
7
|
+
* module for every decision. No fs, no net, no Date.now(): every timestamp is
|
|
8
|
+
* taken from an explicit `now` argument or from command payload fields, so
|
|
9
|
+
* decisions are deterministic and exhaustively unit-testable (same pattern as
|
|
10
|
+
* host-coordination.mjs from issue #70).
|
|
11
|
+
*
|
|
12
|
+
* Delegation contract (rules are never copied here):
|
|
13
|
+
* - `auto_state_classified` → auto-state.mjs applyAutoStateToViewState/Status
|
|
14
|
+
* run on deep clones; changed fields are diffed into field patches.
|
|
15
|
+
* - `run_finalized` → events.mjs finalizeRun + projectViewState run on a deep
|
|
16
|
+
* clone of the on-disk status; diffs produce the status/state patches.
|
|
17
|
+
* - `run_progress` / `followup_started` → payload.statusPatch merges onto the
|
|
18
|
+
* status clone, then projectViewState recomputes the state side (shared
|
|
19
|
+
* applyStatusProjection helper).
|
|
20
|
+
* - `reconcile_finalize` → stamps finalizeRun's exact field names on the
|
|
21
|
+
* status (endedAt/exitCode/processState/pid) but does NOT run finalizeRun:
|
|
22
|
+
* the reconciler's explicitly observed semanticState governs, and a full
|
|
23
|
+
* finalize would recompute it from a possibly stale preview.
|
|
24
|
+
*
|
|
25
|
+
* Reject reasons: "unknown_view" | "revision_conflict" | "stale_run" |
|
|
26
|
+
* "manual_fence" | "busy" | "no_change" | "field_not_allowed".
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
applyAutoStateToStatus,
|
|
30
|
+
applyAutoStateToViewState,
|
|
31
|
+
isManualCompletion,
|
|
32
|
+
} from "./auto-state.mjs";
|
|
33
|
+
import { finalizeRun, projectViewState } from "./events.mjs";
|
|
34
|
+
|
|
35
|
+
/** Command kinds accepted by the View State Coordinator (issue #91 scope). */
|
|
36
|
+
export const STATE_COMMAND_KINDS = Object.freeze([
|
|
37
|
+
"mark_completed",
|
|
38
|
+
"auto_state_classified",
|
|
39
|
+
"run_finalized",
|
|
40
|
+
"mark_queued",
|
|
41
|
+
"run_started",
|
|
42
|
+
"run_progress",
|
|
43
|
+
"reconcile_finalize",
|
|
44
|
+
"host_run_failed",
|
|
45
|
+
"archive_view",
|
|
46
|
+
"adopt_session",
|
|
47
|
+
"sync_foreground",
|
|
48
|
+
"plan_ready",
|
|
49
|
+
"followup_started",
|
|
50
|
+
"patch_fields",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Kinds the coordinator applies WITHOUT journaling (plan D3/Task-3 decision:
|
|
55
|
+
* run_progress is a periodic self-healing snapshot — ~4 writes/sec/run would
|
|
56
|
+
* bloat the journal unboundedly; a lost beat is overwritten by the next one).
|
|
57
|
+
* Transient commands still materialize and bump materializedRevision, but they
|
|
58
|
+
* are not replayed and not deduped.
|
|
59
|
+
*/
|
|
60
|
+
export const TRANSIENT_KINDS = Object.freeze(["run_progress"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Decision-layer reject reasons: journaled, authoritative coordinator verdicts
|
|
64
|
+
* with NO recovery semantics (a retry would be decided the same way). The
|
|
65
|
+
* complement — transport ambiguity ("timeout"/"connection_reset"/
|
|
66
|
+
* "connection_failed"/"coordinator_unavailable") — is the only class where
|
|
67
|
+
* "outcome unknown, replay will recover" diagnostics are honest (issue #91
|
|
68
|
+
* hygiene). validateCommand envelope errors are first-party programmer errors
|
|
69
|
+
* and are not in this set.
|
|
70
|
+
*/
|
|
71
|
+
export const DECIDED_REJECT_REASONS = Object.freeze(new Set([
|
|
72
|
+
"manual_fence",
|
|
73
|
+
"stale_run",
|
|
74
|
+
"no_change",
|
|
75
|
+
"busy",
|
|
76
|
+
"revision_conflict",
|
|
77
|
+
"unknown_view",
|
|
78
|
+
"unknown_kind",
|
|
79
|
+
"field_not_allowed",
|
|
80
|
+
]));
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Diagnostic fields for a non-applied command result (issue #91 hygiene):
|
|
84
|
+
* decided rejects are info-level skips — the coordinator's verdict is
|
|
85
|
+
* authoritative, so "outcome unknown, replay will recover" would be a lie.
|
|
86
|
+
* Transport ambiguity keeps the warn with the caller's recovery hint.
|
|
87
|
+
* Pure: shapes only, no I/O.
|
|
88
|
+
* @param {string} code diagnostic code stem (e.g. "run_started")
|
|
89
|
+
* @param {string} label human phrase for the message (e.g. "Run bootstrap")
|
|
90
|
+
* @param {string|null} reason rejected reason from the command result
|
|
91
|
+
* @param {string} ambiguousTail recovery hint appended ONLY for ambiguous reasons
|
|
92
|
+
* @returns {{ level: "info"|"warn", code: string, message: string }}
|
|
93
|
+
*/
|
|
94
|
+
export function commandRejectDiagnostic(code, label, reason, ambiguousTail) {
|
|
95
|
+
const decided = reason != null && DECIDED_REJECT_REASONS.has(reason);
|
|
96
|
+
return decided
|
|
97
|
+
? { level: "info", code: `${code}_skipped`, message: `${label} skipped by the coordinator (${reason}); its decision is authoritative` }
|
|
98
|
+
: { level: "warn", code: `${code}_ambiguous`, message: `${label} outcome unknown (${reason}); if the command was journaled, coordinator replay will recover it; ${ambiguousTail}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Per-source whitelist for `patch_fields` (metadata/evidence-mirror merges).
|
|
103
|
+
* Anything not listed here is rejected with "field_not_allowed" — semantic
|
|
104
|
+
* fields must travel through their dedicated kinds so the guard table in the
|
|
105
|
+
* plan stays exhaustive.
|
|
106
|
+
* @typedef {{ state: string[], status: string[] }} PatchableFields
|
|
107
|
+
* @type {Record<string, PatchableFields>}
|
|
108
|
+
*/
|
|
109
|
+
export const PATCHABLE_FIELDS = Object.freeze({
|
|
110
|
+
// summary/latestAssistantPreview: the post-exit model-summary persist routes
|
|
111
|
+
// through patch_fields (controller ruling R1 — plan oversight, Task 3).
|
|
112
|
+
"job-runner": Object.freeze({ state: Object.freeze(["review", "evidenceSummary", "summary", "latestAssistantPreview"]), status: Object.freeze(["evidenceSummary", "summary", "latestAssistantPreview"]) }),
|
|
113
|
+
"state-runner": Object.freeze({ state: Object.freeze(["review", "evidenceSummary"]), status: Object.freeze(["evidenceSummary"]) }),
|
|
114
|
+
"service": Object.freeze({ state: Object.freeze(["lastVisitedAt"]), status: Object.freeze([]) }),
|
|
115
|
+
// lastVisitedAt (markVisited): visiting is a user action, so it routes as
|
|
116
|
+
// dashboard-user — the manual fence only fences non-human sources, and
|
|
117
|
+
// legacy stamped lastVisitedAt unconditionally (visit-recency tracking
|
|
118
|
+
// must keep working on manually-completed rows).
|
|
119
|
+
"dashboard-user": Object.freeze({ state: Object.freeze(["lastVisitedAt"]), status: Object.freeze([]) }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** Who may originate a state command. Non-human sources are fenced by manual completions. */
|
|
123
|
+
export const COMMAND_SOURCES = Object.freeze([
|
|
124
|
+
"dashboard-user",
|
|
125
|
+
"service",
|
|
126
|
+
"job-runner",
|
|
127
|
+
"state-runner",
|
|
128
|
+
"pty-runner",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A semantic-state mutation request routed through the coordinator.
|
|
133
|
+
* @typedef {Object} StateCommand
|
|
134
|
+
* @property {"state_command"} type
|
|
135
|
+
* @property {string} commandId Stable id — journal replays return the original result.
|
|
136
|
+
* @property {string} viewId
|
|
137
|
+
* @property {string} [runId] Active run this command belongs to (stale-run fenced).
|
|
138
|
+
* @property {typeof COMMAND_SOURCES[number]} source
|
|
139
|
+
* @property {number|null} [expectedRevision] Optimistic concurrency on materializedRevision.
|
|
140
|
+
* @property {typeof STATE_COMMAND_KINDS[number]} kind
|
|
141
|
+
* @property {Record<string, unknown>} payload Kind-specific; validated per kind.
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate the command envelope plus kind-specific payload presence. Pure.
|
|
146
|
+
* @param {any} raw
|
|
147
|
+
* @returns {{ ok: true, command: object } | { ok: false, error: string }}
|
|
148
|
+
*/
|
|
149
|
+
export function validateCommand(raw) {
|
|
150
|
+
if (!raw || raw.type !== "state_command") return { ok: false, error: "bad_type" };
|
|
151
|
+
// Transient kinds have no idempotency semantics — the shell neither dedupes
|
|
152
|
+
// nor replays them, so commandId is optional there (echoed in the reply only).
|
|
153
|
+
if ((typeof raw.commandId !== "string" || !raw.commandId) && !TRANSIENT_KINDS.includes(raw.kind)) {
|
|
154
|
+
return { ok: false, error: "missing_commandId" };
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw.viewId !== "string" || !raw.viewId) return { ok: false, error: "missing_viewId" };
|
|
157
|
+
if (!STATE_COMMAND_KINDS.includes(raw.kind)) return { ok: false, error: "unknown_kind" };
|
|
158
|
+
if (!COMMAND_SOURCES.includes(raw.source)) return { ok: false, error: "unknown_source" };
|
|
159
|
+
if (raw.expectedRevision != null && typeof raw.expectedRevision !== "number") return { ok: false, error: "bad_expectedRevision" };
|
|
160
|
+
if (raw.runId != null && typeof raw.runId !== "string") return { ok: false, error: "bad_runId" };
|
|
161
|
+
if (raw.kind === "auto_state_classified") {
|
|
162
|
+
const classification = raw.payload?.classification;
|
|
163
|
+
if (!classification || typeof classification !== "object") return { ok: false, error: "missing_classification" };
|
|
164
|
+
if (typeof classification.classifiedAt !== "number") return { ok: false, error: "bad_classification" };
|
|
165
|
+
}
|
|
166
|
+
if (raw.kind === "run_finalized") {
|
|
167
|
+
if (!raw.payload || typeof raw.payload !== "object") return { ok: false, error: "missing_payload" };
|
|
168
|
+
if (typeof raw.payload.exitCode !== "number" && raw.payload.exitCode !== null) return { ok: false, error: "missing_exitCode" };
|
|
169
|
+
if (raw.payload.endedAt != null && typeof raw.payload.endedAt !== "number") return { ok: false, error: "bad_endedAt" };
|
|
170
|
+
if (raw.payload.lastAgentActivityAt != null && typeof raw.payload.lastAgentActivityAt !== "number") return { ok: false, error: "bad_lastAgentActivityAt" };
|
|
171
|
+
if (raw.payload.stoppedByUser != null && typeof raw.payload.stoppedByUser !== "boolean") return { ok: false, error: "bad_stoppedByUser" };
|
|
172
|
+
if (raw.payload.stopReason != null && typeof raw.payload.stopReason !== "string") return { ok: false, error: "bad_stopReason" };
|
|
173
|
+
}
|
|
174
|
+
switch (raw.kind) {
|
|
175
|
+
case "mark_queued":
|
|
176
|
+
// runId may be null (PTY host launch pins no run — legacy
|
|
177
|
+
// markQueued(id, null)); the key must be present, and when non-null
|
|
178
|
+
// it must be a non-empty string.
|
|
179
|
+
if (!raw.payload || !("runId" in raw.payload)) return { ok: false, error: "missing_runId" };
|
|
180
|
+
if (raw.payload.runId != null && (typeof raw.payload.runId !== "string" || !raw.payload.runId)) return { ok: false, error: "missing_runId" };
|
|
181
|
+
break;
|
|
182
|
+
case "run_started": {
|
|
183
|
+
// Non-empty string (not just != null): a degenerate runId passes the
|
|
184
|
+
// null guard, then the statusRunId binding silently drops the status
|
|
185
|
+
// half while the ack still says "applied" (Task 2 review P2-A).
|
|
186
|
+
if (typeof raw.runId !== "string" || !raw.runId) return { ok: false, error: "missing_runId" };
|
|
187
|
+
const status = raw.payload?.status;
|
|
188
|
+
if (!status || typeof status !== "object") return { ok: false, error: "missing_status" };
|
|
189
|
+
if (typeof status.runId !== "string" || status.runId !== raw.runId) return { ok: false, error: "bad_status" };
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case "run_progress":
|
|
193
|
+
if (!raw.payload || typeof raw.payload.statusPatch !== "object" || raw.payload.statusPatch == null) return { ok: false, error: "missing_statusPatch" };
|
|
194
|
+
break;
|
|
195
|
+
case "followup_started":
|
|
196
|
+
// The command carries NO runId (a null runId skips the generic stale-run
|
|
197
|
+
// guard against the finished parent run); the NEW run's id travels here
|
|
198
|
+
// and governs the state-side currentRunId (ruling R2).
|
|
199
|
+
if (!raw.payload || typeof raw.payload.statusPatch !== "object" || raw.payload.statusPatch == null) return { ok: false, error: "missing_statusPatch" };
|
|
200
|
+
if (typeof raw.payload.newRunId !== "string" || !raw.payload.newRunId) return { ok: false, error: "missing_newRunId" };
|
|
201
|
+
break;
|
|
202
|
+
case "reconcile_finalize": {
|
|
203
|
+
// Project mode (service.mjs dead-runner path): the run's terminal status
|
|
204
|
+
// exists but the row was never materialized from it — the status itself
|
|
205
|
+
// is the verdict, so semanticState/summary are derived, not passed.
|
|
206
|
+
if (raw.payload?.project === true) {
|
|
207
|
+
if (raw.payload.reason != null && typeof raw.payload.reason !== "string") return { ok: false, error: "bad_reason" };
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
const semanticState = raw.payload?.semanticState;
|
|
211
|
+
if (semanticState !== "failed" && semanticState !== "idle") return { ok: false, error: "bad_semanticState" };
|
|
212
|
+
// The reconciler's summary is caller-provided (legacy parity: both
|
|
213
|
+
// service reconcile sites always stamp a summary, ruling R4).
|
|
214
|
+
if (typeof raw.payload?.summary !== "string" || !raw.payload.summary) return { ok: false, error: "missing_summary" };
|
|
215
|
+
if (raw.payload.reason != null && typeof raw.payload.reason !== "string") return { ok: false, error: "bad_reason" };
|
|
216
|
+
if (raw.payload.exitCode != null && typeof raw.payload.exitCode !== "number") return { ok: false, error: "bad_exitCode" };
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "host_run_failed":
|
|
220
|
+
if (raw.payload?.error != null && typeof raw.payload.error !== "string") return { ok: false, error: "bad_error" };
|
|
221
|
+
if (raw.payload?.exitCode != null && typeof raw.payload.exitCode !== "number") return { ok: false, error: "bad_exitCode" };
|
|
222
|
+
break;
|
|
223
|
+
case "sync_foreground":
|
|
224
|
+
if (!raw.payload?.projection || typeof raw.payload.projection !== "object") return { ok: false, error: "missing_projection" };
|
|
225
|
+
break;
|
|
226
|
+
case "plan_ready":
|
|
227
|
+
// The producer (job-runner's plan-ready pass) fires post-finalization and
|
|
228
|
+
// re-points the row at the plan-producing run (ruling R3).
|
|
229
|
+
if (raw.payload?.question != null && typeof raw.payload.question !== "string") return { ok: false, error: "bad_question" };
|
|
230
|
+
if (typeof raw.payload?.runId !== "string" || !raw.payload.runId) return { ok: false, error: "missing_runId" };
|
|
231
|
+
break;
|
|
232
|
+
case "patch_fields": {
|
|
233
|
+
const hasState = raw.payload?.state != null && typeof raw.payload.state === "object";
|
|
234
|
+
const hasStatus = raw.payload?.status != null && typeof raw.payload.status === "object";
|
|
235
|
+
if (!hasState && !hasStatus) return { ok: false, error: "missing_payload" };
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, command: raw };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Decide whether a command may mutate the view state, and which field patches
|
|
244
|
+
* to apply. Pure: deep-clones inputs before delegating, never mutates arguments,
|
|
245
|
+
* never touches the clock (pass `now` for wall-clock timestamps; falls back to
|
|
246
|
+
* payload-provided timestamps for determinism).
|
|
247
|
+
*
|
|
248
|
+
* @param {object} command
|
|
249
|
+
* @param {object|null} currentState ViewState as currently materialized (state.json).
|
|
250
|
+
* @param {object|null} currentStatus RunStatus for command.runId as currently
|
|
251
|
+
* materialized (status.json), when a status file exists; null otherwise.
|
|
252
|
+
* @param {number} [now] Wall-clock epoch ms supplied by the coordinator shell.
|
|
253
|
+
* @returns {{ action: "apply", mutate: { state?: object, status?: object }, reason: string }
|
|
254
|
+
* | { action: "reject", reason: string }}
|
|
255
|
+
* `mutate.state`/`mutate.status` are sparse field patches — only fields whose
|
|
256
|
+
* value actually changed. The coordinator merges them onto the materialized
|
|
257
|
+
* files and stamps the shared materializedRevision.
|
|
258
|
+
*/
|
|
259
|
+
export function decideStateTransition(command, currentState, currentStatus, now = undefined) {
|
|
260
|
+
if (!currentState) return reject("unknown_view");
|
|
261
|
+
if (command.expectedRevision != null && command.expectedRevision !== (currentState.materializedRevision ?? 0)) {
|
|
262
|
+
return reject("revision_conflict");
|
|
263
|
+
}
|
|
264
|
+
// Generic stale-run guard: a command for a run other than the row's current
|
|
265
|
+
// run is stale. run_started is EXEMPT — it carries its own liveness-scoped
|
|
266
|
+
// guard below (launch-order inversion, cold-start race): the runner is
|
|
267
|
+
// authoritative that its run just started, so a bootstrap landing on a
|
|
268
|
+
// not-alive row applies and re-pins currentRunId. Only two SIMULTANEOUS live
|
|
269
|
+
// runs on one view are the hazard this guard exists for.
|
|
270
|
+
if (
|
|
271
|
+
command.kind !== "run_started" &&
|
|
272
|
+
command.runId &&
|
|
273
|
+
currentState.currentRunId &&
|
|
274
|
+
command.runId !== currentState.currentRunId
|
|
275
|
+
) {
|
|
276
|
+
return reject("stale_run");
|
|
277
|
+
}
|
|
278
|
+
// Manual completions are user verdicts: only a human source may act on a
|
|
279
|
+
// fenced row (this is the #46 invariant — late classifications lose).
|
|
280
|
+
if (command.source !== "dashboard-user" && isManualCompletion(currentState)) {
|
|
281
|
+
return reject("manual_fence");
|
|
282
|
+
}
|
|
283
|
+
switch (command.kind) {
|
|
284
|
+
case "mark_completed": {
|
|
285
|
+
if (currentState.processState === "alive") return reject("busy");
|
|
286
|
+
// autoState: null on both artifacts is the manual-completion fence
|
|
287
|
+
// signal that later auto-state commands (and the auto-state rules
|
|
288
|
+
// themselves) key off — see isManualCompletion().
|
|
289
|
+
return {
|
|
290
|
+
action: "apply",
|
|
291
|
+
reason: "manual_completion",
|
|
292
|
+
mutate: {
|
|
293
|
+
state: {
|
|
294
|
+
semanticState: "completed",
|
|
295
|
+
processState: "exited",
|
|
296
|
+
needsInput: false,
|
|
297
|
+
hasError: false,
|
|
298
|
+
question: null,
|
|
299
|
+
pendingQuestions: [],
|
|
300
|
+
error: null,
|
|
301
|
+
autoState: null,
|
|
302
|
+
},
|
|
303
|
+
status: { autoState: null },
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
case "auto_state_classified": {
|
|
308
|
+
const classification = command.payload?.classification;
|
|
309
|
+
const at = now ?? classification?.classifiedAt;
|
|
310
|
+
const stateClone = cloneJson(currentState);
|
|
311
|
+
const statusClone = cloneJson(currentStatus);
|
|
312
|
+
const stateChanged = applyAutoStateToViewState(stateClone, classification, at);
|
|
313
|
+
const statusChanged = statusClone ? applyAutoStateToStatus(statusClone, classification, at) : false;
|
|
314
|
+
if (!stateChanged && !statusChanged) return reject("no_change");
|
|
315
|
+
return { action: "apply", reason: command.kind, mutate: buildPatches(currentState, stateClone, stateChanged, currentStatus, statusClone, statusChanged) };
|
|
316
|
+
}
|
|
317
|
+
case "run_finalized": {
|
|
318
|
+
const payload = command.payload ?? {};
|
|
319
|
+
// Only a live run record for this view's current run may be finalized;
|
|
320
|
+
// anything else is stale (duplicate finalize commands included).
|
|
321
|
+
if (!currentStatus) return reject("stale_run");
|
|
322
|
+
if (currentState.currentRunId !== command.runId) return reject("stale_run");
|
|
323
|
+
if (currentState.processState !== "alive") return reject("stale_run");
|
|
324
|
+
const at = now ?? payload.endedAt ?? 0;
|
|
325
|
+
const statusClone = cloneJson(currentStatus);
|
|
326
|
+
// Overlay fresher fields the throttled on-disk write may lag behind.
|
|
327
|
+
// stopReason must be overlaid onto the status itself: finalizeSemanticState
|
|
328
|
+
// reads status.stopReason (finalizeRun's opts have no stopReason slot), so
|
|
329
|
+
// a fresh "aborted" reported by the runner would otherwise be lost and the
|
|
330
|
+
// run would land on "idle" instead of "failed".
|
|
331
|
+
if (typeof payload.latestAssistantPreview === "string") statusClone.latestAssistantPreview = payload.latestAssistantPreview;
|
|
332
|
+
if (payload.lastAgentActivityAt != null) statusClone.lastAgentActivityAt = payload.lastAgentActivityAt;
|
|
333
|
+
if (payload.stopReason != null) statusClone.stopReason = payload.stopReason;
|
|
334
|
+
finalizeRun(statusClone, {
|
|
335
|
+
exitCode: payload.exitCode,
|
|
336
|
+
stoppedByUser: payload.stoppedByUser,
|
|
337
|
+
stopReason: payload.stopReason,
|
|
338
|
+
openEnded: payload.openEnded,
|
|
339
|
+
}, at);
|
|
340
|
+
const projectedState = projectViewState(statusClone, at, currentState);
|
|
341
|
+
return {
|
|
342
|
+
action: "apply",
|
|
343
|
+
reason: command.kind,
|
|
344
|
+
mutate: {
|
|
345
|
+
state: diffFields(currentState, projectedState),
|
|
346
|
+
status: diffFields(currentStatus, statusClone),
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
case "mark_queued": {
|
|
351
|
+
return {
|
|
352
|
+
action: "apply",
|
|
353
|
+
reason: command.kind,
|
|
354
|
+
mutate: {
|
|
355
|
+
state: {
|
|
356
|
+
currentRunId: command.payload.runId,
|
|
357
|
+
semanticState: "queued",
|
|
358
|
+
processState: "alive",
|
|
359
|
+
summary: "Queued",
|
|
360
|
+
needsInput: false,
|
|
361
|
+
hasError: false,
|
|
362
|
+
question: null,
|
|
363
|
+
pendingQuestions: [],
|
|
364
|
+
error: null,
|
|
365
|
+
autoState: null,
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
case "run_started": {
|
|
371
|
+
// Liveness-scoped stale guard (the generic guard above exempts this
|
|
372
|
+
// kind): only TWO LIVE RUNS on one view are the hazard — a bootstrap for
|
|
373
|
+
// a different runId while this row already runs something else is
|
|
374
|
+
// out-of-order and rejected. When the row is NOT alive, the runner is
|
|
375
|
+
// authoritative that its run just started: apply and re-pin
|
|
376
|
+
// currentRunId. This covers the cold-start race where run_started lands
|
|
377
|
+
// before mark_queued (the runner was spawned first), and re-launches of
|
|
378
|
+
// rows still pointing at the previous run.
|
|
379
|
+
if (
|
|
380
|
+
currentState.processState === "alive" &&
|
|
381
|
+
currentState.currentRunId &&
|
|
382
|
+
currentState.currentRunId !== command.runId
|
|
383
|
+
) {
|
|
384
|
+
return reject("stale_run");
|
|
385
|
+
}
|
|
386
|
+
const statusAfter = cloneJson(command.payload.status);
|
|
387
|
+
return {
|
|
388
|
+
action: "apply",
|
|
389
|
+
reason: command.kind,
|
|
390
|
+
mutate: {
|
|
391
|
+
state: {
|
|
392
|
+
processState: "alive",
|
|
393
|
+
semanticState: "working",
|
|
394
|
+
currentRunId: command.runId,
|
|
395
|
+
},
|
|
396
|
+
status: diffFields(currentStatus, statusAfter),
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
case "run_progress": {
|
|
401
|
+
// Liveness semantics: only the row's current live run may move forward.
|
|
402
|
+
// Late/duplicate progress from a finished run is dropped (next run's
|
|
403
|
+
// progress supersedes it anyway — this kind is transient by design).
|
|
404
|
+
//
|
|
405
|
+
// A missing status file splits two ways (F2, split by the hard-down
|
|
406
|
+
// residual closure): a QUALIFIED beat from the row's current live run —
|
|
407
|
+
// full-shaped patch carrying processState/semanticState and the same
|
|
408
|
+
// runId — bootstraps the file, because the coordinator may have been
|
|
409
|
+
// down through the runner's boot window (mark_queued applied,
|
|
410
|
+
// run_started lost): without the bootstrap the row can never converge
|
|
411
|
+
// (beats and finalize both reject forever). Anything else — exited or
|
|
412
|
+
// re-pointed rows, sparse patches that would materialize an
|
|
413
|
+
// undefined-shaped status — stays stale_run (F2's original exposure).
|
|
414
|
+
if (!currentStatus) {
|
|
415
|
+
// P2 hardening: the bootstrap is keyed on command.runId (it becomes the
|
|
416
|
+
// status-file identity), so a degenerate null==null row match must never
|
|
417
|
+
// bootstrap — the shell would silently drop the status half. The check
|
|
418
|
+
// lives here beside its sibling liveness guards (runId semantics for
|
|
419
|
+
// run_progress are decision-layer; envelope validation stays minimal,
|
|
420
|
+
// matching the generic stale-run guard's decision-side read).
|
|
421
|
+
const rowMatches = typeof command.runId === "string" && command.runId.length > 0
|
|
422
|
+
&& currentState.currentRunId === command.runId
|
|
423
|
+
&& currentState.processState === "alive";
|
|
424
|
+
if (!rowMatches || !beatPatchQualifiesForBootstrap(command)) return reject("stale_run");
|
|
425
|
+
// The patch itself is the base: it is the runner's authoritative
|
|
426
|
+
// full in-memory status, so applyStatusProjection's merge-onto-empty
|
|
427
|
+
// materializes exactly the patch (validated full-shaped above).
|
|
428
|
+
return applyStatusProjection(command, currentState, null, now);
|
|
429
|
+
}
|
|
430
|
+
if (currentState.currentRunId !== command.runId || currentState.processState !== "alive") return reject("stale_run");
|
|
431
|
+
return applyStatusProjection(command, currentState, currentStatus, now);
|
|
432
|
+
}
|
|
433
|
+
case "followup_started": {
|
|
434
|
+
// No liveness guard and no command.runId: the follow-up starts from a
|
|
435
|
+
// just-finalized parent run, so the generic stale-run guard must not fire
|
|
436
|
+
// against it. The NEW run's identity (payload.newRunId) governs the
|
|
437
|
+
// state-side currentRunId regardless of what the status patch carries,
|
|
438
|
+
// and a follow-up starting is definitionally the row running again —
|
|
439
|
+
// pin processState so a sparse bootstrap patch can never materialize
|
|
440
|
+
// an undefined (key-dropping) processState on a re-pointed row.
|
|
441
|
+
const result = applyStatusProjection(command, currentState, currentStatus, now);
|
|
442
|
+
return {
|
|
443
|
+
...result,
|
|
444
|
+
mutate: { ...result.mutate, state: { ...result.mutate.state, currentRunId: command.payload.newRunId, processState: "alive" } },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
case "reconcile_finalize": {
|
|
448
|
+
if (currentState.processState !== "alive") return reject("no_change");
|
|
449
|
+
const at = now ?? 0;
|
|
450
|
+
if (command.payload.project === true) {
|
|
451
|
+
// Project mode: faithfully re-materialize the row from the run's
|
|
452
|
+
// terminal status (projectViewState delegation — never copied rules).
|
|
453
|
+
// A missing status means there is nothing to project — stale.
|
|
454
|
+
if (!currentStatus) return reject("stale_run");
|
|
455
|
+
const projected = projectViewState(cloneJson(currentStatus), at, currentState);
|
|
456
|
+
return { action: "apply", reason: command.kind, mutate: { state: diffFields(currentState, projected) } };
|
|
457
|
+
}
|
|
458
|
+
const failed = command.payload.semanticState === "failed";
|
|
459
|
+
const stateClone = cloneJson(currentState);
|
|
460
|
+
stateClone.semanticState = command.payload.semanticState;
|
|
461
|
+
stateClone.processState = "exited";
|
|
462
|
+
// Derived-field clearing at legacy parity (service.mjs reconcile sites,
|
|
463
|
+
// ruling R4): both legacy branches always stamp these fields.
|
|
464
|
+
stateClone.needsInput = false;
|
|
465
|
+
stateClone.hasError = failed;
|
|
466
|
+
stateClone.question = null;
|
|
467
|
+
stateClone.pendingQuestions = [];
|
|
468
|
+
stateClone.error = command.payload.reason ?? null;
|
|
469
|
+
stateClone.summary = command.payload.summary;
|
|
470
|
+
const mutate = { state: diffFields(currentState, stateClone) };
|
|
471
|
+
if (currentStatus) {
|
|
472
|
+
const statusClone = cloneJson(currentStatus);
|
|
473
|
+
// Same field names finalizeRun stamps, minus the semantic recomputation:
|
|
474
|
+
// the reconciler observed the outcome explicitly and its verdict governs.
|
|
475
|
+
statusClone.endedAt = at;
|
|
476
|
+
statusClone.exitCode = command.payload.exitCode ?? null;
|
|
477
|
+
statusClone.processState = "exited";
|
|
478
|
+
statusClone.pid = null;
|
|
479
|
+
statusClone.semanticState = command.payload.semanticState;
|
|
480
|
+
mutate.status = diffFields(currentStatus, statusClone);
|
|
481
|
+
}
|
|
482
|
+
return { action: "apply", reason: command.kind, mutate };
|
|
483
|
+
}
|
|
484
|
+
case "host_run_failed": {
|
|
485
|
+
// No kind-specific guard: the generic manual_fence above is exactly the
|
|
486
|
+
// PR #1 residual-risk closure — a late host crash must not flip a row
|
|
487
|
+
// the user already completed by hand.
|
|
488
|
+
const message = command.payload?.error ?? "PTY host failed";
|
|
489
|
+
const mutate = {
|
|
490
|
+
state: {
|
|
491
|
+
semanticState: "failed",
|
|
492
|
+
processState: "exited",
|
|
493
|
+
// Legacy parity with markRowFailedDirect (runner/pty-runner-legacy.mjs):
|
|
494
|
+
// both summary and error carry the message, hasError/needsInput are
|
|
495
|
+
// stamped so row rendering and warm-host eviction match the direct era.
|
|
496
|
+
summary: message,
|
|
497
|
+
hasError: true,
|
|
498
|
+
needsInput: false,
|
|
499
|
+
error: command.payload?.error ?? null,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
if (currentStatus) {
|
|
503
|
+
mutate.status = {
|
|
504
|
+
semanticState: "failed",
|
|
505
|
+
processState: "exited",
|
|
506
|
+
error: command.payload?.error ?? null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return { action: "apply", reason: command.kind, mutate };
|
|
510
|
+
}
|
|
511
|
+
case "archive_view": {
|
|
512
|
+
// Busy rows are allowed: archiving a working row stops it (matches the
|
|
513
|
+
// legacy archiveView behavior this kind replaces).
|
|
514
|
+
return {
|
|
515
|
+
action: "apply",
|
|
516
|
+
reason: command.kind,
|
|
517
|
+
mutate: {
|
|
518
|
+
state: {
|
|
519
|
+
semanticState: "stopped",
|
|
520
|
+
processState: "exited",
|
|
521
|
+
needsInput: false,
|
|
522
|
+
hasError: false,
|
|
523
|
+
question: null,
|
|
524
|
+
pendingQuestions: [],
|
|
525
|
+
error: null,
|
|
526
|
+
autoState: null,
|
|
527
|
+
summary: "Stopped",
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
case "adopt_session": {
|
|
533
|
+
if (currentState.processState === "alive") return reject("busy");
|
|
534
|
+
// Derived-field clearing at legacy parity (service.mjs adoptSession reuse
|
|
535
|
+
// path, ruling R4). autoState is intentionally untouched: the legacy
|
|
536
|
+
// adopt path leaves it alone.
|
|
537
|
+
return {
|
|
538
|
+
action: "apply",
|
|
539
|
+
reason: command.kind,
|
|
540
|
+
mutate: {
|
|
541
|
+
state: {
|
|
542
|
+
semanticState: "idle",
|
|
543
|
+
processState: "exited",
|
|
544
|
+
needsInput: false,
|
|
545
|
+
hasError: false,
|
|
546
|
+
question: null,
|
|
547
|
+
pendingQuestions: [],
|
|
548
|
+
error: null,
|
|
549
|
+
summary: "Backgrounded session",
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
case "sync_foreground": {
|
|
555
|
+
// Foreground mirrors never own a background run: the projection is the
|
|
556
|
+
// caller's, but currentRunId stays null regardless of what it carries.
|
|
557
|
+
const stateClone = cloneJson(currentState);
|
|
558
|
+
Object.assign(stateClone, command.payload.projection);
|
|
559
|
+
stateClone.currentRunId = null;
|
|
560
|
+
return { action: "apply", reason: command.kind, mutate: { state: diffFields(currentState, stateClone) } };
|
|
561
|
+
}
|
|
562
|
+
case "plan_ready": {
|
|
563
|
+
// The producer (job-runner's plan-ready pass) fires POST-finalization:
|
|
564
|
+
// the run has exited by the time a plan is ready for approval, so a live
|
|
565
|
+
// row means out-of-order delivery — drop it (ruling R3 inverts the guard).
|
|
566
|
+
if (currentState.processState === "alive") return reject("no_change");
|
|
567
|
+
// Exact legacy parity with runner/job-runner.mjs's plan-ready write.
|
|
568
|
+
return {
|
|
569
|
+
action: "apply",
|
|
570
|
+
reason: command.kind,
|
|
571
|
+
mutate: {
|
|
572
|
+
state: {
|
|
573
|
+
semanticState: "needs_input",
|
|
574
|
+
processState: "exited",
|
|
575
|
+
needsInput: true,
|
|
576
|
+
question: command.payload?.question ?? "Approve this plan?",
|
|
577
|
+
summary: "Plan ready for approval",
|
|
578
|
+
currentRunId: command.payload.runId,
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
case "patch_fields": {
|
|
584
|
+
const allowed = PATCHABLE_FIELDS[command.source];
|
|
585
|
+
if (!allowed) return reject("field_not_allowed");
|
|
586
|
+
const requestedState = command.payload.state ?? {};
|
|
587
|
+
const requestedStatus = command.payload.status ?? {};
|
|
588
|
+
for (const key of Object.keys(requestedState)) {
|
|
589
|
+
if (!allowed.state.includes(key)) return reject("field_not_allowed");
|
|
590
|
+
}
|
|
591
|
+
for (const key of Object.keys(requestedStatus)) {
|
|
592
|
+
if (!allowed.status.includes(key)) return reject("field_not_allowed");
|
|
593
|
+
}
|
|
594
|
+
const stateClone = cloneJson(currentState);
|
|
595
|
+
Object.assign(stateClone, requestedState);
|
|
596
|
+
const statePatch = diffFields(currentState, stateClone);
|
|
597
|
+
let statusPatch;
|
|
598
|
+
if (currentStatus && Object.keys(requestedStatus).length > 0) {
|
|
599
|
+
const statusClone = cloneJson(currentStatus);
|
|
600
|
+
Object.assign(statusClone, requestedStatus);
|
|
601
|
+
statusPatch = diffFields(currentStatus, statusClone);
|
|
602
|
+
}
|
|
603
|
+
if (Object.keys(statePatch).length === 0 && (!statusPatch || Object.keys(statusPatch).length === 0)) {
|
|
604
|
+
return reject("no_change");
|
|
605
|
+
}
|
|
606
|
+
return { action: "apply", reason: command.kind, mutate: statusPatch ? { state: statePatch, status: statusPatch } : { state: statePatch } };
|
|
607
|
+
}
|
|
608
|
+
default:
|
|
609
|
+
// validateCommand already rejects unknown kinds; defensive only.
|
|
610
|
+
return reject("unknown_kind");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** @param {string} reason @returns {{ action: "reject", reason: string }} */
|
|
615
|
+
function reject(reason) {
|
|
616
|
+
return { action: "reject", reason };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** JSON round-trip clone: patches stay JSON-serializable (they are written to disk). */
|
|
620
|
+
function cloneJson(value) {
|
|
621
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Shallow top-level field diff (deep-equality per field via JSON form), so a
|
|
626
|
+
* cloned-but-unchanged nested object never lands in a patch.
|
|
627
|
+
* @param {object|null} before
|
|
628
|
+
* @param {object} after
|
|
629
|
+
*/
|
|
630
|
+
function diffFields(before, after) {
|
|
631
|
+
const patch = {};
|
|
632
|
+
for (const key of Object.keys(after)) {
|
|
633
|
+
if (JSON.stringify(before?.[key]) !== JSON.stringify(after[key])) patch[key] = after[key];
|
|
634
|
+
}
|
|
635
|
+
return patch;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** @param {boolean} stateChanged @param {boolean} statusChanged */
|
|
639
|
+
function buildPatches(currentState, stateClone, stateChanged, currentStatus, statusClone, statusChanged) {
|
|
640
|
+
const mutate = {};
|
|
641
|
+
if (stateChanged) mutate.state = diffFields(currentState, stateClone);
|
|
642
|
+
if (statusChanged) mutate.status = diffFields(currentStatus, statusClone);
|
|
643
|
+
return mutate;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Whether a run_progress beat may bootstrap a missing status file: the patch
|
|
648
|
+
* must be full-shaped — carrying processState and semanticState (so the
|
|
649
|
+
* materialized status never has an undefined shape) and pinned to the same
|
|
650
|
+
* runId as the command (so a foreign run's snapshot can never masquerade as
|
|
651
|
+
* this run's bootstrap). Sparse or mismatched patches stay stale_run.
|
|
652
|
+
* @param {object} command
|
|
653
|
+
*/
|
|
654
|
+
function beatPatchQualifiesForBootstrap(command) {
|
|
655
|
+
const patch = command.payload?.statusPatch;
|
|
656
|
+
return Boolean(
|
|
657
|
+
patch &&
|
|
658
|
+
typeof patch === "object" &&
|
|
659
|
+
typeof patch.processState === "string" &&
|
|
660
|
+
typeof patch.semanticState === "string" &&
|
|
661
|
+
patch.runId === command.runId,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Shared body for the status-patch kinds (`run_progress`, `followup_started`):
|
|
667
|
+
* merge payload.statusPatch onto the materialized status, then let
|
|
668
|
+
* projectViewState recompute the state side — the projection rules live in
|
|
669
|
+
* events.mjs and are never copied here (same delegation contract as
|
|
670
|
+
* run_finalized).
|
|
671
|
+
*
|
|
672
|
+
* A missing status file is tolerated: the patch merges onto an empty object so
|
|
673
|
+
* the coordinator materializes a fresh status from the patch fields (this is
|
|
674
|
+
* the followup_started bootstrap path — a new run's status file does not exist
|
|
675
|
+
* yet). The caller should send enough fields to make that object meaningful.
|
|
676
|
+
*
|
|
677
|
+
* Pure; timestamp comes from `now`, falling back to the patch's
|
|
678
|
+
* lastActivityAt (payload-carried determinism, same rule as run_finalized).
|
|
679
|
+
*
|
|
680
|
+
* @param {object} command
|
|
681
|
+
* @param {object} currentState
|
|
682
|
+
* @param {object|null} currentStatus
|
|
683
|
+
* @param {number|undefined} now
|
|
684
|
+
*/
|
|
685
|
+
function applyStatusProjection(command, currentState, currentStatus, now) {
|
|
686
|
+
const patch = command.payload.statusPatch;
|
|
687
|
+
const at = now ?? patch.lastActivityAt ?? 0;
|
|
688
|
+
const statusClone = cloneJson(currentStatus) ?? {};
|
|
689
|
+
Object.assign(statusClone, patch);
|
|
690
|
+
const projectedState = projectViewState(statusClone, at, currentState);
|
|
691
|
+
return {
|
|
692
|
+
action: "apply",
|
|
693
|
+
reason: command.kind,
|
|
694
|
+
mutate: {
|
|
695
|
+
state: diffFields(currentState, projectedState),
|
|
696
|
+
status: diffFields(currentStatus ?? {}, statusClone),
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
}
|