@oscharko-dev/keiko-contracts 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/bff-wire.d.ts +33 -0
- package/dist/bff-wire.d.ts.map +1 -1
- package/dist/command-runner.d.ts +81 -0
- package/dist/command-runner.d.ts.map +1 -0
- package/dist/command-runner.js +209 -0
- package/dist/container-runtime.d.ts +125 -0
- package/dist/container-runtime.d.ts.map +1 -0
- package/dist/container-runtime.js +287 -0
- package/dist/context-engineering.js +2 -2
- package/dist/discussion-intelligence.d.ts +70 -0
- package/dist/discussion-intelligence.d.ts.map +1 -0
- package/dist/discussion-intelligence.js +440 -0
- package/dist/editor-agent-governance.d.ts +65 -0
- package/dist/editor-agent-governance.d.ts.map +1 -0
- package/dist/editor-agent-governance.js +180 -0
- package/dist/editor-agent.d.ts +29 -1
- package/dist/editor-agent.d.ts.map +1 -1
- package/dist/editor-agent.js +183 -6
- package/dist/editor-builtin-capabilities.d.ts +13 -0
- package/dist/editor-builtin-capabilities.d.ts.map +1 -0
- package/dist/editor-builtin-capabilities.js +135 -0
- package/dist/editor-language-mode-map.d.ts +11 -0
- package/dist/editor-language-mode-map.d.ts.map +1 -0
- package/dist/editor-language-mode-map.js +89 -0
- package/dist/editor-layout.d.ts +37 -0
- package/dist/editor-layout.d.ts.map +1 -1
- package/dist/editor-layout.js +125 -8
- package/dist/editor-workspace-path.d.ts +20 -0
- package/dist/editor-workspace-path.d.ts.map +1 -0
- package/dist/editor-workspace-path.js +133 -0
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/gateway.d.ts +72 -3
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +73 -3
- package/dist/git-commit-intent.d.ts +28 -0
- package/dist/git-commit-intent.d.ts.map +1 -0
- package/dist/git-commit-intent.js +155 -0
- package/dist/git-commit-policy.d.ts +29 -0
- package/dist/git-commit-policy.d.ts.map +1 -0
- package/dist/git-commit-policy.js +173 -0
- package/dist/git-delivery-action-sheet.d.ts +157 -0
- package/dist/git-delivery-action-sheet.d.ts.map +1 -0
- package/dist/git-delivery-action-sheet.js +430 -0
- package/dist/git-delivery-evidence.d.ts +92 -0
- package/dist/git-delivery-evidence.d.ts.map +1 -0
- package/dist/git-delivery-evidence.js +272 -0
- package/dist/git-delivery-policy.d.ts +40 -0
- package/dist/git-delivery-policy.d.ts.map +1 -0
- package/dist/git-delivery-policy.js +183 -0
- package/dist/git-delivery-provider.d.ts +53 -0
- package/dist/git-delivery-provider.d.ts.map +1 -0
- package/dist/git-delivery-provider.js +96 -0
- package/dist/git-delivery.d.ts +202 -0
- package/dist/git-delivery.d.ts.map +1 -0
- package/dist/git-delivery.js +410 -0
- package/dist/git-history.d.ts +27 -0
- package/dist/git-history.d.ts.map +1 -0
- package/dist/git-history.js +73 -0
- package/dist/git-merge.d.ts +67 -0
- package/dist/git-merge.d.ts.map +1 -0
- package/dist/git-merge.js +323 -0
- package/dist/git-pull-request.d.ts +112 -0
- package/dist/git-pull-request.d.ts.map +1 -0
- package/dist/git-pull-request.js +351 -0
- package/dist/git-repository-agent.d.ts +46 -0
- package/dist/git-repository-agent.d.ts.map +1 -0
- package/dist/git-repository-agent.js +198 -0
- package/dist/git-repository-summary.d.ts +54 -0
- package/dist/git-repository-summary.d.ts.map +1 -0
- package/dist/git-repository-summary.js +105 -0
- package/dist/git-repository.d.ts +60 -0
- package/dist/git-repository.d.ts.map +1 -0
- package/dist/git-repository.js +106 -0
- package/dist/git-sync.d.ts +49 -0
- package/dist/git-sync.d.ts.map +1 -0
- package/dist/git-sync.js +110 -0
- package/dist/index.d.ts +63 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -6
- package/dist/lsp-process.d.ts +38 -0
- package/dist/lsp-process.d.ts.map +1 -0
- package/dist/lsp-process.js +103 -0
- package/dist/runtime-capabilities.d.ts +44 -0
- package/dist/runtime-capabilities.d.ts.map +1 -0
- package/dist/runtime-capabilities.js +141 -0
- package/dist/task-workspace.d.ts +302 -0
- package/dist/task-workspace.d.ts.map +1 -0
- package/dist/task-workspace.js +1236 -0
- package/dist/voice-action-intent.d.ts +86 -0
- package/dist/voice-action-intent.d.ts.map +1 -0
- package/dist/voice-action-intent.js +387 -0
- package/dist/voice-playback.d.ts +50 -0
- package/dist/voice-playback.d.ts.map +1 -0
- package/dist/voice-playback.js +240 -0
- package/dist/voice-protocol.d.ts +165 -0
- package/dist/voice-protocol.d.ts.map +1 -0
- package/dist/voice-protocol.js +312 -0
- package/dist/voice-transcript.d.ts +57 -0
- package/dist/voice-transcript.d.ts.map +1 -0
- package/dist/voice-transcript.js +221 -0
- package/package.json +5 -1
- package/dist/conversation-budget.d.ts +0 -37
- package/dist/conversation-budget.d.ts.map +0 -1
- package/dist/conversation-budget.js +0 -97
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
// Public type contracts for the task-scoped isolated workspace domain (Issue #444, Epic #443).
|
|
2
|
+
// Ownership: the task-workspace domain — what a task-scoped isolated workspace IS, how a task binds
|
|
3
|
+
// to it, its lifecycle state machine, drift/recovery semantics, and the read-only vs mutating
|
|
4
|
+
// operation authority. Disjoint from the subsystems it DELEGATES to: it never re-implements Git
|
|
5
|
+
// mutation (owned by git-delivery.ts, #470), editor/runtime context (owned by editor-agent.ts /
|
|
6
|
+
// editor-session.ts, #1491), terminal mutation (keiko-tools terminal policy, ADR-0018), or workspace
|
|
7
|
+
// discovery + path containment (owned by @oscharko-dev/keiko-workspace). The delegation boundary is
|
|
8
|
+
// encoded as data in TASK_WORKSPACE_DELEGATED_SUBSYSTEMS so a second copy of any of those subsystems
|
|
9
|
+
// is structurally prevented (AC4).
|
|
10
|
+
//
|
|
11
|
+
// Leaf-package rules (ADR-0019): pure types, frozen `as const` tables, and pure functions only. No IO,
|
|
12
|
+
// no clock, no crypto, no randomness, and no imports of any @oscharko-dev/* package. Hashes, ids, and
|
|
13
|
+
// correlation ids are produced by callers (opaque strings); timestamps are caller-provided ISO strings.
|
|
14
|
+
// CONTENT-FREE invariant (SC3): every persisted/audit field is an opaque id/hash, a count, a boolean
|
|
15
|
+
// flag, an enum, an ISO timestamp string, or a branch/path name — NEVER source text, secrets, tokens,
|
|
16
|
+
// raw provider payloads, or unbounded command output. Enforced at runtime by rejecting any unknown
|
|
17
|
+
// key against a closed allowlist in EVERY persisted-object validator — the audit event
|
|
18
|
+
// (validateWorkspaceEvent / WORKSPACE_EVENT_ALLOWED_KEYS), the durable instance
|
|
19
|
+
// (validateWorkspaceInstance / WORKSPACE_INSTANCE_ALLOWED_KEYS), the binding
|
|
20
|
+
// (validateWorkspaceBinding / WORKSPACE_BINDING_ALLOWED_KEYS), and the activation intent
|
|
21
|
+
// (validateWorkspaceActivation / WORKSPACE_ACTIVATION_ALLOWED_KEYS) — so a downstream persistence
|
|
22
|
+
// layer that trusts `.ok` cannot store smuggled content through any shape.
|
|
23
|
+
export const TASK_WORKSPACE_SCHEMA_VERSION = "1";
|
|
24
|
+
// ─── Local type guards (copied per leaf convention; see git-repository.ts) ──────
|
|
25
|
+
function isRecord(input) {
|
|
26
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
27
|
+
}
|
|
28
|
+
function isNonEmptyString(input) {
|
|
29
|
+
return typeof input === "string" && input.length > 0;
|
|
30
|
+
}
|
|
31
|
+
function isBoolean(input) {
|
|
32
|
+
return typeof input === "boolean";
|
|
33
|
+
}
|
|
34
|
+
// Collect "unknown key not allowed" reasons for a content-free object: any key outside the closed
|
|
35
|
+
// allowlist is treated as an attempt to smuggle source text / secrets / tokens / raw payloads, so
|
|
36
|
+
// the validator rejects it (SC3). Shared by the audit-event validator AND the persisted-object
|
|
37
|
+
// validators (instance/binding/activation) so the content-free invariant is enforced uniformly
|
|
38
|
+
// across the durable surface, not only the event stream.
|
|
39
|
+
function unknownKeyReasons(input, allowedKeys) {
|
|
40
|
+
const reasons = [];
|
|
41
|
+
for (const key of Object.keys(input)) {
|
|
42
|
+
if (!allowedKeys.includes(key)) {
|
|
43
|
+
reasons.push(`unknown key not allowed (content-free): ${key}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return reasons;
|
|
47
|
+
}
|
|
48
|
+
export const TASK_WORKSPACE_LIFECYCLE_STATES = [
|
|
49
|
+
"provisioning",
|
|
50
|
+
"active",
|
|
51
|
+
"paused",
|
|
52
|
+
"handoff-ready",
|
|
53
|
+
"archived",
|
|
54
|
+
"merged",
|
|
55
|
+
"abandoned",
|
|
56
|
+
"recovery-required",
|
|
57
|
+
"failed",
|
|
58
|
+
"cleanup-pending",
|
|
59
|
+
];
|
|
60
|
+
export function isTaskWorkspaceLifecycleState(value) {
|
|
61
|
+
return (typeof value === "string" &&
|
|
62
|
+
TASK_WORKSPACE_LIFECYCLE_STATES.includes(value));
|
|
63
|
+
}
|
|
64
|
+
// ─── Legal transition matrix (AC2) ────────────────────────────────────────────────
|
|
65
|
+
// Self-transitions (from === to) are ILLEGAL: no state lists itself as a successor.
|
|
66
|
+
export const TASK_WORKSPACE_LEGAL_TRANSITIONS = {
|
|
67
|
+
provisioning: ["active", "recovery-required", "failed", "cleanup-pending"],
|
|
68
|
+
active: ["paused", "handoff-ready", "recovery-required", "failed", "cleanup-pending"],
|
|
69
|
+
paused: [
|
|
70
|
+
"active",
|
|
71
|
+
"handoff-ready",
|
|
72
|
+
"archived",
|
|
73
|
+
"abandoned",
|
|
74
|
+
"recovery-required",
|
|
75
|
+
"cleanup-pending",
|
|
76
|
+
],
|
|
77
|
+
"handoff-ready": [
|
|
78
|
+
"active",
|
|
79
|
+
"merged",
|
|
80
|
+
"archived",
|
|
81
|
+
"abandoned",
|
|
82
|
+
"recovery-required",
|
|
83
|
+
"cleanup-pending",
|
|
84
|
+
],
|
|
85
|
+
merged: ["archived", "cleanup-pending"],
|
|
86
|
+
archived: ["cleanup-pending"],
|
|
87
|
+
abandoned: ["cleanup-pending"],
|
|
88
|
+
"recovery-required": ["active", "paused", "failed", "abandoned", "cleanup-pending"],
|
|
89
|
+
failed: ["recovery-required", "abandoned", "cleanup-pending"],
|
|
90
|
+
"cleanup-pending": ["archived", "abandoned", "recovery-required"],
|
|
91
|
+
};
|
|
92
|
+
// `from`/`to` are guarded with isTaskWorkspaceLifecycleState before the table lookup so a
|
|
93
|
+
// post-deserialization or wire-supplied non-state value (e.g. "", "__proto__", "constructor", a
|
|
94
|
+
// number, null) fails CLOSED — returns false / [] — instead of throwing or resolving to a
|
|
95
|
+
// prototype-chain value. This keeps every exported predicate throw-free on `unknown` input,
|
|
96
|
+
// matching the sibling validators (ADR-0088 D6).
|
|
97
|
+
export function isLegalTaskWorkspaceTransition(from, to) {
|
|
98
|
+
if (!isTaskWorkspaceLifecycleState(from) || !isTaskWorkspaceLifecycleState(to))
|
|
99
|
+
return false;
|
|
100
|
+
return TASK_WORKSPACE_LEGAL_TRANSITIONS[from].includes(to);
|
|
101
|
+
}
|
|
102
|
+
export function nextLegalTaskWorkspaceStates(from) {
|
|
103
|
+
if (!isTaskWorkspaceLifecycleState(from))
|
|
104
|
+
return [];
|
|
105
|
+
return TASK_WORKSPACE_LEGAL_TRANSITIONS[from];
|
|
106
|
+
}
|
|
107
|
+
export const TASK_WORKSPACE_TRANSITION_PRECONDITIONS = [
|
|
108
|
+
"lock-held-by-actor",
|
|
109
|
+
"path-contained",
|
|
110
|
+
"worktree-clean",
|
|
111
|
+
"branch-ready",
|
|
112
|
+
"provider-ready",
|
|
113
|
+
"operator-approval",
|
|
114
|
+
];
|
|
115
|
+
export function isTaskWorkspaceTransitionPrecondition(value) {
|
|
116
|
+
return (typeof value === "string" &&
|
|
117
|
+
TASK_WORKSPACE_TRANSITION_PRECONDITIONS.includes(value));
|
|
118
|
+
}
|
|
119
|
+
// Data table keyed `${from}->${to}`. Every LEGAL transition that carries a precondition is listed;
|
|
120
|
+
// any legal transition absent from this table defaults to no preconditions ([]). All
|
|
121
|
+
// `*->cleanup-pending` transitions require operator-approval. Illegal transitions never reach this
|
|
122
|
+
// table — they are rejected first by validateTaskWorkspaceTransition.
|
|
123
|
+
const TASK_WORKSPACE_TRANSITION_PRECONDITION_TABLE = {
|
|
124
|
+
"provisioning->active": ["lock-held-by-actor", "path-contained", "branch-ready"],
|
|
125
|
+
"provisioning->recovery-required": ["lock-held-by-actor"],
|
|
126
|
+
"provisioning->failed": [],
|
|
127
|
+
"provisioning->cleanup-pending": ["operator-approval"],
|
|
128
|
+
"active->paused": ["lock-held-by-actor"],
|
|
129
|
+
"active->handoff-ready": ["lock-held-by-actor", "worktree-clean"],
|
|
130
|
+
"active->recovery-required": [],
|
|
131
|
+
"active->failed": [],
|
|
132
|
+
"active->cleanup-pending": ["operator-approval"],
|
|
133
|
+
"paused->active": ["lock-held-by-actor", "path-contained"],
|
|
134
|
+
"paused->handoff-ready": ["lock-held-by-actor", "worktree-clean"],
|
|
135
|
+
"paused->archived": ["operator-approval"],
|
|
136
|
+
"paused->abandoned": ["operator-approval"],
|
|
137
|
+
"paused->recovery-required": [],
|
|
138
|
+
"paused->cleanup-pending": ["operator-approval"],
|
|
139
|
+
"handoff-ready->active": ["lock-held-by-actor", "path-contained"],
|
|
140
|
+
"handoff-ready->merged": ["provider-ready", "operator-approval"],
|
|
141
|
+
"handoff-ready->archived": ["operator-approval"],
|
|
142
|
+
"handoff-ready->abandoned": ["operator-approval"],
|
|
143
|
+
"handoff-ready->recovery-required": [],
|
|
144
|
+
"handoff-ready->cleanup-pending": ["operator-approval"],
|
|
145
|
+
"merged->archived": ["operator-approval"],
|
|
146
|
+
"merged->cleanup-pending": ["operator-approval"],
|
|
147
|
+
"archived->cleanup-pending": ["operator-approval"],
|
|
148
|
+
"abandoned->cleanup-pending": ["operator-approval"],
|
|
149
|
+
"recovery-required->active": ["lock-held-by-actor", "path-contained"],
|
|
150
|
+
"recovery-required->paused": ["lock-held-by-actor"],
|
|
151
|
+
"recovery-required->failed": [],
|
|
152
|
+
"recovery-required->abandoned": ["operator-approval"],
|
|
153
|
+
"recovery-required->cleanup-pending": ["operator-approval"],
|
|
154
|
+
"failed->recovery-required": [],
|
|
155
|
+
"failed->abandoned": ["operator-approval"],
|
|
156
|
+
"failed->cleanup-pending": ["operator-approval"],
|
|
157
|
+
"cleanup-pending->archived": ["lock-held-by-actor", "worktree-clean"],
|
|
158
|
+
"cleanup-pending->abandoned": ["operator-approval"],
|
|
159
|
+
"cleanup-pending->recovery-required": [],
|
|
160
|
+
};
|
|
161
|
+
export function requiredTaskWorkspaceTransitionPreconditions(from, to) {
|
|
162
|
+
return TASK_WORKSPACE_TRANSITION_PRECONDITION_TABLE[`${from}->${to}`] ?? [];
|
|
163
|
+
}
|
|
164
|
+
const PRECONDITION_CONTEXT_KEY = {
|
|
165
|
+
"lock-held-by-actor": "lockHeldByActor",
|
|
166
|
+
"path-contained": "pathContained",
|
|
167
|
+
"worktree-clean": "worktreeClean",
|
|
168
|
+
"branch-ready": "branchReady",
|
|
169
|
+
"provider-ready": "providerReady",
|
|
170
|
+
"operator-approval": "operatorApproved",
|
|
171
|
+
};
|
|
172
|
+
export function validateTaskWorkspaceTransition(input) {
|
|
173
|
+
const { from, to, context } = input;
|
|
174
|
+
if (!isLegalTaskWorkspaceTransition(from, to)) {
|
|
175
|
+
return { ok: false, reasons: [`illegal transition from ${from} to ${to}`] };
|
|
176
|
+
}
|
|
177
|
+
const reasons = [];
|
|
178
|
+
for (const precondition of requiredTaskWorkspaceTransitionPreconditions(from, to)) {
|
|
179
|
+
if (!context[PRECONDITION_CONTEXT_KEY[precondition]]) {
|
|
180
|
+
reasons.push(`unmet precondition: ${precondition}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
184
|
+
}
|
|
185
|
+
export const TASK_WORKSPACE_HEALTH_STATES = [
|
|
186
|
+
"healthy",
|
|
187
|
+
"degraded",
|
|
188
|
+
"drifted",
|
|
189
|
+
"locked-out",
|
|
190
|
+
"missing",
|
|
191
|
+
"unknown",
|
|
192
|
+
];
|
|
193
|
+
export function isTaskWorkspaceHealth(value) {
|
|
194
|
+
return (typeof value === "string" && TASK_WORKSPACE_HEALTH_STATES.includes(value));
|
|
195
|
+
}
|
|
196
|
+
export const TASK_WORKSPACE_DRIFT_MARKERS = [
|
|
197
|
+
"worktree-missing",
|
|
198
|
+
"gitdir-mismatch",
|
|
199
|
+
"head-moved",
|
|
200
|
+
"branch-deleted",
|
|
201
|
+
"uncommitted-changes",
|
|
202
|
+
"lock-stale",
|
|
203
|
+
"path-escape",
|
|
204
|
+
"pointer-stale",
|
|
205
|
+
];
|
|
206
|
+
export function isTaskWorkspaceDriftMarker(value) {
|
|
207
|
+
return (typeof value === "string" &&
|
|
208
|
+
TASK_WORKSPACE_DRIFT_MARKERS.includes(value));
|
|
209
|
+
}
|
|
210
|
+
export const WORKSPACE_LOCK_REASONS = [
|
|
211
|
+
"provisioning",
|
|
212
|
+
"activation",
|
|
213
|
+
"mutation",
|
|
214
|
+
"repair",
|
|
215
|
+
"cleanup",
|
|
216
|
+
];
|
|
217
|
+
export function isWorkspaceLockReason(value) {
|
|
218
|
+
return typeof value === "string" && WORKSPACE_LOCK_REASONS.includes(value);
|
|
219
|
+
}
|
|
220
|
+
export const WORKSPACE_FAILURE_CLASSES = [
|
|
221
|
+
"retryable",
|
|
222
|
+
"repairable",
|
|
223
|
+
"blocked",
|
|
224
|
+
"policy-denied",
|
|
225
|
+
"terminal",
|
|
226
|
+
];
|
|
227
|
+
export function isWorkspaceFailureClass(value) {
|
|
228
|
+
return (typeof value === "string" && WORKSPACE_FAILURE_CLASSES.includes(value));
|
|
229
|
+
}
|
|
230
|
+
export const WORKSPACE_RECOVERY_STRATEGIES = [
|
|
231
|
+
"reconcile-pointer",
|
|
232
|
+
"recreate-worktree",
|
|
233
|
+
"reattach-branch",
|
|
234
|
+
"release-stale-lock",
|
|
235
|
+
"commit-or-stash-required",
|
|
236
|
+
"operator-repair",
|
|
237
|
+
"abandon-and-cleanup",
|
|
238
|
+
];
|
|
239
|
+
export function isWorkspaceRecoveryStrategy(value) {
|
|
240
|
+
return (typeof value === "string" &&
|
|
241
|
+
WORKSPACE_RECOVERY_STRATEGIES.includes(value));
|
|
242
|
+
}
|
|
243
|
+
export const TASK_WORKSPACE_SURFACES = [
|
|
244
|
+
"chat",
|
|
245
|
+
"files",
|
|
246
|
+
"terminal",
|
|
247
|
+
"browser",
|
|
248
|
+
"editor",
|
|
249
|
+
"runtime",
|
|
250
|
+
"git-delivery",
|
|
251
|
+
"review",
|
|
252
|
+
];
|
|
253
|
+
export function isWorkspaceSurface(value) {
|
|
254
|
+
return typeof value === "string" && TASK_WORKSPACE_SURFACES.includes(value);
|
|
255
|
+
}
|
|
256
|
+
export const WORKSPACE_EVENT_TYPES = [
|
|
257
|
+
"provisioned",
|
|
258
|
+
"activated",
|
|
259
|
+
"paused",
|
|
260
|
+
"resumed",
|
|
261
|
+
"handoff-prepared",
|
|
262
|
+
"merged",
|
|
263
|
+
"archived",
|
|
264
|
+
"abandoned",
|
|
265
|
+
"recovery-flagged",
|
|
266
|
+
"repaired",
|
|
267
|
+
"cleanup-requested",
|
|
268
|
+
"cleanup-completed",
|
|
269
|
+
"lock-acquired",
|
|
270
|
+
"lock-released",
|
|
271
|
+
"drift-detected",
|
|
272
|
+
"health-changed",
|
|
273
|
+
"transition-rejected",
|
|
274
|
+
];
|
|
275
|
+
export function isWorkspaceEventType(value) {
|
|
276
|
+
return typeof value === "string" && WORKSPACE_EVENT_TYPES.includes(value);
|
|
277
|
+
}
|
|
278
|
+
// The closed set of keys a WorkspaceEvent may carry. Any other key means an attempt to smuggle
|
|
279
|
+
// source text / secrets / raw payloads through the audit stream, so the validator rejects it (SC3).
|
|
280
|
+
export const WORKSPACE_EVENT_ALLOWED_KEYS = [
|
|
281
|
+
"schemaVersion",
|
|
282
|
+
"eventId",
|
|
283
|
+
"workspaceId",
|
|
284
|
+
"taskId",
|
|
285
|
+
"type",
|
|
286
|
+
"at",
|
|
287
|
+
"correlationId",
|
|
288
|
+
"fromState",
|
|
289
|
+
"toState",
|
|
290
|
+
"health",
|
|
291
|
+
"driftMarkers",
|
|
292
|
+
"lockId",
|
|
293
|
+
];
|
|
294
|
+
// eslint-disable-next-line complexity
|
|
295
|
+
export function validateWorkspaceEvent(input) {
|
|
296
|
+
if (!isRecord(input))
|
|
297
|
+
return { ok: false, reasons: ["event must be an object"] };
|
|
298
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_EVENT_ALLOWED_KEYS);
|
|
299
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
300
|
+
reasons.push("schemaVersion invalid");
|
|
301
|
+
for (const key of ["eventId", "workspaceId", "taskId", "at", "correlationId"]) {
|
|
302
|
+
if (!isNonEmptyString(input[key]))
|
|
303
|
+
reasons.push(`${key} must be a non-empty string`);
|
|
304
|
+
}
|
|
305
|
+
if (!isWorkspaceEventType(input.type))
|
|
306
|
+
reasons.push("type invalid");
|
|
307
|
+
if (input.fromState !== undefined && !isTaskWorkspaceLifecycleState(input.fromState)) {
|
|
308
|
+
reasons.push("fromState invalid");
|
|
309
|
+
}
|
|
310
|
+
if (input.toState !== undefined && !isTaskWorkspaceLifecycleState(input.toState)) {
|
|
311
|
+
reasons.push("toState invalid");
|
|
312
|
+
}
|
|
313
|
+
if (input.health !== undefined && !isTaskWorkspaceHealth(input.health)) {
|
|
314
|
+
reasons.push("health invalid");
|
|
315
|
+
}
|
|
316
|
+
if (input.driftMarkers !== undefined) {
|
|
317
|
+
if (!Array.isArray(input.driftMarkers))
|
|
318
|
+
reasons.push("driftMarkers must be an array");
|
|
319
|
+
else if (!input.driftMarkers.every(isTaskWorkspaceDriftMarker)) {
|
|
320
|
+
reasons.push("driftMarkers contains an invalid marker");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (input.lockId !== undefined && !isNonEmptyString(input.lockId)) {
|
|
324
|
+
reasons.push("lockId must be a non-empty string when present");
|
|
325
|
+
}
|
|
326
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
327
|
+
}
|
|
328
|
+
function validateWorkspaceLock(input, reasons) {
|
|
329
|
+
if (!isRecord(input)) {
|
|
330
|
+
reasons.push("lock must be an object or null");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
for (const key of ["lockId", "owner", "acquiredAt"]) {
|
|
334
|
+
if (!isNonEmptyString(input[key]))
|
|
335
|
+
reasons.push(`lock.${key} must be a non-empty string`);
|
|
336
|
+
}
|
|
337
|
+
if (!isWorkspaceLockReason(input.reason))
|
|
338
|
+
reasons.push("lock.reason invalid");
|
|
339
|
+
if (input.expiresAt !== undefined && !isNonEmptyString(input.expiresAt)) {
|
|
340
|
+
reasons.push("lock.expiresAt must be a non-empty string when present");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
function validateRecoveryHints(input, reasons) {
|
|
344
|
+
if (!Array.isArray(input)) {
|
|
345
|
+
reasons.push("recoveryHints must be an array");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
input.forEach((hint, index) => {
|
|
349
|
+
if (!isRecord(hint)) {
|
|
350
|
+
reasons.push(`recoveryHints[${String(index)}] must be an object`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!isTaskWorkspaceDriftMarker(hint.marker)) {
|
|
354
|
+
reasons.push(`recoveryHints[${String(index)}].marker invalid`);
|
|
355
|
+
}
|
|
356
|
+
if (!isWorkspaceRecoveryStrategy(hint.strategy)) {
|
|
357
|
+
reasons.push(`recoveryHints[${String(index)}].strategy invalid`);
|
|
358
|
+
}
|
|
359
|
+
if (!isBoolean(hint.operatorActionRequired)) {
|
|
360
|
+
reasons.push(`recoveryHints[${String(index)}].operatorActionRequired must be a boolean`);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// The closed set of top-level keys a persisted WorkspaceInstance may carry. Enforced by
|
|
365
|
+
// validateWorkspaceInstance so the durable record stays content-free (SC3): a #447 persistence layer
|
|
366
|
+
// that trusts `validateWorkspaceInstance(input).ok` cannot store a record carrying a smuggled
|
|
367
|
+
// `sourceDiff` / `commandOutput` / `tokenValue` field. Nested `lock` and `recoveryHints` shapes are
|
|
368
|
+
// validated by their own helpers.
|
|
369
|
+
export const WORKSPACE_INSTANCE_ALLOWED_KEYS = [
|
|
370
|
+
"schemaVersion",
|
|
371
|
+
"workspaceId",
|
|
372
|
+
"taskId",
|
|
373
|
+
"repositoryId",
|
|
374
|
+
"repositoryRoot",
|
|
375
|
+
"baseBranch",
|
|
376
|
+
"taskBranch",
|
|
377
|
+
"managedWorktreePath",
|
|
378
|
+
"gitdirIdentity",
|
|
379
|
+
"lifecycleState",
|
|
380
|
+
"health",
|
|
381
|
+
"lock",
|
|
382
|
+
"createdAt",
|
|
383
|
+
"updatedAt",
|
|
384
|
+
"lastVerifiedAt",
|
|
385
|
+
"lastVerifiedHead",
|
|
386
|
+
"driftMarkers",
|
|
387
|
+
"recoveryHints",
|
|
388
|
+
"auditCorrelationId",
|
|
389
|
+
];
|
|
390
|
+
// eslint-disable-next-line complexity
|
|
391
|
+
export function validateWorkspaceInstance(input) {
|
|
392
|
+
if (!isRecord(input))
|
|
393
|
+
return { ok: false, reasons: ["instance must be an object"] };
|
|
394
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_INSTANCE_ALLOWED_KEYS);
|
|
395
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
396
|
+
reasons.push("schemaVersion invalid");
|
|
397
|
+
for (const key of [
|
|
398
|
+
"workspaceId",
|
|
399
|
+
"taskId",
|
|
400
|
+
"repositoryId",
|
|
401
|
+
"repositoryRoot",
|
|
402
|
+
"baseBranch",
|
|
403
|
+
"taskBranch",
|
|
404
|
+
"managedWorktreePath",
|
|
405
|
+
"gitdirIdentity",
|
|
406
|
+
"createdAt",
|
|
407
|
+
"updatedAt",
|
|
408
|
+
"auditCorrelationId",
|
|
409
|
+
]) {
|
|
410
|
+
if (!isNonEmptyString(input[key]))
|
|
411
|
+
reasons.push(`${key} must be a non-empty string`);
|
|
412
|
+
}
|
|
413
|
+
if (!isTaskWorkspaceLifecycleState(input.lifecycleState))
|
|
414
|
+
reasons.push("lifecycleState invalid");
|
|
415
|
+
if (!isTaskWorkspaceHealth(input.health))
|
|
416
|
+
reasons.push("health invalid");
|
|
417
|
+
if (input.lock !== null)
|
|
418
|
+
validateWorkspaceLock(input.lock, reasons);
|
|
419
|
+
if (input.lastVerifiedAt !== undefined && !isNonEmptyString(input.lastVerifiedAt)) {
|
|
420
|
+
reasons.push("lastVerifiedAt must be a non-empty string when present");
|
|
421
|
+
}
|
|
422
|
+
if (input.lastVerifiedHead !== undefined && !isNonEmptyString(input.lastVerifiedHead)) {
|
|
423
|
+
reasons.push("lastVerifiedHead must be a non-empty string when present");
|
|
424
|
+
}
|
|
425
|
+
if (!Array.isArray(input.driftMarkers))
|
|
426
|
+
reasons.push("driftMarkers must be an array");
|
|
427
|
+
else if (!input.driftMarkers.every(isTaskWorkspaceDriftMarker)) {
|
|
428
|
+
reasons.push("driftMarkers contains an invalid marker");
|
|
429
|
+
}
|
|
430
|
+
validateRecoveryHints(input.recoveryHints, reasons);
|
|
431
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
432
|
+
}
|
|
433
|
+
// The closed set of keys a WorkspaceBinding may carry (content-free, SC3).
|
|
434
|
+
export const WORKSPACE_BINDING_ALLOWED_KEYS = [
|
|
435
|
+
"schemaVersion",
|
|
436
|
+
"workspaceId",
|
|
437
|
+
"taskId",
|
|
438
|
+
"activeRoot",
|
|
439
|
+
"boundSurfaces",
|
|
440
|
+
"gitDeliveryRoot",
|
|
441
|
+
"editorProjectRoot",
|
|
442
|
+
];
|
|
443
|
+
// eslint-disable-next-line complexity
|
|
444
|
+
export function validateWorkspaceBinding(input) {
|
|
445
|
+
if (!isRecord(input))
|
|
446
|
+
return { ok: false, reasons: ["binding must be an object"] };
|
|
447
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_BINDING_ALLOWED_KEYS);
|
|
448
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
449
|
+
reasons.push("schemaVersion invalid");
|
|
450
|
+
for (const key of [
|
|
451
|
+
"workspaceId",
|
|
452
|
+
"taskId",
|
|
453
|
+
"activeRoot",
|
|
454
|
+
"gitDeliveryRoot",
|
|
455
|
+
"editorProjectRoot",
|
|
456
|
+
]) {
|
|
457
|
+
if (!isNonEmptyString(input[key]))
|
|
458
|
+
reasons.push(`${key} must be a non-empty string`);
|
|
459
|
+
}
|
|
460
|
+
if (!Array.isArray(input.boundSurfaces))
|
|
461
|
+
reasons.push("boundSurfaces must be an array");
|
|
462
|
+
else if (!input.boundSurfaces.every(isWorkspaceSurface)) {
|
|
463
|
+
reasons.push("boundSurfaces contains an invalid surface");
|
|
464
|
+
}
|
|
465
|
+
if (isNonEmptyString(input.activeRoot)) {
|
|
466
|
+
if (input.gitDeliveryRoot !== input.activeRoot) {
|
|
467
|
+
reasons.push("gitDeliveryRoot must equal activeRoot");
|
|
468
|
+
}
|
|
469
|
+
if (input.editorProjectRoot !== input.activeRoot) {
|
|
470
|
+
reasons.push("editorProjectRoot must equal activeRoot");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
474
|
+
}
|
|
475
|
+
// The closed set of keys a WorkspaceActivation intent may carry (content-free, SC3).
|
|
476
|
+
export const WORKSPACE_ACTIVATION_ALLOWED_KEYS = [
|
|
477
|
+
"schemaVersion",
|
|
478
|
+
"workspaceId",
|
|
479
|
+
"taskId",
|
|
480
|
+
"requestedBy",
|
|
481
|
+
"acquireLock",
|
|
482
|
+
"expectedLifecycleState",
|
|
483
|
+
];
|
|
484
|
+
export function validateWorkspaceActivation(input) {
|
|
485
|
+
if (!isRecord(input))
|
|
486
|
+
return { ok: false, reasons: ["activation must be an object"] };
|
|
487
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_ACTIVATION_ALLOWED_KEYS);
|
|
488
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
489
|
+
reasons.push("schemaVersion invalid");
|
|
490
|
+
for (const key of ["workspaceId", "taskId", "requestedBy"]) {
|
|
491
|
+
if (!isNonEmptyString(input[key]))
|
|
492
|
+
reasons.push(`${key} must be a non-empty string`);
|
|
493
|
+
}
|
|
494
|
+
if (!isBoolean(input.acquireLock))
|
|
495
|
+
reasons.push("acquireLock must be a boolean");
|
|
496
|
+
if (input.expectedLifecycleState !== undefined &&
|
|
497
|
+
!isTaskWorkspaceLifecycleState(input.expectedLifecycleState)) {
|
|
498
|
+
reasons.push("expectedLifecycleState invalid");
|
|
499
|
+
}
|
|
500
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
501
|
+
}
|
|
502
|
+
export const TASK_WORKSPACE_OPERATIONS = [
|
|
503
|
+
{
|
|
504
|
+
name: "discover",
|
|
505
|
+
authority: "read-only",
|
|
506
|
+
requiresLock: false,
|
|
507
|
+
requiresOperatorApproval: false,
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: "get-instance",
|
|
511
|
+
authority: "read-only",
|
|
512
|
+
requiresLock: false,
|
|
513
|
+
requiresOperatorApproval: false,
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
name: "get-health",
|
|
517
|
+
authority: "read-only",
|
|
518
|
+
requiresLock: false,
|
|
519
|
+
requiresOperatorApproval: false,
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
name: "resolve-binding",
|
|
523
|
+
authority: "read-only",
|
|
524
|
+
requiresLock: false,
|
|
525
|
+
requiresOperatorApproval: false,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
name: "provision",
|
|
529
|
+
authority: "mutating-server-action",
|
|
530
|
+
requiresLock: true,
|
|
531
|
+
requiresOperatorApproval: false,
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "activate",
|
|
535
|
+
authority: "mutating-server-action",
|
|
536
|
+
requiresLock: true,
|
|
537
|
+
requiresOperatorApproval: false,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
name: "pause",
|
|
541
|
+
authority: "mutating-server-action",
|
|
542
|
+
requiresLock: false,
|
|
543
|
+
requiresOperatorApproval: false,
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "resume",
|
|
547
|
+
authority: "mutating-server-action",
|
|
548
|
+
requiresLock: true,
|
|
549
|
+
requiresOperatorApproval: false,
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
name: "prepare-handoff",
|
|
553
|
+
authority: "mutating-server-action",
|
|
554
|
+
requiresLock: false,
|
|
555
|
+
requiresOperatorApproval: false,
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
name: "mark-merged",
|
|
559
|
+
authority: "mutating-server-action",
|
|
560
|
+
requiresLock: false,
|
|
561
|
+
requiresOperatorApproval: false,
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: "archive",
|
|
565
|
+
authority: "mutating-server-action",
|
|
566
|
+
requiresLock: false,
|
|
567
|
+
requiresOperatorApproval: false,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
name: "abandon",
|
|
571
|
+
authority: "mutating-server-action",
|
|
572
|
+
requiresLock: false,
|
|
573
|
+
requiresOperatorApproval: false,
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: "flag-recovery",
|
|
577
|
+
authority: "mutating-server-action",
|
|
578
|
+
requiresLock: false,
|
|
579
|
+
requiresOperatorApproval: false,
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: "repair",
|
|
583
|
+
authority: "mutating-server-action",
|
|
584
|
+
requiresLock: true,
|
|
585
|
+
requiresOperatorApproval: true,
|
|
586
|
+
},
|
|
587
|
+
{
|
|
588
|
+
name: "request-cleanup",
|
|
589
|
+
authority: "mutating-server-action",
|
|
590
|
+
requiresLock: false,
|
|
591
|
+
requiresOperatorApproval: true,
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
name: "complete-cleanup",
|
|
595
|
+
authority: "mutating-server-action",
|
|
596
|
+
requiresLock: true,
|
|
597
|
+
requiresOperatorApproval: true,
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
name: "acquire-lock",
|
|
601
|
+
authority: "mutating-server-action",
|
|
602
|
+
requiresLock: false,
|
|
603
|
+
requiresOperatorApproval: false,
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
name: "release-lock",
|
|
607
|
+
authority: "mutating-server-action",
|
|
608
|
+
requiresLock: false,
|
|
609
|
+
requiresOperatorApproval: false,
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
name: "append-event",
|
|
613
|
+
authority: "read-only",
|
|
614
|
+
requiresLock: false,
|
|
615
|
+
requiresOperatorApproval: false,
|
|
616
|
+
},
|
|
617
|
+
];
|
|
618
|
+
export function taskWorkspaceOperation(name) {
|
|
619
|
+
return TASK_WORKSPACE_OPERATIONS.find((operation) => operation.name === name);
|
|
620
|
+
}
|
|
621
|
+
export function isReadOnlyTaskWorkspaceOperation(name) {
|
|
622
|
+
return taskWorkspaceOperation(name)?.authority === "read-only";
|
|
623
|
+
}
|
|
624
|
+
export function isMutatingTaskWorkspaceOperation(name) {
|
|
625
|
+
return taskWorkspaceOperation(name)?.authority === "mutating-server-action";
|
|
626
|
+
}
|
|
627
|
+
export const TASK_WORKSPACE_DELEGATED_SUBSYSTEMS = [
|
|
628
|
+
{ concern: "git-mutation", owner: "git-delivery (#470)" },
|
|
629
|
+
{ concern: "editor-runtime-context", owner: "editor-agent / editor-session (#1491)" },
|
|
630
|
+
{ concern: "terminal-mutation", owner: "keiko-tools terminal policy (ADR-0018) — unchanged" },
|
|
631
|
+
{
|
|
632
|
+
concern: "workspace-discovery-and-containment",
|
|
633
|
+
owner: "@oscharko-dev/keiko-workspace",
|
|
634
|
+
},
|
|
635
|
+
];
|
|
636
|
+
export function isDelegatedTaskWorkspaceConcern(concern) {
|
|
637
|
+
return (typeof concern === "string" &&
|
|
638
|
+
TASK_WORKSPACE_DELEGATED_SUBSYSTEMS.some((entry) => entry.concern === concern));
|
|
639
|
+
}
|
|
640
|
+
export function taskWorkspaceDelegatedOwner(concern) {
|
|
641
|
+
return TASK_WORKSPACE_DELEGATED_SUBSYSTEMS.find((entry) => entry.concern === concern)?.owner;
|
|
642
|
+
}
|
|
643
|
+
export const WORKSPACE_RECONCILIATION_STATUSES = [
|
|
644
|
+
"healthy",
|
|
645
|
+
"missing",
|
|
646
|
+
"drifted",
|
|
647
|
+
"locked",
|
|
648
|
+
"partially-created",
|
|
649
|
+
"stale-pointer",
|
|
650
|
+
"unmanaged-path",
|
|
651
|
+
"recovery-required",
|
|
652
|
+
];
|
|
653
|
+
export function isWorkspaceReconciliationStatus(value) {
|
|
654
|
+
return (typeof value === "string" &&
|
|
655
|
+
WORKSPACE_RECONCILIATION_STATUSES.includes(value));
|
|
656
|
+
}
|
|
657
|
+
// Lifecycle states past the operational window: a missing worktree for one of these is EXPECTED
|
|
658
|
+
// (cleanup), so reconciliation treats them as settled rather than drifted.
|
|
659
|
+
const TERMINAL_LIFECYCLE_STATES = [
|
|
660
|
+
"merged",
|
|
661
|
+
"archived",
|
|
662
|
+
"abandoned",
|
|
663
|
+
"cleanup-pending",
|
|
664
|
+
];
|
|
665
|
+
const PARTIAL_LIFECYCLE_STATES = [
|
|
666
|
+
"provisioning",
|
|
667
|
+
"failed",
|
|
668
|
+
];
|
|
669
|
+
// The single authoritative map from a drift marker to its recovery strategy + whether an operator
|
|
670
|
+
// must act (e.g. a moved HEAD or uncommitted work may carry intentional changes, and a containment
|
|
671
|
+
// escape is never auto-repaired). Consumed by both reconciliation and the repair service (AC5).
|
|
672
|
+
const DRIFT_MARKER_RECOVERY = {
|
|
673
|
+
"worktree-missing": { strategy: "recreate-worktree", operatorActionRequired: false },
|
|
674
|
+
// A moved-but-readable gitdir can be re-linked automatically (the pointer identity is re-derived);
|
|
675
|
+
// a missing/corrupt `.git` pointer cannot be repaired by the narrow worktree adapter without risking
|
|
676
|
+
// loss of the worktree's uncommitted work, so it is operator-guided.
|
|
677
|
+
"gitdir-mismatch": { strategy: "reconcile-pointer", operatorActionRequired: false },
|
|
678
|
+
"pointer-stale": { strategy: "operator-repair", operatorActionRequired: true },
|
|
679
|
+
"head-moved": { strategy: "operator-repair", operatorActionRequired: true },
|
|
680
|
+
// A deleted local branch cannot be safely re-created by the narrow worktree adapter without risking
|
|
681
|
+
// loss of the worktree's commits, so reattachment is operator-guided, never automatic.
|
|
682
|
+
"branch-deleted": { strategy: "reattach-branch", operatorActionRequired: true },
|
|
683
|
+
"uncommitted-changes": { strategy: "commit-or-stash-required", operatorActionRequired: true },
|
|
684
|
+
"lock-stale": { strategy: "release-stale-lock", operatorActionRequired: false },
|
|
685
|
+
"path-escape": { strategy: "operator-repair", operatorActionRequired: true },
|
|
686
|
+
};
|
|
687
|
+
// Pure: map a set of drift markers to ordered, de-duplicated recovery hints. The order follows the
|
|
688
|
+
// input marker order so the most salient drift drives the first suggested repair.
|
|
689
|
+
export function planWorkspaceRecoveryHints(driftMarkers) {
|
|
690
|
+
const hints = [];
|
|
691
|
+
const seen = new Set();
|
|
692
|
+
for (const marker of driftMarkers) {
|
|
693
|
+
if (!isTaskWorkspaceDriftMarker(marker) || seen.has(marker))
|
|
694
|
+
continue;
|
|
695
|
+
seen.add(marker);
|
|
696
|
+
const mapping = DRIFT_MARKER_RECOVERY[marker];
|
|
697
|
+
hints.push({
|
|
698
|
+
marker,
|
|
699
|
+
strategy: mapping.strategy,
|
|
700
|
+
operatorActionRequired: mapping.operatorActionRequired,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return hints;
|
|
704
|
+
}
|
|
705
|
+
function withStaleLock(markers, facts) {
|
|
706
|
+
return facts.lockPresent && !facts.lockLive ? [...markers, "lock-stale"] : markers;
|
|
707
|
+
}
|
|
708
|
+
function outcome(status, markers) {
|
|
709
|
+
return { status, driftMarkers: markers, recoveryHints: planWorkspaceRecoveryHints(markers) };
|
|
710
|
+
}
|
|
711
|
+
// Pure deterministic classifier with a fixed precedence (most severe first): a containment escape and
|
|
712
|
+
// a live foreign lock short-circuit before any disk classification; terminal lifecycles are settled;
|
|
713
|
+
// then partial-creation, then on-disk drift (missing → stale pointer → branch/HEAD/dirty), then a
|
|
714
|
+
// lingering recovery-required flag, then a stale lock on an otherwise-healthy workspace.
|
|
715
|
+
// eslint-disable-next-line complexity
|
|
716
|
+
export function classifyWorkspaceReconciliation(facts) {
|
|
717
|
+
if (!facts.pathContained)
|
|
718
|
+
return outcome("unmanaged-path", ["path-escape"]);
|
|
719
|
+
if (facts.lockedByOtherActor)
|
|
720
|
+
return outcome("locked", []);
|
|
721
|
+
if (TERMINAL_LIFECYCLE_STATES.includes(facts.lifecycleState))
|
|
722
|
+
return outcome("healthy", []);
|
|
723
|
+
if (PARTIAL_LIFECYCLE_STATES.includes(facts.lifecycleState)) {
|
|
724
|
+
const base = !facts.worktreeDirExists
|
|
725
|
+
? ["worktree-missing"]
|
|
726
|
+
: !facts.gitPointerPresent
|
|
727
|
+
? ["pointer-stale"]
|
|
728
|
+
: [];
|
|
729
|
+
return outcome("partially-created", withStaleLock(base, facts));
|
|
730
|
+
}
|
|
731
|
+
if (!facts.worktreeDirExists)
|
|
732
|
+
return outcome("missing", withStaleLock(["worktree-missing"], facts));
|
|
733
|
+
if (!facts.gitPointerPresent)
|
|
734
|
+
return outcome("stale-pointer", withStaleLock(["pointer-stale"], facts));
|
|
735
|
+
if (!facts.gitdirIdentityMatches) {
|
|
736
|
+
return outcome("stale-pointer", withStaleLock(["gitdir-mismatch"], facts));
|
|
737
|
+
}
|
|
738
|
+
if (!facts.taskBranchPresent)
|
|
739
|
+
return outcome("drifted", withStaleLock(["branch-deleted"], facts));
|
|
740
|
+
if (!facts.headMatches)
|
|
741
|
+
return outcome("drifted", withStaleLock(["head-moved"], facts));
|
|
742
|
+
if (facts.uncommittedChanges) {
|
|
743
|
+
return outcome("drifted", withStaleLock(["uncommitted-changes"], facts));
|
|
744
|
+
}
|
|
745
|
+
if (facts.lifecycleState === "recovery-required") {
|
|
746
|
+
return outcome("recovery-required", withStaleLock([], facts));
|
|
747
|
+
}
|
|
748
|
+
if (facts.lockPresent && !facts.lockLive)
|
|
749
|
+
return outcome("drifted", ["lock-stale"]);
|
|
750
|
+
return outcome("healthy", []);
|
|
751
|
+
}
|
|
752
|
+
// Pure: the health enum value reconciliation persists for a given status, so the stored health stays
|
|
753
|
+
// consistent with the classification (and #448 can read either).
|
|
754
|
+
export function reconciliationHealth(status) {
|
|
755
|
+
switch (status) {
|
|
756
|
+
case "healthy":
|
|
757
|
+
return "healthy";
|
|
758
|
+
case "missing":
|
|
759
|
+
return "missing";
|
|
760
|
+
case "drifted":
|
|
761
|
+
case "stale-pointer":
|
|
762
|
+
return "drifted";
|
|
763
|
+
case "locked":
|
|
764
|
+
return "locked-out";
|
|
765
|
+
case "partially-created":
|
|
766
|
+
case "unmanaged-path":
|
|
767
|
+
case "recovery-required":
|
|
768
|
+
return "degraded";
|
|
769
|
+
default:
|
|
770
|
+
return "unknown";
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Pure: whether reconciliation should flag a once-operational workspace (active/paused/handoff-ready)
|
|
774
|
+
// as recovery-required because its worktree is GONE or structurally unusable (missing, a broken/moved
|
|
775
|
+
// git pointer, or an escaped path). A merely `drifted` workspace (moved HEAD, uncommitted work, branch
|
|
776
|
+
// mismatch, stale lock) keeps a usable worktree, so it is surfaced via health + drift markers + hints
|
|
777
|
+
// WITHOUT being forced out of its lifecycle — crisp classification over aggressive auto-healing. A
|
|
778
|
+
// partially-created (provisioning/failed) workspace is left to the provisioning retry path, and a live
|
|
779
|
+
// foreign lock is deferred, never flagged.
|
|
780
|
+
export function reconciliationRequiresRecoveryFlag(status, lifecycleState) {
|
|
781
|
+
if (lifecycleState !== "active" &&
|
|
782
|
+
lifecycleState !== "paused" &&
|
|
783
|
+
lifecycleState !== "handoff-ready") {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
return status === "missing" || status === "stale-pointer" || status === "unmanaged-path";
|
|
787
|
+
}
|
|
788
|
+
// Pure: reconstruct the reconciliation status from the CONTENT-FREE persisted instance fields, so a
|
|
789
|
+
// read-only report can be derived without re-running IO (the GET surface) and matches what a live
|
|
790
|
+
// reconcile last persisted. Mirrors the precedence of classifyWorkspaceReconciliation.
|
|
791
|
+
// eslint-disable-next-line complexity
|
|
792
|
+
export function reconciliationStatusFromInstance(input) {
|
|
793
|
+
const markers = input.driftMarkers;
|
|
794
|
+
if (markers.includes("path-escape"))
|
|
795
|
+
return "unmanaged-path";
|
|
796
|
+
if (input.health === "locked-out")
|
|
797
|
+
return "locked";
|
|
798
|
+
if (TERMINAL_LIFECYCLE_STATES.includes(input.lifecycleState))
|
|
799
|
+
return "healthy";
|
|
800
|
+
if (PARTIAL_LIFECYCLE_STATES.includes(input.lifecycleState))
|
|
801
|
+
return "partially-created";
|
|
802
|
+
if (markers.includes("worktree-missing"))
|
|
803
|
+
return "missing";
|
|
804
|
+
if (markers.includes("pointer-stale") || markers.includes("gitdir-mismatch")) {
|
|
805
|
+
return "stale-pointer";
|
|
806
|
+
}
|
|
807
|
+
if (markers.includes("branch-deleted") ||
|
|
808
|
+
markers.includes("head-moved") ||
|
|
809
|
+
markers.includes("uncommitted-changes") ||
|
|
810
|
+
markers.includes("lock-stale")) {
|
|
811
|
+
return "drifted";
|
|
812
|
+
}
|
|
813
|
+
if (input.lifecycleState === "recovery-required")
|
|
814
|
+
return "recovery-required";
|
|
815
|
+
return "healthy";
|
|
816
|
+
}
|
|
817
|
+
// ─── Repair applicability (Issue #447) ──────────────────────────────────────────────
|
|
818
|
+
// A repair may only apply a strategy the reconciliation actually recommended for the workspace, so a
|
|
819
|
+
// caller cannot drive an arbitrary git mutation: the repair service validates the requested strategy
|
|
820
|
+
// against the instance's current recovery hints. `abandon-and-cleanup` is the one universal escape
|
|
821
|
+
// (always available for a non-healthy workspace) and `operator-repair`/`commit-or-stash-required`
|
|
822
|
+
// signal that no automatic mutation is safe — the operator must act first.
|
|
823
|
+
// The strategies the repair service can apply WITHOUT operator action, because each is reversible /
|
|
824
|
+
// non-destructive: recreate a missing worktree from the still-present branch, refresh a stale/moved
|
|
825
|
+
// worktree pointer, or release an expired lock. `reattach-branch`, `operator-repair`, and
|
|
826
|
+
// `commit-or-stash-required` are deliberately excluded — they require a human decision.
|
|
827
|
+
export function isAutomaticWorkspaceRepairStrategy(strategy) {
|
|
828
|
+
return (strategy === "reconcile-pointer" ||
|
|
829
|
+
strategy === "recreate-worktree" ||
|
|
830
|
+
strategy === "release-stale-lock");
|
|
831
|
+
}
|
|
832
|
+
// Pure: whether `strategy` is applicable given the recovery hints reconciliation produced. An
|
|
833
|
+
// automatic strategy is applicable iff a hint recommends it; `abandon-and-cleanup` is applicable for
|
|
834
|
+
// any non-healthy status; operator strategies are never "applicable" as an automatic repair.
|
|
835
|
+
export function isWorkspaceRepairStrategyApplicable(input) {
|
|
836
|
+
if (input.strategy === "abandon-and-cleanup")
|
|
837
|
+
return input.status !== "healthy";
|
|
838
|
+
if (!isAutomaticWorkspaceRepairStrategy(input.strategy))
|
|
839
|
+
return false;
|
|
840
|
+
return input.recoveryHints.some((hint) => hint.strategy === input.strategy && !hint.operatorActionRequired);
|
|
841
|
+
}
|
|
842
|
+
export const WORKSPACE_RECONCILIATION_ENTRY_ALLOWED_KEYS = [
|
|
843
|
+
"schemaVersion",
|
|
844
|
+
"workspaceId",
|
|
845
|
+
"taskId",
|
|
846
|
+
"status",
|
|
847
|
+
"lifecycleState",
|
|
848
|
+
"health",
|
|
849
|
+
"driftMarkers",
|
|
850
|
+
"recoveryHints",
|
|
851
|
+
"repairable",
|
|
852
|
+
"operatorActionRequired",
|
|
853
|
+
"lastVerifiedAt",
|
|
854
|
+
];
|
|
855
|
+
// Pure: the two derived booleans on a reconciliation entry. `repairable` is true when an automatic
|
|
856
|
+
// strategy is available OR the workspace is partially-created (the canonical retry case, AC3).
|
|
857
|
+
export function workspaceEntryRepairable(input) {
|
|
858
|
+
if (input.status === "partially-created")
|
|
859
|
+
return true;
|
|
860
|
+
return input.recoveryHints.some((hint) => !hint.operatorActionRequired);
|
|
861
|
+
}
|
|
862
|
+
export function workspaceEntryOperatorActionRequired(recoveryHints) {
|
|
863
|
+
return recoveryHints.some((hint) => hint.operatorActionRequired);
|
|
864
|
+
}
|
|
865
|
+
// Pure: build a content-free reconciliation entry from the persisted (content-free) instance fields.
|
|
866
|
+
// The status is reconstructed from those fields so a read-only report matches what a live reconcile
|
|
867
|
+
// last persisted, and the two derived booleans come from the recovery hints. Used by both the live and
|
|
868
|
+
// stored-derived report paths so they cannot diverge (AC5).
|
|
869
|
+
export function deriveReconciliationEntry(input) {
|
|
870
|
+
const status = reconciliationStatusFromInstance({
|
|
871
|
+
lifecycleState: input.lifecycleState,
|
|
872
|
+
health: input.health,
|
|
873
|
+
driftMarkers: input.driftMarkers,
|
|
874
|
+
});
|
|
875
|
+
return {
|
|
876
|
+
schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
|
|
877
|
+
workspaceId: input.workspaceId,
|
|
878
|
+
taskId: input.taskId,
|
|
879
|
+
status,
|
|
880
|
+
lifecycleState: input.lifecycleState,
|
|
881
|
+
health: input.health,
|
|
882
|
+
driftMarkers: input.driftMarkers,
|
|
883
|
+
recoveryHints: input.recoveryHints,
|
|
884
|
+
repairable: workspaceEntryRepairable({ status, recoveryHints: input.recoveryHints }),
|
|
885
|
+
operatorActionRequired: workspaceEntryOperatorActionRequired(input.recoveryHints),
|
|
886
|
+
...(input.lastVerifiedAt !== undefined ? { lastVerifiedAt: input.lastVerifiedAt } : {}),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
// eslint-disable-next-line complexity
|
|
890
|
+
export function validateWorkspaceReconciliationEntry(input) {
|
|
891
|
+
if (!isRecord(input))
|
|
892
|
+
return { ok: false, reasons: ["entry must be an object"] };
|
|
893
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_RECONCILIATION_ENTRY_ALLOWED_KEYS);
|
|
894
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
895
|
+
reasons.push("schemaVersion invalid");
|
|
896
|
+
for (const key of ["workspaceId", "taskId"]) {
|
|
897
|
+
if (!isNonEmptyString(input[key]))
|
|
898
|
+
reasons.push(`${key} must be a non-empty string`);
|
|
899
|
+
}
|
|
900
|
+
if (!isWorkspaceReconciliationStatus(input.status))
|
|
901
|
+
reasons.push("status invalid");
|
|
902
|
+
if (!isTaskWorkspaceLifecycleState(input.lifecycleState))
|
|
903
|
+
reasons.push("lifecycleState invalid");
|
|
904
|
+
if (!isTaskWorkspaceHealth(input.health))
|
|
905
|
+
reasons.push("health invalid");
|
|
906
|
+
if (!Array.isArray(input.driftMarkers) || !input.driftMarkers.every(isTaskWorkspaceDriftMarker)) {
|
|
907
|
+
reasons.push("driftMarkers must be an array of valid markers");
|
|
908
|
+
}
|
|
909
|
+
validateRecoveryHints(input.recoveryHints, reasons);
|
|
910
|
+
if (!isBoolean(input.repairable))
|
|
911
|
+
reasons.push("repairable must be a boolean");
|
|
912
|
+
if (!isBoolean(input.operatorActionRequired)) {
|
|
913
|
+
reasons.push("operatorActionRequired must be a boolean");
|
|
914
|
+
}
|
|
915
|
+
if (input.lastVerifiedAt !== undefined && !isNonEmptyString(input.lastVerifiedAt)) {
|
|
916
|
+
reasons.push("lastVerifiedAt must be a non-empty string when present");
|
|
917
|
+
}
|
|
918
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
919
|
+
}
|
|
920
|
+
export const WORKSPACE_ACTIVE_RESTORATION_KINDS = [
|
|
921
|
+
"none",
|
|
922
|
+
"restored",
|
|
923
|
+
"recovery-required",
|
|
924
|
+
"cleared-dangling",
|
|
925
|
+
"ambiguous",
|
|
926
|
+
];
|
|
927
|
+
export function isWorkspaceActiveRestorationKind(value) {
|
|
928
|
+
return (typeof value === "string" &&
|
|
929
|
+
WORKSPACE_ACTIVE_RESTORATION_KINDS.includes(value));
|
|
930
|
+
}
|
|
931
|
+
// Pure: decide how (and whether) to restore the active workspace after restart, given the persisted
|
|
932
|
+
// pointer target (or undefined for unbound mode) and the reconciliation entries. Deterministic and
|
|
933
|
+
// conservative — it never auto-selects among ambiguous active workspaces.
|
|
934
|
+
export function resolveActiveRestoration(pointerWorkspaceId, entries) {
|
|
935
|
+
if (pointerWorkspaceId === undefined || pointerWorkspaceId.length === 0) {
|
|
936
|
+
const activeIds = entries
|
|
937
|
+
.filter((entry) => entry.lifecycleState === "active")
|
|
938
|
+
.map((entry) => entry.workspaceId)
|
|
939
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
940
|
+
if (activeIds.length >= 2)
|
|
941
|
+
return { kind: "ambiguous", ambiguousWorkspaceIds: activeIds };
|
|
942
|
+
return { kind: "none" };
|
|
943
|
+
}
|
|
944
|
+
const target = entries.find((entry) => entry.workspaceId === pointerWorkspaceId);
|
|
945
|
+
if (target === undefined)
|
|
946
|
+
return { kind: "cleared-dangling", workspaceId: pointerWorkspaceId };
|
|
947
|
+
if (target.status === "healthy")
|
|
948
|
+
return { kind: "restored", workspaceId: pointerWorkspaceId };
|
|
949
|
+
return { kind: "recovery-required", workspaceId: pointerWorkspaceId };
|
|
950
|
+
}
|
|
951
|
+
export function validateWorkspaceActiveRestoration(input) {
|
|
952
|
+
if (!isRecord(input))
|
|
953
|
+
return { ok: false, reasons: ["restoration must be an object"] };
|
|
954
|
+
const reasons = unknownKeyReasons(input, [
|
|
955
|
+
"kind",
|
|
956
|
+
"workspaceId",
|
|
957
|
+
"ambiguousWorkspaceIds",
|
|
958
|
+
]);
|
|
959
|
+
if (!isWorkspaceActiveRestorationKind(input.kind))
|
|
960
|
+
reasons.push("kind invalid");
|
|
961
|
+
if (input.workspaceId !== undefined && !isNonEmptyString(input.workspaceId)) {
|
|
962
|
+
reasons.push("workspaceId must be a non-empty string when present");
|
|
963
|
+
}
|
|
964
|
+
if (input.ambiguousWorkspaceIds !== undefined) {
|
|
965
|
+
if (!Array.isArray(input.ambiguousWorkspaceIds) ||
|
|
966
|
+
!input.ambiguousWorkspaceIds.every(isNonEmptyString)) {
|
|
967
|
+
reasons.push("ambiguousWorkspaceIds must be an array of non-empty strings when present");
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
971
|
+
}
|
|
972
|
+
export const WORKSPACE_RECONCILIATION_REPORT_ALLOWED_KEYS = [
|
|
973
|
+
"schemaVersion",
|
|
974
|
+
"generatedAt",
|
|
975
|
+
"entries",
|
|
976
|
+
"activeRestoration",
|
|
977
|
+
];
|
|
978
|
+
export function validateWorkspaceReconciliationReport(input) {
|
|
979
|
+
if (!isRecord(input))
|
|
980
|
+
return { ok: false, reasons: ["report must be an object"] };
|
|
981
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_RECONCILIATION_REPORT_ALLOWED_KEYS);
|
|
982
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
983
|
+
reasons.push("schemaVersion invalid");
|
|
984
|
+
if (!isNonEmptyString(input.generatedAt))
|
|
985
|
+
reasons.push("generatedAt must be a non-empty string");
|
|
986
|
+
if (!Array.isArray(input.entries))
|
|
987
|
+
reasons.push("entries must be an array");
|
|
988
|
+
else {
|
|
989
|
+
input.entries.forEach((entry, index) => {
|
|
990
|
+
const entryValidation = validateWorkspaceReconciliationEntry(entry);
|
|
991
|
+
if (!entryValidation.ok) {
|
|
992
|
+
reasons.push(`entries[${String(index)}]: ${entryValidation.reasons.join("; ")}`);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
const restorationValidation = validateWorkspaceActiveRestoration(input.activeRestoration);
|
|
997
|
+
if (!restorationValidation.ok) {
|
|
998
|
+
reasons.push(`activeRestoration: ${restorationValidation.reasons.join("; ")}`);
|
|
999
|
+
}
|
|
1000
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
1001
|
+
}
|
|
1002
|
+
export const WORKSPACE_HEALTH_CLASSIFICATIONS = [
|
|
1003
|
+
"healthy",
|
|
1004
|
+
"dirty",
|
|
1005
|
+
"drifted",
|
|
1006
|
+
"missing",
|
|
1007
|
+
"stale-pointer",
|
|
1008
|
+
"locked",
|
|
1009
|
+
"orphaned",
|
|
1010
|
+
"archived",
|
|
1011
|
+
"cleanup-ready",
|
|
1012
|
+
"recovery-required",
|
|
1013
|
+
];
|
|
1014
|
+
export function isWorkspaceHealthClassification(value) {
|
|
1015
|
+
return (typeof value === "string" &&
|
|
1016
|
+
WORKSPACE_HEALTH_CLASSIFICATIONS.includes(value));
|
|
1017
|
+
}
|
|
1018
|
+
// The lifecycle states from which a workspace may be physically cleaned up. STRICTER than the contract
|
|
1019
|
+
// transition table (which permits `active->cleanup-pending` with operator approval): #448 policy
|
|
1020
|
+
// requires a workspace be SETTLED (archived/merged/abandoned/failed) or already cleanup-pending before
|
|
1021
|
+
// its worktree can be removed, so an active/paused/handoff-ready/recovery-required workspace can never
|
|
1022
|
+
// be cleaned without first transitioning it to a settled state (defense in depth, SC4).
|
|
1023
|
+
export const WORKSPACE_CLEANUP_ELIGIBLE_LIFECYCLE_STATES = [
|
|
1024
|
+
"archived",
|
|
1025
|
+
"merged",
|
|
1026
|
+
"abandoned",
|
|
1027
|
+
"failed",
|
|
1028
|
+
"cleanup-pending",
|
|
1029
|
+
];
|
|
1030
|
+
export function isCleanupEligibleLifecycleState(value) {
|
|
1031
|
+
return (isTaskWorkspaceLifecycleState(value) &&
|
|
1032
|
+
WORKSPACE_CLEANUP_ELIGIBLE_LIFECYCLE_STATES.includes(value));
|
|
1033
|
+
}
|
|
1034
|
+
export const WORKSPACE_CLEANUP_REFUSAL_REASONS = [
|
|
1035
|
+
"ownership-unproven",
|
|
1036
|
+
"path-escape",
|
|
1037
|
+
"lock-live",
|
|
1038
|
+
"worktree-dirty",
|
|
1039
|
+
"not-eligible-state",
|
|
1040
|
+
];
|
|
1041
|
+
export function isWorkspaceCleanupRefusalReason(value) {
|
|
1042
|
+
return (typeof value === "string" &&
|
|
1043
|
+
WORKSPACE_CLEANUP_REFUSAL_REASONS.includes(value));
|
|
1044
|
+
}
|
|
1045
|
+
// Pure: the SINGLE cleanup-safety gate, shared by the health classifier (`cleanup-ready`) and the
|
|
1046
|
+
// keiko-server cleanup service (refusal), so they can never diverge. Refusal precedence (most
|
|
1047
|
+
// fundamental first): ownership must be proven (SC1), the path must be realpath-contained inside the
|
|
1048
|
+
// managed root (SC1/SC2), no live lock may be held (SC4), the worktree must be clean (SC4), and a
|
|
1049
|
+
// persisted instance must be in a cleanup-eligible lifecycle state. An orphaned worktree (no record)
|
|
1050
|
+
// skips the lifecycle check — once owned, contained, clean, and unlocked it is always removable.
|
|
1051
|
+
export function evaluateWorkspaceCleanupSafety(facts) {
|
|
1052
|
+
if (!facts.ownershipProven)
|
|
1053
|
+
return { allowed: false, refusalReason: "ownership-unproven" };
|
|
1054
|
+
if (!facts.pathContained)
|
|
1055
|
+
return { allowed: false, refusalReason: "path-escape" };
|
|
1056
|
+
if (facts.lockLive)
|
|
1057
|
+
return { allowed: false, refusalReason: "lock-live" };
|
|
1058
|
+
if (facts.worktreeDirty)
|
|
1059
|
+
return { allowed: false, refusalReason: "worktree-dirty" };
|
|
1060
|
+
if (facts.hasRecord && !isCleanupEligibleLifecycleState(facts.lifecycleState)) {
|
|
1061
|
+
return { allowed: false, refusalReason: "not-eligible-state" };
|
|
1062
|
+
}
|
|
1063
|
+
return { allowed: true };
|
|
1064
|
+
}
|
|
1065
|
+
// Pure: maps a reconciliation status + lifecycle + live dirty/cleanup signals to the operational health
|
|
1066
|
+
// classification. Severe structural conditions win first (they precede any cleanup consideration); a
|
|
1067
|
+
// structurally healthy worktree then resolves by lifecycle — settled (archived/merged) → `archived`,
|
|
1068
|
+
// settled-for-disposal (abandoned/failed/cleanup-pending) + cleanup-eligible → `cleanup-ready`,
|
|
1069
|
+
// otherwise `dirty` when the working tree is dirty, else `healthy`.
|
|
1070
|
+
// eslint-disable-next-line complexity
|
|
1071
|
+
function healthClassificationFor(status, lifecycleState, worktreeDirty, cleanupEligible) {
|
|
1072
|
+
if (status === "unmanaged-path")
|
|
1073
|
+
return "recovery-required";
|
|
1074
|
+
if (status === "locked")
|
|
1075
|
+
return "locked";
|
|
1076
|
+
if (status === "missing")
|
|
1077
|
+
return "missing";
|
|
1078
|
+
if (status === "stale-pointer")
|
|
1079
|
+
return "stale-pointer";
|
|
1080
|
+
if (status === "drifted")
|
|
1081
|
+
return "drifted";
|
|
1082
|
+
if (status === "partially-created" || status === "recovery-required")
|
|
1083
|
+
return "recovery-required";
|
|
1084
|
+
// status === "healthy": structurally sound (contained, pointer + branch + HEAD verified, no foreign
|
|
1085
|
+
// lock). A `failed`/`provisioning` lifecycle is a partial-creation reconciliation state, so it never
|
|
1086
|
+
// reaches here (it returns "partially-created" above). Resolve the remaining lifecycles by disposition.
|
|
1087
|
+
if (lifecycleState === "archived" || lifecycleState === "merged")
|
|
1088
|
+
return "archived";
|
|
1089
|
+
if ((lifecycleState === "abandoned" || lifecycleState === "cleanup-pending") && cleanupEligible) {
|
|
1090
|
+
return "cleanup-ready";
|
|
1091
|
+
}
|
|
1092
|
+
return worktreeDirty ? "dirty" : "healthy";
|
|
1093
|
+
}
|
|
1094
|
+
// Pure: classify ONE persisted instance. Delegates the structural classification to the #447
|
|
1095
|
+
// reconciliation classifier (no second precedence chain) and the cleanup decision to the single safety
|
|
1096
|
+
// gate, then composes them into the operational health evaluation.
|
|
1097
|
+
export function classifyWorkspaceHealth(signals) {
|
|
1098
|
+
const facts = signals.reconciliation;
|
|
1099
|
+
const recon = classifyWorkspaceReconciliation(facts);
|
|
1100
|
+
const decision = evaluateWorkspaceCleanupSafety({
|
|
1101
|
+
lifecycleState: facts.lifecycleState,
|
|
1102
|
+
hasRecord: true,
|
|
1103
|
+
pathContained: facts.pathContained,
|
|
1104
|
+
ownershipProven: signals.ownershipProven,
|
|
1105
|
+
worktreeDirty: signals.worktreeDirty,
|
|
1106
|
+
lockLive: facts.lockLive,
|
|
1107
|
+
});
|
|
1108
|
+
return {
|
|
1109
|
+
classification: healthClassificationFor(recon.status, facts.lifecycleState, signals.worktreeDirty, decision.allowed),
|
|
1110
|
+
driftMarkers: recon.driftMarkers,
|
|
1111
|
+
recoveryHints: recon.recoveryHints,
|
|
1112
|
+
cleanupEligible: decision.allowed,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
export const WORKSPACE_HEALTH_ENTRY_KINDS = [
|
|
1116
|
+
"instance",
|
|
1117
|
+
"orphan-worktree",
|
|
1118
|
+
];
|
|
1119
|
+
export function isWorkspaceHealthEntryKind(value) {
|
|
1120
|
+
return (typeof value === "string" &&
|
|
1121
|
+
WORKSPACE_HEALTH_ENTRY_KINDS.includes(value));
|
|
1122
|
+
}
|
|
1123
|
+
export const WORKSPACE_HEALTH_ENTRY_ALLOWED_KEYS = [
|
|
1124
|
+
"schemaVersion",
|
|
1125
|
+
"kind",
|
|
1126
|
+
"classification",
|
|
1127
|
+
"driftMarkers",
|
|
1128
|
+
"recoveryHints",
|
|
1129
|
+
"cleanupEligible",
|
|
1130
|
+
"workspaceId",
|
|
1131
|
+
"taskId",
|
|
1132
|
+
"lifecycleState",
|
|
1133
|
+
"health",
|
|
1134
|
+
"lastVerifiedAt",
|
|
1135
|
+
"orphanId",
|
|
1136
|
+
];
|
|
1137
|
+
// Pure: build a content-free health entry for a persisted instance from its (content-free) fields plus
|
|
1138
|
+
// the pure evaluation. Used by the server health service so the report shape cannot diverge from the
|
|
1139
|
+
// classifier output.
|
|
1140
|
+
export function deriveWorkspaceHealthEntry(input) {
|
|
1141
|
+
return {
|
|
1142
|
+
schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
|
|
1143
|
+
kind: "instance",
|
|
1144
|
+
classification: input.evaluation.classification,
|
|
1145
|
+
driftMarkers: input.evaluation.driftMarkers,
|
|
1146
|
+
recoveryHints: input.evaluation.recoveryHints,
|
|
1147
|
+
cleanupEligible: input.evaluation.cleanupEligible,
|
|
1148
|
+
workspaceId: input.workspaceId,
|
|
1149
|
+
taskId: input.taskId,
|
|
1150
|
+
lifecycleState: input.lifecycleState,
|
|
1151
|
+
health: input.health,
|
|
1152
|
+
...(input.lastVerifiedAt !== undefined ? { lastVerifiedAt: input.lastVerifiedAt } : {}),
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
// Pure: build a content-free health entry for an orphaned managed worktree (no persisted record). It
|
|
1156
|
+
// always classifies `orphaned`; `cleanupEligible` reflects whether the live safety gate cleared it.
|
|
1157
|
+
export function deriveOrphanWorktreeHealthEntry(input) {
|
|
1158
|
+
return {
|
|
1159
|
+
schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
|
|
1160
|
+
kind: "orphan-worktree",
|
|
1161
|
+
classification: "orphaned",
|
|
1162
|
+
driftMarkers: [],
|
|
1163
|
+
recoveryHints: [],
|
|
1164
|
+
cleanupEligible: input.cleanupEligible,
|
|
1165
|
+
orphanId: input.orphanId,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
// eslint-disable-next-line complexity
|
|
1169
|
+
export function validateWorkspaceHealthEntry(input) {
|
|
1170
|
+
if (!isRecord(input))
|
|
1171
|
+
return { ok: false, reasons: ["entry must be an object"] };
|
|
1172
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_HEALTH_ENTRY_ALLOWED_KEYS);
|
|
1173
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
1174
|
+
reasons.push("schemaVersion invalid");
|
|
1175
|
+
if (!isWorkspaceHealthEntryKind(input.kind))
|
|
1176
|
+
reasons.push("kind invalid");
|
|
1177
|
+
if (!isWorkspaceHealthClassification(input.classification))
|
|
1178
|
+
reasons.push("classification invalid");
|
|
1179
|
+
if (!Array.isArray(input.driftMarkers) || !input.driftMarkers.every(isTaskWorkspaceDriftMarker)) {
|
|
1180
|
+
reasons.push("driftMarkers must be an array of valid markers");
|
|
1181
|
+
}
|
|
1182
|
+
validateRecoveryHints(input.recoveryHints, reasons);
|
|
1183
|
+
if (!isBoolean(input.cleanupEligible))
|
|
1184
|
+
reasons.push("cleanupEligible must be a boolean");
|
|
1185
|
+
if (input.kind === "instance") {
|
|
1186
|
+
for (const key of ["workspaceId", "taskId"]) {
|
|
1187
|
+
if (!isNonEmptyString(input[key]))
|
|
1188
|
+
reasons.push(`${key} must be a non-empty string`);
|
|
1189
|
+
}
|
|
1190
|
+
if (!isTaskWorkspaceLifecycleState(input.lifecycleState))
|
|
1191
|
+
reasons.push("lifecycleState invalid");
|
|
1192
|
+
if (!isTaskWorkspaceHealth(input.health))
|
|
1193
|
+
reasons.push("health invalid");
|
|
1194
|
+
if (input.orphanId !== undefined)
|
|
1195
|
+
reasons.push("orphanId not allowed for an instance entry");
|
|
1196
|
+
}
|
|
1197
|
+
else if (input.kind === "orphan-worktree") {
|
|
1198
|
+
if (!isNonEmptyString(input.orphanId))
|
|
1199
|
+
reasons.push("orphanId must be a non-empty string");
|
|
1200
|
+
if (input.classification !== "orphaned")
|
|
1201
|
+
reasons.push("orphan entry must classify as orphaned");
|
|
1202
|
+
for (const key of ["workspaceId", "taskId", "lifecycleState", "health"]) {
|
|
1203
|
+
if (input[key] !== undefined)
|
|
1204
|
+
reasons.push(`${key} not allowed for an orphan entry`);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (input.lastVerifiedAt !== undefined && !isNonEmptyString(input.lastVerifiedAt)) {
|
|
1208
|
+
reasons.push("lastVerifiedAt must be a non-empty string when present");
|
|
1209
|
+
}
|
|
1210
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
1211
|
+
}
|
|
1212
|
+
export const WORKSPACE_HEALTH_REPORT_ALLOWED_KEYS = [
|
|
1213
|
+
"schemaVersion",
|
|
1214
|
+
"generatedAt",
|
|
1215
|
+
"entries",
|
|
1216
|
+
];
|
|
1217
|
+
export function validateWorkspaceHealthReport(input) {
|
|
1218
|
+
if (!isRecord(input))
|
|
1219
|
+
return { ok: false, reasons: ["report must be an object"] };
|
|
1220
|
+
const reasons = unknownKeyReasons(input, WORKSPACE_HEALTH_REPORT_ALLOWED_KEYS);
|
|
1221
|
+
if (input.schemaVersion !== TASK_WORKSPACE_SCHEMA_VERSION)
|
|
1222
|
+
reasons.push("schemaVersion invalid");
|
|
1223
|
+
if (!isNonEmptyString(input.generatedAt))
|
|
1224
|
+
reasons.push("generatedAt must be a non-empty string");
|
|
1225
|
+
if (!Array.isArray(input.entries))
|
|
1226
|
+
reasons.push("entries must be an array");
|
|
1227
|
+
else {
|
|
1228
|
+
input.entries.forEach((entry, index) => {
|
|
1229
|
+
const entryValidation = validateWorkspaceHealthEntry(entry);
|
|
1230
|
+
if (!entryValidation.ok) {
|
|
1231
|
+
reasons.push(`entries[${String(index)}]: ${entryValidation.reasons.join("; ")}`);
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
|
|
1236
|
+
}
|