@oscharko-dev/keiko-server 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/chat-handlers.d.ts +18 -2
- package/dist/chat-handlers.d.ts.map +1 -1
- package/dist/chat-handlers.js +185 -3
- package/dist/command-runner-errors.d.ts +17 -0
- package/dist/command-runner-errors.d.ts.map +1 -0
- package/dist/command-runner-errors.js +37 -0
- package/dist/command-runner-evidence.d.ts +23 -0
- package/dist/command-runner-evidence.d.ts.map +1 -0
- package/dist/command-runner-evidence.js +69 -0
- package/dist/command-runner-routes.d.ts +7 -0
- package/dist/command-runner-routes.d.ts.map +1 -0
- package/dist/command-runner-routes.js +175 -0
- package/dist/command-runner.d.ts +29 -0
- package/dist/command-runner.d.ts.map +1 -0
- package/dist/command-runner.js +348 -0
- package/dist/conversation-prompt.d.ts +2 -2
- package/dist/conversation-prompt.d.ts.map +1 -1
- package/dist/conversation-prompt.js +17 -1
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +3 -0
- package/dist/deps.d.ts +27 -1
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +288 -13
- package/dist/discussion-prompt.d.ts +4 -0
- package/dist/discussion-prompt.d.ts.map +1 -0
- package/dist/discussion-prompt.js +19 -0
- package/dist/editor/agentActionAudit.d.ts +18 -0
- package/dist/editor/agentActionAudit.d.ts.map +1 -0
- package/dist/editor/agentActionAudit.js +80 -0
- package/dist/editor/agentRoutes.d.ts +1 -0
- package/dist/editor/agentRoutes.d.ts.map +1 -1
- package/dist/editor/agentRoutes.js +292 -55
- package/dist/editor/agentSessionRegistry.d.ts +35 -0
- package/dist/editor/agentSessionRegistry.d.ts.map +1 -0
- package/dist/editor/agentSessionRegistry.js +243 -0
- package/dist/editor/completionRoutes.d.ts.map +1 -1
- package/dist/editor/completionRoutes.js +5 -10
- package/dist/editor/languageRoutes.d.ts +12 -1
- package/dist/editor/languageRoutes.d.ts.map +1 -1
- package/dist/editor/languageRoutes.js +71 -8
- package/dist/editor/languageService.d.ts +3 -2
- package/dist/editor/languageService.d.ts.map +1 -1
- package/dist/editor/languageService.js +41 -3
- package/dist/editor/languageServiceHost.d.ts.map +1 -1
- package/dist/editor/languageServiceHost.js +2 -2
- package/dist/editor/lsp/hostLanguageOperation.d.ts +17 -0
- package/dist/editor/lsp/hostLanguageOperation.d.ts.map +1 -0
- package/dist/editor/lsp/hostLanguageOperation.js +436 -0
- package/dist/editor/lsp/hostLanguageProviders.d.ts +26 -0
- package/dist/editor/lsp/hostLanguageProviders.d.ts.map +1 -0
- package/dist/editor/lsp/hostLanguageProviders.js +161 -0
- package/dist/editor/lsp/lspFrameCodec.d.ts +13 -0
- package/dist/editor/lsp/lspFrameCodec.d.ts.map +1 -0
- package/dist/editor/lsp/lspFrameCodec.js +164 -0
- package/dist/editor/lsp/lspJsonRpcClient.d.ts +34 -0
- package/dist/editor/lsp/lspJsonRpcClient.d.ts.map +1 -0
- package/dist/editor/lsp/lspJsonRpcClient.js +173 -0
- package/dist/editor/lsp/lspLanguageProvider.d.ts +7 -0
- package/dist/editor/lsp/lspLanguageProvider.d.ts.map +1 -0
- package/dist/editor/lsp/lspLanguageProvider.js +29 -0
- package/dist/editor/lsp/lspLifecycleLedger.d.ts +5 -0
- package/dist/editor/lsp/lspLifecycleLedger.d.ts.map +1 -0
- package/dist/editor/lsp/lspLifecycleLedger.js +37 -0
- package/dist/editor/lsp/lspNodeAdapter.d.ts +31 -0
- package/dist/editor/lsp/lspNodeAdapter.d.ts.map +1 -0
- package/dist/editor/lsp/lspNodeAdapter.js +230 -0
- package/dist/editor/lsp/lspProcessManager.d.ts +24 -0
- package/dist/editor/lsp/lspProcessManager.d.ts.map +1 -0
- package/dist/editor/lsp/lspProcessManager.js +255 -0
- package/dist/editor/lsp/lspRestartThrottle.d.ts +6 -0
- package/dist/editor/lsp/lspRestartThrottle.d.ts.map +1 -0
- package/dist/editor/lsp/lspRestartThrottle.js +24 -0
- package/dist/editor/lsp/lspStatusRoute.d.ts +8 -0
- package/dist/editor/lsp/lspStatusRoute.d.ts.map +1 -0
- package/dist/editor/lsp/lspStatusRoute.js +22 -0
- package/dist/editor/lsp/lspTransport.d.ts +19 -0
- package/dist/editor/lsp/lspTransport.d.ts.map +1 -0
- package/dist/editor/lsp/lspTransport.js +55 -0
- package/dist/editor/lsp/testing/fakeLspProcess.d.ts +23 -0
- package/dist/editor/lsp/testing/fakeLspProcess.d.ts.map +1 -0
- package/dist/editor/lsp/testing/fakeLspProcess.js +132 -0
- package/dist/files.d.ts +45 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +631 -7
- package/dist/gateway-readiness.js +3 -3
- package/dist/gateway-setup.d.ts +2 -0
- package/dist/gateway-setup.d.ts.map +1 -1
- package/dist/gateway-setup.js +275 -11
- package/dist/gitDelivery/actionSheetProjection.d.ts +30 -0
- package/dist/gitDelivery/actionSheetProjection.d.ts.map +1 -0
- package/dist/gitDelivery/actionSheetProjection.js +206 -0
- package/dist/gitDelivery/actionSheetRoutes.d.ts +29 -0
- package/dist/gitDelivery/actionSheetRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/actionSheetRoutes.js +293 -0
- package/dist/gitDelivery/agentOperationsRoutes.d.ts +33 -0
- package/dist/gitDelivery/agentOperationsRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/agentOperationsRoutes.js +405 -0
- package/dist/gitDelivery/commitRoutes.d.ts +23 -0
- package/dist/gitDelivery/commitRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/commitRoutes.js +204 -0
- package/dist/gitDelivery/evidenceRoutes.d.ts +9 -0
- package/dist/gitDelivery/evidenceRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/evidenceRoutes.js +101 -0
- package/dist/gitDelivery/execution.d.ts +38 -0
- package/dist/gitDelivery/execution.d.ts.map +1 -0
- package/dist/gitDelivery/execution.js +117 -0
- package/dist/gitDelivery/localMutationRoutes.d.ts +30 -0
- package/dist/gitDelivery/localMutationRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/localMutationRoutes.js +165 -0
- package/dist/gitDelivery/mergeExecution.d.ts +63 -0
- package/dist/gitDelivery/mergeExecution.d.ts.map +1 -0
- package/dist/gitDelivery/mergeExecution.js +168 -0
- package/dist/gitDelivery/mergeRoutes.d.ts +12 -0
- package/dist/gitDelivery/mergeRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/mergeRoutes.js +218 -0
- package/dist/gitDelivery/mutationEvidenceLedger.d.ts +23 -0
- package/dist/gitDelivery/mutationEvidenceLedger.d.ts.map +1 -0
- package/dist/gitDelivery/mutationEvidenceLedger.js +87 -0
- package/dist/gitDelivery/prExecution.d.ts +54 -0
- package/dist/gitDelivery/prExecution.d.ts.map +1 -0
- package/dist/gitDelivery/prExecution.js +192 -0
- package/dist/gitDelivery/prRoutes.d.ts +12 -0
- package/dist/gitDelivery/prRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/prRoutes.js +256 -0
- package/dist/gitDelivery/pushExecution.d.ts +43 -0
- package/dist/gitDelivery/pushExecution.d.ts.map +1 -0
- package/dist/gitDelivery/pushExecution.js +124 -0
- package/dist/gitDelivery/pushRoutes.d.ts +12 -0
- package/dist/gitDelivery/pushRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/pushRoutes.js +200 -0
- package/dist/gitDelivery/requestGuards.d.ts +15 -0
- package/dist/gitDelivery/requestGuards.d.ts.map +1 -0
- package/dist/gitDelivery/requestGuards.js +97 -0
- package/dist/gitDelivery/syncEvidence.d.ts +37 -0
- package/dist/gitDelivery/syncEvidence.d.ts.map +1 -0
- package/dist/gitDelivery/syncEvidence.js +85 -0
- package/dist/gitDelivery/syncExecution.d.ts +30 -0
- package/dist/gitDelivery/syncExecution.d.ts.map +1 -0
- package/dist/gitDelivery/syncExecution.js +266 -0
- package/dist/gitDelivery/syncRoutes.d.ts +13 -0
- package/dist/gitDelivery/syncRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/syncRoutes.js +200 -0
- package/dist/gitPorcelainStatus.d.ts +15 -0
- package/dist/gitPorcelainStatus.d.ts.map +1 -0
- package/dist/gitPorcelainStatus.js +104 -0
- package/dist/gitRepositoryReads.d.ts +10 -0
- package/dist/gitRepositoryReads.d.ts.map +1 -0
- package/dist/gitRepositoryReads.js +314 -0
- package/dist/gitRepositoryRoutes.d.ts +7 -0
- package/dist/gitRepositoryRoutes.d.ts.map +1 -0
- package/dist/gitRepositoryRoutes.js +221 -0
- package/dist/gitRoutes.d.ts +66 -0
- package/dist/gitRoutes.d.ts.map +1 -0
- package/dist/gitRoutes.js +543 -0
- package/dist/governed-workflow.d.ts +2 -0
- package/dist/governed-workflow.d.ts.map +1 -1
- package/dist/governed-workflow.js +4 -0
- package/dist/grounded-qa.d.ts +11 -0
- package/dist/grounded-qa.d.ts.map +1 -1
- package/dist/grounded-qa.js +13 -4
- package/dist/headers.d.ts +4 -1
- package/dist/headers.d.ts.map +1 -1
- package/dist/headers.js +11 -4
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts +1 -1
- package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts.map +1 -1
- package/dist/qualityIntelligence/figmaSnapshotRoutes.js +1 -1
- package/dist/read-handlers.d.ts +5 -0
- package/dist/read-handlers.d.ts.map +1 -1
- package/dist/read-handlers.js +57 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +259 -6
- package/dist/run-engine.d.ts.map +1 -1
- package/dist/run-engine.js +3 -0
- package/dist/run-handlers.d.ts.map +1 -1
- package/dist/run-handlers.js +74 -4
- package/dist/run-request.d.ts +11 -0
- package/dist/run-request.d.ts.map +1 -1
- package/dist/run-request.js +158 -10
- package/dist/runtime/capabilityDetector.d.ts +38 -0
- package/dist/runtime/capabilityDetector.d.ts.map +1 -0
- package/dist/runtime/capabilityDetector.js +443 -0
- package/dist/runtime/capabilityRoutes.d.ts +9 -0
- package/dist/runtime/capabilityRoutes.d.ts.map +1 -0
- package/dist/runtime/capabilityRoutes.js +45 -0
- package/dist/runtime/containerEngineDetector.d.ts +17 -0
- package/dist/runtime/containerEngineDetector.d.ts.map +1 -0
- package/dist/runtime/containerEngineDetector.js +222 -0
- package/dist/runtime/containerRoutes.d.ts +8 -0
- package/dist/runtime/containerRoutes.d.ts.map +1 -0
- package/dist/runtime/containerRoutes.js +207 -0
- package/dist/runtime/containerRunner-errors.d.ts +18 -0
- package/dist/runtime/containerRunner-errors.d.ts.map +1 -0
- package/dist/runtime/containerRunner-errors.js +42 -0
- package/dist/runtime/containerRunner-evidence.d.ts +24 -0
- package/dist/runtime/containerRunner-evidence.d.ts.map +1 -0
- package/dist/runtime/containerRunner-evidence.js +74 -0
- package/dist/runtime/containerRunner.d.ts +37 -0
- package/dist/runtime/containerRunner.d.ts.map +1 -0
- package/dist/runtime/containerRunner.js +443 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +24 -4
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.d.ts.map +1 -1
- package/dist/store/schema.js +62 -1
- package/dist/task-workspace/active-store.d.ts +21 -0
- package/dist/task-workspace/active-store.d.ts.map +1 -0
- package/dist/task-workspace/active-store.js +55 -0
- package/dist/task-workspace/authorization.d.ts +7 -0
- package/dist/task-workspace/authorization.d.ts.map +1 -0
- package/dist/task-workspace/authorization.js +54 -0
- package/dist/task-workspace/binding.d.ts +3 -0
- package/dist/task-workspace/binding.d.ts.map +1 -0
- package/dist/task-workspace/binding.js +22 -0
- package/dist/task-workspace/cleanup.d.ts +4 -0
- package/dist/task-workspace/cleanup.d.ts.map +1 -0
- package/dist/task-workspace/cleanup.js +428 -0
- package/dist/task-workspace/errors.d.ts +14 -0
- package/dist/task-workspace/errors.d.ts.map +1 -0
- package/dist/task-workspace/errors.js +81 -0
- package/dist/task-workspace/evidence.d.ts +32 -0
- package/dist/task-workspace/evidence.d.ts.map +1 -0
- package/dist/task-workspace/evidence.js +52 -0
- package/dist/task-workspace/field-safety.d.ts +3 -0
- package/dist/task-workspace/field-safety.d.ts.map +1 -0
- package/dist/task-workspace/field-safety.js +42 -0
- package/dist/task-workspace/health.d.ts +4 -0
- package/dist/task-workspace/health.d.ts.map +1 -0
- package/dist/task-workspace/health.js +163 -0
- package/dist/task-workspace/lifecycle.d.ts +3 -0
- package/dist/task-workspace/lifecycle.d.ts.map +1 -0
- package/dist/task-workspace/lifecycle.js +248 -0
- package/dist/task-workspace/locks.d.ts +13 -0
- package/dist/task-workspace/locks.d.ts.map +1 -0
- package/dist/task-workspace/locks.js +44 -0
- package/dist/task-workspace/managed-root.d.ts +7 -0
- package/dist/task-workspace/managed-root.d.ts.map +1 -0
- package/dist/task-workspace/managed-root.js +98 -0
- package/dist/task-workspace/mutex.d.ts +8 -0
- package/dist/task-workspace/mutex.d.ts.map +1 -0
- package/dist/task-workspace/mutex.js +82 -0
- package/dist/task-workspace/naming.d.ts +15 -0
- package/dist/task-workspace/naming.d.ts.map +1 -0
- package/dist/task-workspace/naming.js +0 -0
- package/dist/task-workspace/provisioning.d.ts +3 -0
- package/dist/task-workspace/provisioning.d.ts.map +1 -0
- package/dist/task-workspace/provisioning.js +528 -0
- package/dist/task-workspace/reconciliation.d.ts +15 -0
- package/dist/task-workspace/reconciliation.d.ts.map +1 -0
- package/dist/task-workspace/reconciliation.js +274 -0
- package/dist/task-workspace/repair.d.ts +3 -0
- package/dist/task-workspace/repair.d.ts.map +1 -0
- package/dist/task-workspace/repair.js +286 -0
- package/dist/task-workspace/routes.d.ts +19 -0
- package/dist/task-workspace/routes.d.ts.map +1 -0
- package/dist/task-workspace/routes.js +481 -0
- package/dist/task-workspace/store.d.ts +12 -0
- package/dist/task-workspace/store.d.ts.map +1 -0
- package/dist/task-workspace/store.js +128 -0
- package/dist/task-workspace/types.d.ts +170 -0
- package/dist/task-workspace/types.d.ts.map +1 -0
- package/dist/task-workspace/types.js +5 -0
- package/dist/voice-action-governance.d.ts +23 -0
- package/dist/voice-action-governance.d.ts.map +1 -0
- package/dist/voice-action-governance.js +126 -0
- package/dist/voice-handlers.d.ts +6 -0
- package/dist/voice-handlers.d.ts.map +1 -0
- package/dist/voice-handlers.js +570 -0
- package/dist/voice-realtime-grounded-tool.d.ts +31 -0
- package/dist/voice-realtime-grounded-tool.d.ts.map +1 -0
- package/dist/voice-realtime-grounded-tool.js +322 -0
- package/dist/voice-realtime.d.ts +69 -0
- package/dist/voice-realtime.d.ts.map +1 -0
- package/dist/voice-realtime.js +787 -0
- package/dist/workspace-state-handlers.d.ts +5 -0
- package/dist/workspace-state-handlers.d.ts.map +1 -0
- package/dist/workspace-state-handlers.js +106 -0
- package/package.json +20 -19
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Startup reconciliation for managed task workspaces (Issue #447, Epic #443).
|
|
2
|
+
//
|
|
3
|
+
// This turns the durable WorkspaceInstance store (#445) and the active pointer (#446) into trustworthy
|
|
4
|
+
// OPERATIONAL state after restarts, partial failures, external filesystem changes, or git worktree
|
|
5
|
+
// drift. For each persisted instance it gathers content-free FACTS by IO — realpath containment of the
|
|
6
|
+
// persisted path inside the managed root (a persisted path is NEVER trusted without realpath
|
|
7
|
+
// verification, SC), the `.git` linked-worktree pointer state + its content-free identity, the task
|
|
8
|
+
// branch presence and the worktree HEAD via the narrow #445 worktree adapter, and the lock liveness —
|
|
9
|
+
// then defers ALL the decision logic to the pure #444/#447 contract classifier. The classification is
|
|
10
|
+
// persisted within LEGAL lifecycle transitions (an operational workspace whose disk state is no longer
|
|
11
|
+
// trustworthy is flagged `recovery-required`, never silently dropped, AC2), and one content-free
|
|
12
|
+
// evidence document is appended per instance.
|
|
13
|
+
//
|
|
14
|
+
// It REUSES the existing subsystems and adds none: containment is delegated to the same
|
|
15
|
+
// managed-root/keiko-workspace helper provisioning uses, git inspection runs through the SAME narrow
|
|
16
|
+
// keiko-tools worktree adapter (no `git status`, no allowlist widening), and the durable home is the
|
|
17
|
+
// SAME store. Restoration of the last active workspace is conservative — it never auto-selects among
|
|
18
|
+
// ambiguous active workspaces (SC).
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { assertContainedRealPath, detectWorkspaceAt, PathEscapeError, resolveWithinWorkspace, } from "@oscharko-dev/keiko-workspace";
|
|
23
|
+
import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
|
|
24
|
+
import { TASK_WORKSPACE_SCHEMA_VERSION, classifyWorkspaceReconciliation, deriveReconciliationEntry, reconciliationHealth, reconciliationRequiresRecoveryFlag, resolveActiveRestoration, validateTaskWorkspaceTransition, } from "@oscharko-dev/keiko-contracts";
|
|
25
|
+
import { deriveRepositoryId } from "./naming.js";
|
|
26
|
+
import { lockIsLive, resolveLockTtl } from "./locks.js";
|
|
27
|
+
import { appendWorkspaceLifecycleEvidence, buildWorkspaceEvent, WORKSPACE_LIFECYCLE_EVIDENCE_KIND, } from "./evidence.js";
|
|
28
|
+
function isoFrom(nowMs) {
|
|
29
|
+
return new Date(nowMs).toISOString();
|
|
30
|
+
}
|
|
31
|
+
// Non-throwing content-free identity of a worktree's git admin dir, or undefined when the `.git`
|
|
32
|
+
// linked-worktree pointer is missing/malformed. Mirrors the throwing variant in provisioning.ts but
|
|
33
|
+
// returns undefined so reconciliation can classify a stale pointer instead of failing.
|
|
34
|
+
function safeGitdirIdentity(worktreePath) {
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
const dotGit = join(worktreePath, ".git");
|
|
38
|
+
if (statSync(dotGit).isDirectory())
|
|
39
|
+
return undefined;
|
|
40
|
+
raw = readFileSync(dotGit, "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const match = /^gitdir:\s*(.+)\s*$/mu.exec(raw);
|
|
46
|
+
if (match?.[1] === undefined || match[1].length === 0)
|
|
47
|
+
return undefined;
|
|
48
|
+
return createHash("sha256").update(match[1].trim(), "utf8").digest("hex").slice(0, 32);
|
|
49
|
+
}
|
|
50
|
+
// Realpath-aware containment check delegated to keiko-workspace (same engine provisioning uses). True
|
|
51
|
+
// when the persisted managed path still resolves inside the managed root after symlink resolution; any
|
|
52
|
+
// escape OR an unverifiable parent chain is treated conservatively as not-contained, so reconciliation
|
|
53
|
+
// never trusts a persisted path it cannot prove (SC).
|
|
54
|
+
function isContained(managedRoot, worktreePath) {
|
|
55
|
+
try {
|
|
56
|
+
resolveWithinWorkspace(managedRoot, worktreePath);
|
|
57
|
+
assertContainedRealPath(nodeWorkspaceFs, managedRoot, worktreePath, "managed worktree path");
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof PathEscapeError)
|
|
62
|
+
return false;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function realpathOrSelf(target) {
|
|
67
|
+
try {
|
|
68
|
+
return realpathSync(target);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return target;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Finds the porcelain worktree-list entry whose path resolves to the managed worktree path. Compares
|
|
75
|
+
// realpaths so a symlinked managed root still matches the canonical path git reports.
|
|
76
|
+
function findWorktreeEntry(worktrees, worktreePath) {
|
|
77
|
+
const target = realpathOrSelf(worktreePath);
|
|
78
|
+
return worktrees.find((entry) => realpathOrSelf(entry.path) === target);
|
|
79
|
+
}
|
|
80
|
+
// `actor` scopes lock ownership: in a system reconciliation pass it is undefined, so ANY live lock
|
|
81
|
+
// defers (status `locked`); for a repair re-classification it is the requesting actor, so only a
|
|
82
|
+
// foreign live lock defers.
|
|
83
|
+
function computeLockFacts(lock, nowMs, ttlMs, actor) {
|
|
84
|
+
const lockLive = lockIsLive(lock, nowMs, ttlMs);
|
|
85
|
+
return {
|
|
86
|
+
lockPresent: lock !== null,
|
|
87
|
+
lockLive,
|
|
88
|
+
lockedByOtherActor: lockLive && (actor === undefined || lock?.owner !== actor),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Gathers the content-free reconciliation facts for one instance.
|
|
92
|
+
async function gatherFacts(ctx, adapter, worktrees, instance, nowMs, actor) {
|
|
93
|
+
const pathContained = isContained(ctx.deps.managedRoot, instance.managedWorktreePath);
|
|
94
|
+
const worktreeDirExists = pathContained && existsSync(instance.managedWorktreePath);
|
|
95
|
+
const identity = worktreeDirExists ? safeGitdirIdentity(instance.managedWorktreePath) : undefined;
|
|
96
|
+
const taskBranchPresent = worktreeDirExists
|
|
97
|
+
? await adapter.localBranchExists(instance.taskBranch)
|
|
98
|
+
: false;
|
|
99
|
+
const entry = worktreeDirExists
|
|
100
|
+
? findWorktreeEntry(worktrees, instance.managedWorktreePath)
|
|
101
|
+
: undefined;
|
|
102
|
+
const observedHead = entry?.head;
|
|
103
|
+
const lock = computeLockFacts(instance.lock, nowMs, ctx.lockTtlMs, actor);
|
|
104
|
+
return {
|
|
105
|
+
observedHead,
|
|
106
|
+
facts: {
|
|
107
|
+
lifecycleState: instance.lifecycleState,
|
|
108
|
+
pathContained,
|
|
109
|
+
worktreeDirExists,
|
|
110
|
+
gitPointerPresent: identity !== undefined,
|
|
111
|
+
gitdirIdentityMatches: identity !== undefined && identity === instance.gitdirIdentity,
|
|
112
|
+
taskBranchPresent,
|
|
113
|
+
headMatches: instance.lastVerifiedHead === undefined || instance.lastVerifiedHead === observedHead,
|
|
114
|
+
// Worktree cleanliness is NOT live-detected: the narrow #445 adapter has no `git status` verb and
|
|
115
|
+
// widening it would weaken the governed-mutation boundary. A previously recorded dirty signal is
|
|
116
|
+
// preserved so it still surfaces; live cleanliness is #448's responsibility.
|
|
117
|
+
uncommittedChanges: instance.driftMarkers.includes("uncommitted-changes"),
|
|
118
|
+
lockPresent: lock.lockPresent,
|
|
119
|
+
lockLive: lock.lockLive,
|
|
120
|
+
lockedByOtherActor: lock.lockedByOtherActor,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function reconcileEventType(outcome, flaggedRecovery) {
|
|
125
|
+
if (flaggedRecovery)
|
|
126
|
+
return "recovery-flagged";
|
|
127
|
+
if (outcome.driftMarkers.length > 0)
|
|
128
|
+
return "drift-detected";
|
|
129
|
+
return "health-changed";
|
|
130
|
+
}
|
|
131
|
+
function emitReconcileEvidence(ctx, instance, fromState, outcome, flaggedRecovery, nowMs) {
|
|
132
|
+
const event = buildWorkspaceEvent({
|
|
133
|
+
eventId: ctx.deps.newId(),
|
|
134
|
+
workspaceId: instance.workspaceId,
|
|
135
|
+
taskId: instance.taskId,
|
|
136
|
+
type: reconcileEventType(outcome, flaggedRecovery),
|
|
137
|
+
at: isoFrom(nowMs),
|
|
138
|
+
correlationId: instance.auditCorrelationId,
|
|
139
|
+
fromState,
|
|
140
|
+
toState: instance.lifecycleState,
|
|
141
|
+
health: instance.health,
|
|
142
|
+
...(outcome.driftMarkers.length > 0 ? { driftMarkers: outcome.driftMarkers } : {}),
|
|
143
|
+
});
|
|
144
|
+
appendWorkspaceLifecycleEvidence(ctx.deps.evidenceStore, {
|
|
145
|
+
kind: WORKSPACE_LIFECYCLE_EVIDENCE_KIND,
|
|
146
|
+
schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
|
|
147
|
+
recordedAt: nowMs,
|
|
148
|
+
operation: "reconcile",
|
|
149
|
+
outcome: "reconciled",
|
|
150
|
+
attempt: 1,
|
|
151
|
+
durationMs: 0,
|
|
152
|
+
worktreeCount: 0,
|
|
153
|
+
event,
|
|
154
|
+
}, ctx.deps.redactString);
|
|
155
|
+
}
|
|
156
|
+
// Classifies one instance against pre-fetched git state, persists the classification within a legal
|
|
157
|
+
// transition, and appends evidence. Pure decision-making is delegated to the contract; this only does
|
|
158
|
+
// IO + persistence.
|
|
159
|
+
function reconcileWithContext(ctx, facts, observedHead, instance, nowMs) {
|
|
160
|
+
const outcome = classifyWorkspaceReconciliation(facts);
|
|
161
|
+
const health = reconciliationHealth(outcome.status);
|
|
162
|
+
const fromState = instance.lifecycleState;
|
|
163
|
+
let targetState = fromState;
|
|
164
|
+
if (reconciliationRequiresRecoveryFlag(outcome.status, fromState)) {
|
|
165
|
+
const transition = validateTaskWorkspaceTransition({
|
|
166
|
+
from: fromState,
|
|
167
|
+
to: "recovery-required",
|
|
168
|
+
context: {
|
|
169
|
+
lockHeldByActor: false,
|
|
170
|
+
pathContained: facts.pathContained,
|
|
171
|
+
worktreeClean: false,
|
|
172
|
+
branchReady: false,
|
|
173
|
+
providerReady: false,
|
|
174
|
+
operatorApproved: false,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
if (transition.ok)
|
|
178
|
+
targetState = "recovery-required";
|
|
179
|
+
}
|
|
180
|
+
const iso = isoFrom(nowMs);
|
|
181
|
+
const persisted = ctx.deps.store.upsert({
|
|
182
|
+
...instance,
|
|
183
|
+
lifecycleState: targetState,
|
|
184
|
+
health,
|
|
185
|
+
driftMarkers: outcome.driftMarkers,
|
|
186
|
+
recoveryHints: outcome.recoveryHints,
|
|
187
|
+
lastVerifiedAt: iso,
|
|
188
|
+
updatedAt: iso,
|
|
189
|
+
...(outcome.status === "healthy" && observedHead !== undefined
|
|
190
|
+
? { lastVerifiedHead: observedHead }
|
|
191
|
+
: {}),
|
|
192
|
+
});
|
|
193
|
+
emitReconcileEvidence(ctx, persisted, fromState, outcome, targetState !== fromState, nowMs);
|
|
194
|
+
return { instance: persisted, outcome };
|
|
195
|
+
}
|
|
196
|
+
// Gathers the content-free reconciliation facts for one instance against a PRE-FETCHED adapter +
|
|
197
|
+
// worktree list, WITHOUT persisting or classifying. Exported so the #448 health service can build the
|
|
198
|
+
// same WorkspaceReconciliationFacts the reconciler uses (no second containment/git engine) and then
|
|
199
|
+
// layer its live dirty + ownership signals on top.
|
|
200
|
+
export function gatherInstanceReconciliationFacts(deps, adapter, worktrees, instance, nowMs, actor) {
|
|
201
|
+
const ctx = { deps, lockTtlMs: resolveLockTtl(deps.lockTtlMs) };
|
|
202
|
+
return gatherFacts(ctx, adapter, worktrees, instance, nowMs, actor);
|
|
203
|
+
}
|
|
204
|
+
// Reconciles a SINGLE instance end to end (builds its own adapter + worktree list). Exported so the
|
|
205
|
+
// repair service re-classifies an instance through the exact same fact-gathering and persistence path.
|
|
206
|
+
export async function reconcileSingleInstance(deps, instance, nowMs, actor) {
|
|
207
|
+
const ctx = { deps, lockTtlMs: resolveLockTtl(deps.lockTtlMs) };
|
|
208
|
+
const adapter = deps.createAdapter(detectWorkspaceAt(instance.repositoryRoot));
|
|
209
|
+
const worktrees = await adapter.listWorktrees();
|
|
210
|
+
const { facts, observedHead } = await gatherFacts(ctx, adapter, worktrees, instance, nowMs, actor);
|
|
211
|
+
return reconcileWithContext(ctx, facts, observedHead, instance, nowMs);
|
|
212
|
+
}
|
|
213
|
+
function entryFromInstance(instance) {
|
|
214
|
+
return deriveReconciliationEntry({
|
|
215
|
+
workspaceId: instance.workspaceId,
|
|
216
|
+
taskId: instance.taskId,
|
|
217
|
+
lifecycleState: instance.lifecycleState,
|
|
218
|
+
health: instance.health,
|
|
219
|
+
driftMarkers: instance.driftMarkers,
|
|
220
|
+
recoveryHints: instance.recoveryHints,
|
|
221
|
+
...(instance.lastVerifiedAt !== undefined ? { lastVerifiedAt: instance.lastVerifiedAt } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Builds the report from a set of instances. `clearDangling` is true ONLY on the live reconcile path:
|
|
225
|
+
// clearing a dangling pointer is a state mutation, so the read-only report() must never perform it (it
|
|
226
|
+
// still REPORTS the `cleared-dangling` kind; the next live reconcile or getActive() self-heals it).
|
|
227
|
+
function buildReport(ctx, instances, nowMs, clearDangling) {
|
|
228
|
+
const entries = instances.map(entryFromInstance);
|
|
229
|
+
const pointer = ctx.deps.activePointerStore.get();
|
|
230
|
+
const restoration = resolveActiveRestoration(pointer?.workspaceId, entries);
|
|
231
|
+
if (clearDangling && restoration.kind === "cleared-dangling") {
|
|
232
|
+
ctx.deps.activePointerStore.clear();
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
|
|
236
|
+
generatedAt: isoFrom(nowMs),
|
|
237
|
+
entries,
|
|
238
|
+
activeRestoration: restoration,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function instancesFor(deps, repositoryRoot) {
|
|
242
|
+
if (repositoryRoot === undefined || repositoryRoot.length === 0)
|
|
243
|
+
return deps.store.listAll();
|
|
244
|
+
return deps.store.listByRepository(deriveRepositoryId(repositoryRoot));
|
|
245
|
+
}
|
|
246
|
+
// Live reconcile: group the in-scope instances by repository root so each repository's worktree list
|
|
247
|
+
// is fetched once, reconcile every instance, then build the report from the freshly persisted records.
|
|
248
|
+
async function reconcileImpl(ctx, repositoryRoot) {
|
|
249
|
+
const instances = instancesFor(ctx.deps, repositoryRoot);
|
|
250
|
+
const byRepo = new Map();
|
|
251
|
+
for (const instance of instances) {
|
|
252
|
+
const group = byRepo.get(instance.repositoryRoot) ?? [];
|
|
253
|
+
group.push(instance);
|
|
254
|
+
byRepo.set(instance.repositoryRoot, group);
|
|
255
|
+
}
|
|
256
|
+
const reconciled = [];
|
|
257
|
+
for (const [root, group] of byRepo) {
|
|
258
|
+
const adapter = ctx.deps.createAdapter(detectWorkspaceAt(root));
|
|
259
|
+
const worktrees = await adapter.listWorktrees();
|
|
260
|
+
for (const instance of group) {
|
|
261
|
+
const nowMs = ctx.deps.now();
|
|
262
|
+
const { facts, observedHead } = await gatherFacts(ctx, adapter, worktrees, instance, nowMs, undefined);
|
|
263
|
+
reconciled.push(reconcileWithContext(ctx, facts, observedHead, instance, nowMs).instance);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return buildReport(ctx, reconciled, ctx.deps.now(), true);
|
|
267
|
+
}
|
|
268
|
+
export function createWorkspaceReconciliationService(deps) {
|
|
269
|
+
const ctx = { deps, lockTtlMs: resolveLockTtl(deps.lockTtlMs) };
|
|
270
|
+
return {
|
|
271
|
+
report: (repositoryRoot) => buildReport(ctx, instancesFor(deps, repositoryRoot), deps.now(), false),
|
|
272
|
+
reconcile: (repositoryRoot) => reconcileImpl(ctx, repositoryRoot),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repair.d.ts","sourceRoot":"","sources":["../../src/task-workspace/repair.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAIV,sBAAsB,EACtB,0BAA0B,EAC3B,MAAM,YAAY,CAAC;AAsXpB,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,0BAA0B,GAC/B,sBAAsB,CAMxB"}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Controlled repair for managed task workspaces (Issue #447, Epic #443).
|
|
2
|
+
//
|
|
3
|
+
// Repair is the mutating counterpart to reconciliation. The #444 `repair` operation is lock-aware and
|
|
4
|
+
// operator-approval-gated, so this service NEVER mutates Git or the filesystem without (a) a fresh live
|
|
5
|
+
// reconciliation establishing the workspace genuinely needs the requested recovery, (b) the requested
|
|
6
|
+
// strategy being applicable to that reconciled state, and (c) explicit operator approval — and every
|
|
7
|
+
// repair appends content-free evidence (SC).
|
|
8
|
+
//
|
|
9
|
+
// It REUSES the existing engines, adds none (SC1): the worktree-recreating strategies delegate to the
|
|
10
|
+
// #445 provisioning re-materialization path (which rebuilds a missing worktree from the existing branch
|
|
11
|
+
// and refreshes a stale pointer through the SAME narrow worktree adapter, with its own rollback +
|
|
12
|
+
// evidence); `release-stale-lock` is a content-free store upsert clearing the lock field; and
|
|
13
|
+
// `abandon-and-cleanup` is a legal lifecycle transition to `abandoned` (physical cleanup stays #448's).
|
|
14
|
+
// Strategies that cannot be applied safely without a human — reattach a deleted branch, resolve a moved
|
|
15
|
+
// HEAD or uncommitted work, repair a containment escape — return an `operator-required` result with NO
|
|
16
|
+
// mutation.
|
|
17
|
+
import { detectWorkspaceAt } from "@oscharko-dev/keiko-workspace";
|
|
18
|
+
import { TASK_WORKSPACE_SCHEMA_VERSION, isLegalTaskWorkspaceTransition, isWorkspaceRecoveryStrategy, isWorkspaceRepairStrategyApplicable, reconciliationStatusFromInstance, validateTaskWorkspaceTransition, workspaceEntryOperatorActionRequired, } from "@oscharko-dev/keiko-contracts";
|
|
19
|
+
import { buildBinding } from "./binding.js";
|
|
20
|
+
import { TaskWorkspaceError } from "./errors.js";
|
|
21
|
+
import { assertSafeFieldValue } from "./field-safety.js";
|
|
22
|
+
import { lockIsLive, makeWorkspaceLock, resolveLockTtl } from "./locks.js";
|
|
23
|
+
import { workspaceKey } from "./mutex.js";
|
|
24
|
+
import { reconcileSingleInstance } from "./reconciliation.js";
|
|
25
|
+
import { appendWorkspaceLifecycleEvidence, buildWorkspaceEvent, WORKSPACE_LIFECYCLE_EVIDENCE_KIND, } from "./evidence.js";
|
|
26
|
+
const MAX_FIELD_LENGTH = 512;
|
|
27
|
+
function isBoundedNonEmpty(value) {
|
|
28
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_FIELD_LENGTH;
|
|
29
|
+
}
|
|
30
|
+
function isoFrom(nowMs) {
|
|
31
|
+
return new Date(nowMs).toISOString();
|
|
32
|
+
}
|
|
33
|
+
// The #444 `repair` operation declares requiresLock:true. The service reserves the workspace lock for
|
|
34
|
+
// the requesting actor before any mutation and releases it afterwards, mirroring the #445 provisioning
|
|
35
|
+
// lock lifecycle. The whole repair runs under the `ws:` in-process mutex (#449, ADR-0093 D1) so the
|
|
36
|
+
// advisory check → reconcile → acquire → mutate sequence is atomic against same-process callers; the
|
|
37
|
+
// advisory lock builder is the consolidated locks.ts helper.
|
|
38
|
+
function makeRepairLock(ctx, owner, nowMs) {
|
|
39
|
+
return makeWorkspaceLock({
|
|
40
|
+
newId: ctx.deps.newId,
|
|
41
|
+
owner,
|
|
42
|
+
reason: "repair",
|
|
43
|
+
nowMs,
|
|
44
|
+
ttlMs: ctx.lockTtlMs,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// Releases a still-held repair lock owned by this actor. The provision/release/abandon paths already
|
|
48
|
+
// persist their own terminal lock state, so this only fires when a repair errored before clearing it.
|
|
49
|
+
function releaseRepairLock(ctx, workspaceId, owner) {
|
|
50
|
+
const current = ctx.deps.store.getById(workspaceId);
|
|
51
|
+
if (current?.lock?.owner === owner && current.lock.reason === "repair") {
|
|
52
|
+
ctx.deps.store.upsert({ ...current, lock: null, updatedAt: isoFrom(ctx.deps.now()) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// The reconciliation deps are a subset of the repair deps, so the repair service re-classifies a single
|
|
56
|
+
// instance through the exact same fact-gathering and persistence path reconciliation uses.
|
|
57
|
+
function reconDeps(deps) {
|
|
58
|
+
return {
|
|
59
|
+
store: deps.store,
|
|
60
|
+
activePointerStore: deps.activePointerStore,
|
|
61
|
+
evidenceStore: deps.evidenceStore,
|
|
62
|
+
managedRoot: deps.managedRoot,
|
|
63
|
+
createAdapter: deps.createAdapter,
|
|
64
|
+
redactString: deps.redactString,
|
|
65
|
+
now: deps.now,
|
|
66
|
+
newId: deps.newId,
|
|
67
|
+
...(deps.lockTtlMs !== undefined ? { lockTtlMs: deps.lockTtlMs } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function emitRepair(ctx, instance, fromState, outcome, type, nowMs) {
|
|
71
|
+
const event = buildWorkspaceEvent({
|
|
72
|
+
eventId: ctx.deps.newId(),
|
|
73
|
+
workspaceId: instance.workspaceId,
|
|
74
|
+
taskId: instance.taskId,
|
|
75
|
+
type,
|
|
76
|
+
at: isoFrom(nowMs),
|
|
77
|
+
correlationId: instance.auditCorrelationId,
|
|
78
|
+
fromState,
|
|
79
|
+
toState: instance.lifecycleState,
|
|
80
|
+
health: instance.health,
|
|
81
|
+
...(instance.driftMarkers.length > 0 ? { driftMarkers: instance.driftMarkers } : {}),
|
|
82
|
+
});
|
|
83
|
+
appendWorkspaceLifecycleEvidence(ctx.deps.evidenceStore, {
|
|
84
|
+
kind: WORKSPACE_LIFECYCLE_EVIDENCE_KIND,
|
|
85
|
+
schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
|
|
86
|
+
recordedAt: nowMs,
|
|
87
|
+
operation: "repair",
|
|
88
|
+
outcome,
|
|
89
|
+
attempt: 1,
|
|
90
|
+
durationMs: 0,
|
|
91
|
+
worktreeCount: 0,
|
|
92
|
+
event,
|
|
93
|
+
}, ctx.deps.redactString);
|
|
94
|
+
}
|
|
95
|
+
function resultFor(instance, strategy, applied) {
|
|
96
|
+
const status = reconciliationStatusFromInstance({
|
|
97
|
+
lifecycleState: instance.lifecycleState,
|
|
98
|
+
health: instance.health,
|
|
99
|
+
driftMarkers: instance.driftMarkers,
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
instance,
|
|
103
|
+
binding: buildBinding(instance),
|
|
104
|
+
strategy,
|
|
105
|
+
applied,
|
|
106
|
+
outcome: applied ? "repaired" : "operator-required",
|
|
107
|
+
status,
|
|
108
|
+
driftMarkers: instance.driftMarkers,
|
|
109
|
+
operatorActionRequired: workspaceEntryOperatorActionRequired(instance.recoveryHints),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// release-stale-lock: clear the lock field (no Git/filesystem mutation, just a content-free store
|
|
113
|
+
// upsert), then re-reconcile so the classification recomputes without the stale-lock marker.
|
|
114
|
+
async function applyReleaseStaleLock(ctx, instance, requestedBy, nowMs) {
|
|
115
|
+
const cleared = ctx.deps.store.upsert({ ...instance, lock: null, updatedAt: isoFrom(nowMs) });
|
|
116
|
+
const reconciled = await reconcileSingleInstance(reconDeps(ctx.deps), cleared, ctx.deps.now(), requestedBy);
|
|
117
|
+
return reconciled.instance;
|
|
118
|
+
}
|
|
119
|
+
// abandon-and-cleanup: a legal lifecycle transition to `abandoned` (operator-approval gated). Physical
|
|
120
|
+
// worktree cleanup is deferred to #448; this only records the terminal decision and clears the active
|
|
121
|
+
// pointer if it referenced this workspace.
|
|
122
|
+
function applyAbandon(ctx, instance, nowMs) {
|
|
123
|
+
const transition = validateTaskWorkspaceTransition({
|
|
124
|
+
from: instance.lifecycleState,
|
|
125
|
+
to: "abandoned",
|
|
126
|
+
context: {
|
|
127
|
+
lockHeldByActor: true,
|
|
128
|
+
pathContained: false,
|
|
129
|
+
worktreeClean: false,
|
|
130
|
+
branchReady: false,
|
|
131
|
+
providerReady: false,
|
|
132
|
+
operatorApproved: true,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
if (!transition.ok) {
|
|
136
|
+
throw new TaskWorkspaceError("ILLEGAL_TRANSITION", `cannot abandon workspace from ${instance.lifecycleState}`, transition.reasons);
|
|
137
|
+
}
|
|
138
|
+
const abandoned = ctx.deps.store.upsert({
|
|
139
|
+
...instance,
|
|
140
|
+
lifecycleState: "abandoned",
|
|
141
|
+
health: "unknown",
|
|
142
|
+
lock: null,
|
|
143
|
+
driftMarkers: [],
|
|
144
|
+
recoveryHints: [],
|
|
145
|
+
updatedAt: isoFrom(nowMs),
|
|
146
|
+
});
|
|
147
|
+
if (ctx.deps.activePointerStore.get()?.workspaceId === abandoned.workspaceId) {
|
|
148
|
+
ctx.deps.activePointerStore.clear();
|
|
149
|
+
}
|
|
150
|
+
return abandoned;
|
|
151
|
+
}
|
|
152
|
+
// recreate-worktree / reconcile-pointer: delegate to the #445 provisioning re-materialization path,
|
|
153
|
+
// which rebuilds a missing worktree from the existing branch (or resume-completes an existing one and
|
|
154
|
+
// refreshes its pointer identity), with its own rollback + evidence. Reuse, not a second git engine.
|
|
155
|
+
//
|
|
156
|
+
// An EXTERNALLY deleted worktree directory leaves a stale entry in git's worktree admin metadata that
|
|
157
|
+
// still claims the task branch is "checked out" at the missing path, so a fresh `git worktree add`
|
|
158
|
+
// would be rejected. Pruning first (an allowlisted verb on the same narrow adapter) clears only the
|
|
159
|
+
// admin entries whose working directory is already gone — a still-present worktree is untouched — so
|
|
160
|
+
// the subsequent re-materialization can re-attach the branch.
|
|
161
|
+
async function applyProvisioningRepair(ctx, instance, requestedBy) {
|
|
162
|
+
await ctx.deps.createAdapter(detectWorkspaceAt(instance.repositoryRoot)).pruneWorktrees();
|
|
163
|
+
const result = await ctx.deps.provisioning.provision({
|
|
164
|
+
repositoryRequestPath: instance.repositoryRoot,
|
|
165
|
+
taskId: instance.taskId,
|
|
166
|
+
baseBranch: instance.baseBranch,
|
|
167
|
+
requestedBy,
|
|
168
|
+
});
|
|
169
|
+
return result.instance;
|
|
170
|
+
}
|
|
171
|
+
async function executeStrategy(ctx, instance, strategy, requestedBy, nowMs) {
|
|
172
|
+
switch (strategy) {
|
|
173
|
+
case "recreate-worktree":
|
|
174
|
+
case "reconcile-pointer":
|
|
175
|
+
return applyProvisioningRepair(ctx, instance, requestedBy);
|
|
176
|
+
case "release-stale-lock":
|
|
177
|
+
return applyReleaseStaleLock(ctx, instance, requestedBy, nowMs);
|
|
178
|
+
case "abandon-and-cleanup":
|
|
179
|
+
return applyAbandon(ctx, instance, nowMs);
|
|
180
|
+
default:
|
|
181
|
+
// reattach-branch / operator-repair / commit-or-stash-required never reach here (they are
|
|
182
|
+
// operator-required and short-circuit earlier); this is a defensive guard.
|
|
183
|
+
throw new TaskWorkspaceError("REPAIR_NOT_APPLICABLE", `strategy ${strategy} is not an automatic repair`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function validateRequest(request) {
|
|
187
|
+
if (!isBoundedNonEmpty(request.workspaceId) || !isBoundedNonEmpty(request.requestedBy)) {
|
|
188
|
+
throw new TaskWorkspaceError("INVALID_REQUEST", "invalid repair request");
|
|
189
|
+
}
|
|
190
|
+
// requestedBy becomes the repair advisory-lock owner; reject control/zero-width/bidi code points.
|
|
191
|
+
assertSafeFieldValue(request.requestedBy, "requestedBy");
|
|
192
|
+
if (!isWorkspaceRecoveryStrategy(request.strategy)) {
|
|
193
|
+
throw new TaskWorkspaceError("INVALID_REQUEST", "unknown recovery strategy");
|
|
194
|
+
}
|
|
195
|
+
return request.strategy;
|
|
196
|
+
}
|
|
197
|
+
// Whether the requested strategy needs a human regardless of approval: either it is one of the
|
|
198
|
+
// inherently operator-guided strategies, or reconciliation mapped its drift to an operator-required
|
|
199
|
+
// hint (e.g. a deleted branch).
|
|
200
|
+
function needsOperator(outcome, strategy) {
|
|
201
|
+
if (strategy === "operator-repair" ||
|
|
202
|
+
strategy === "commit-or-stash-required" ||
|
|
203
|
+
strategy === "reattach-branch") {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
const hint = outcome.recoveryHints.find((candidate) => candidate.strategy === strategy);
|
|
207
|
+
return hint?.operatorActionRequired === true;
|
|
208
|
+
}
|
|
209
|
+
// Emits the operator-required evidence and returns the no-mutation result for a recovery that needs a
|
|
210
|
+
// human (corrupt pointer, moved HEAD, uncommitted work, deleted branch).
|
|
211
|
+
function reportOperatorRequired(ctx, reconciled, strategy) {
|
|
212
|
+
emitRepair(ctx, reconciled, reconciled.lifecycleState, "operator-required", "transition-rejected", ctx.deps.now());
|
|
213
|
+
return resultFor(reconciled, strategy, false);
|
|
214
|
+
}
|
|
215
|
+
// The authorization gates before any mutation: operator approval (the #444 `repair` operation requires
|
|
216
|
+
// it), applicability of the requested strategy to the reconciled state, and — for abandon — that the
|
|
217
|
+
// current lifecycle can LEGALLY reach `abandoned` (an active/provisioning workspace must be paused or
|
|
218
|
+
// fail/recover first), refused cleanly as not-applicable rather than as a downstream illegal transition.
|
|
219
|
+
function assertRepairAuthorized(request, reconciled, outcome, strategy) {
|
|
220
|
+
if (!request.operatorApproved) {
|
|
221
|
+
throw new TaskWorkspaceError("OPERATOR_APPROVAL_REQUIRED", "repair requires operator approval");
|
|
222
|
+
}
|
|
223
|
+
if (!isWorkspaceRepairStrategyApplicable({
|
|
224
|
+
status: outcome.status,
|
|
225
|
+
recoveryHints: outcome.recoveryHints,
|
|
226
|
+
strategy,
|
|
227
|
+
})) {
|
|
228
|
+
throw new TaskWorkspaceError("REPAIR_NOT_APPLICABLE", `strategy ${strategy} is not applicable to a ${outcome.status} workspace`);
|
|
229
|
+
}
|
|
230
|
+
if (strategy === "abandon-and-cleanup" &&
|
|
231
|
+
!isLegalTaskWorkspaceTransition(reconciled.lifecycleState, "abandoned")) {
|
|
232
|
+
throw new TaskWorkspaceError("REPAIR_NOT_APPLICABLE", `a ${reconciled.lifecycleState} workspace must be paused or flagged for recovery before it can be abandoned`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function repairLocked(ctx, request) {
|
|
236
|
+
const strategy = validateRequest(request);
|
|
237
|
+
const existing = ctx.deps.store.getById(request.workspaceId);
|
|
238
|
+
if (existing === undefined) {
|
|
239
|
+
throw new TaskWorkspaceError("WORKSPACE_NOT_FOUND", "workspace not found");
|
|
240
|
+
}
|
|
241
|
+
const nowMs = ctx.deps.now();
|
|
242
|
+
if (lockIsLive(existing.lock, nowMs, ctx.lockTtlMs) &&
|
|
243
|
+
existing.lock?.owner !== request.requestedBy) {
|
|
244
|
+
throw new TaskWorkspaceError("LOCK_CONTENTION", "workspace is locked by another actor");
|
|
245
|
+
}
|
|
246
|
+
// Ground truth: re-reconcile this instance live before deciding anything (the persisted state may be
|
|
247
|
+
// stale — the worktree may have been fixed or drifted further since the last pass).
|
|
248
|
+
const { instance: reconciled, outcome } = await reconcileSingleInstance(reconDeps(ctx.deps), existing, nowMs, request.requestedBy);
|
|
249
|
+
// No automatic mutation is safe: report the operator requirement without touching Git/filesystem.
|
|
250
|
+
if (needsOperator(outcome, strategy)) {
|
|
251
|
+
return reportOperatorRequired(ctx, reconciled, strategy);
|
|
252
|
+
}
|
|
253
|
+
assertRepairAuthorized(request, reconciled, outcome, strategy);
|
|
254
|
+
// Acquire the workspace lock for the requesting actor before mutating (the #444 `repair` operation is
|
|
255
|
+
// requiresLock:true). executeStrategy persists its own terminal lock state (provision clears it,
|
|
256
|
+
// release-stale-lock/abandon set it null); the finally releases the repair lock only if it is still
|
|
257
|
+
// ours — so an error mid-repair never leaves the workspace locked.
|
|
258
|
+
const fromState = reconciled.lifecycleState;
|
|
259
|
+
const locked = ctx.deps.store.upsert({
|
|
260
|
+
...reconciled,
|
|
261
|
+
lock: makeRepairLock(ctx, request.requestedBy, nowMs),
|
|
262
|
+
updatedAt: isoFrom(nowMs),
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
const repaired = await executeStrategy(ctx, locked, strategy, request.requestedBy, nowMs);
|
|
266
|
+
// Read back the authoritative post-repair instance (the provisioning path persists its own state).
|
|
267
|
+
const finalInstance = ctx.deps.store.getById(repaired.workspaceId) ?? repaired;
|
|
268
|
+
emitRepair(ctx, finalInstance, fromState, "repaired", "repaired", ctx.deps.now());
|
|
269
|
+
return resultFor(finalInstance, strategy, true);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
releaseRepairLock(ctx, request.workspaceId, request.requestedBy);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Serializes the whole repair (advisory check → live reconcile → lock acquire → strategy mutation) under
|
|
276
|
+
// the workspace's `ws:` key (#449, ADR-0093 D1). The nested provisioning re-materialization runs under
|
|
277
|
+
// the distinct `prov:` key, so there is no self-deadlock against this `ws:` hold.
|
|
278
|
+
function repairImpl(ctx, request) {
|
|
279
|
+
return ctx.deps.mutex.runExclusive([workspaceKey(request.workspaceId)], () => repairLocked(ctx, request));
|
|
280
|
+
}
|
|
281
|
+
export function createWorkspaceRepairService(deps) {
|
|
282
|
+
const ctx = { deps, lockTtlMs: resolveLockTtl(deps.lockTtlMs) };
|
|
283
|
+
return {
|
|
284
|
+
repair: (request) => repairImpl(ctx, request),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RouteContext, type RouteResult } from "../routes.js";
|
|
2
|
+
import type { UiHandlerDeps } from "../deps.js";
|
|
3
|
+
export declare function handleProvisionTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
4
|
+
export declare function handleGetTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): RouteResult;
|
|
5
|
+
export declare function handleActivateTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
6
|
+
export declare function handleListTaskWorkspaces(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
7
|
+
export declare function handleGetActiveTaskWorkspace(_ctx: RouteContext, deps: UiHandlerDeps): RouteResult;
|
|
8
|
+
export declare function handleSetActiveTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
9
|
+
export declare function handleClearActiveTaskWorkspace(_ctx: RouteContext, deps: UiHandlerDeps): RouteResult;
|
|
10
|
+
export declare function handlePauseTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
11
|
+
export declare function handleResumeTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
12
|
+
export declare function handleHandoffTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
13
|
+
export declare function handleGetTaskWorkspaceReconciliation(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
14
|
+
export declare function handleReconcileTaskWorkspaces(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
15
|
+
export declare function handleRepairTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
16
|
+
export declare function handleGetTaskWorkspaceHealth(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
17
|
+
export declare function handleCleanupTaskWorkspace(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
18
|
+
export declare function handleCleanupOrphanTaskWorkspaces(ctx: RouteContext, deps: UiHandlerDeps): Promise<RouteResult>;
|
|
19
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/task-workspace/routes.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAa,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA6OhD,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAuBtB;AAGD,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,GAAG,WAAW,CAS1F;AAGD,wBAAsB,2BAA2B,CAC/C,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAqBtB;AAgBD,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAStB;AAGD,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,GAAG,WAAW,CAIjG;AAGD,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAyBtB;AAGD,wBAAgB,8BAA8B,CAC5C,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,aAAa,GAClB,WAAW,CAKb;AA0BD,wBAAgB,wBAAwB,CACtC,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAEtB;AAGD,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAEtB;AAGD,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAEtB;AAiBD,wBAAsB,oCAAoC,CACxD,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAQtB;AAID,wBAAsB,6BAA6B,CACjD,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAStB;AAUD,wBAAsB,yBAAyB,CAC7C,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CA2BtB;AASD,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAQtB;AAYD,wBAAsB,0BAA0B,CAC9C,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAuBtB;AAID,wBAAsB,iCAAiC,CACrD,GAAG,EAAE,YAAY,EACjB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,WAAW,CAAC,CAiBtB"}
|