@oscharko-dev/keiko-server 0.2.7 → 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.
Files changed (302) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/chat-handlers.d.ts +18 -2
  3. package/dist/chat-handlers.d.ts.map +1 -1
  4. package/dist/chat-handlers.js +185 -3
  5. package/dist/command-runner-errors.d.ts +17 -0
  6. package/dist/command-runner-errors.d.ts.map +1 -0
  7. package/dist/command-runner-errors.js +37 -0
  8. package/dist/command-runner-evidence.d.ts +23 -0
  9. package/dist/command-runner-evidence.d.ts.map +1 -0
  10. package/dist/command-runner-evidence.js +69 -0
  11. package/dist/command-runner-routes.d.ts +7 -0
  12. package/dist/command-runner-routes.d.ts.map +1 -0
  13. package/dist/command-runner-routes.js +175 -0
  14. package/dist/command-runner.d.ts +29 -0
  15. package/dist/command-runner.d.ts.map +1 -0
  16. package/dist/command-runner.js +348 -0
  17. package/dist/conversation-prompt.d.ts +2 -2
  18. package/dist/conversation-prompt.d.ts.map +1 -1
  19. package/dist/conversation-prompt.js +17 -1
  20. package/dist/csp.d.ts.map +1 -1
  21. package/dist/csp.js +3 -0
  22. package/dist/deps.d.ts +28 -1
  23. package/dist/deps.d.ts.map +1 -1
  24. package/dist/deps.js +288 -13
  25. package/dist/discussion-prompt.d.ts +4 -0
  26. package/dist/discussion-prompt.d.ts.map +1 -0
  27. package/dist/discussion-prompt.js +19 -0
  28. package/dist/editor/agentActionAudit.d.ts +18 -0
  29. package/dist/editor/agentActionAudit.d.ts.map +1 -0
  30. package/dist/editor/agentActionAudit.js +80 -0
  31. package/dist/editor/agentRoutes.d.ts +1 -0
  32. package/dist/editor/agentRoutes.d.ts.map +1 -1
  33. package/dist/editor/agentRoutes.js +292 -55
  34. package/dist/editor/agentSessionRegistry.d.ts +35 -0
  35. package/dist/editor/agentSessionRegistry.d.ts.map +1 -0
  36. package/dist/editor/agentSessionRegistry.js +243 -0
  37. package/dist/editor/completionRoutes.d.ts.map +1 -1
  38. package/dist/editor/completionRoutes.js +5 -10
  39. package/dist/editor/languageRoutes.d.ts +12 -1
  40. package/dist/editor/languageRoutes.d.ts.map +1 -1
  41. package/dist/editor/languageRoutes.js +71 -8
  42. package/dist/editor/languageService.d.ts +3 -2
  43. package/dist/editor/languageService.d.ts.map +1 -1
  44. package/dist/editor/languageService.js +41 -3
  45. package/dist/editor/languageServiceHost.d.ts.map +1 -1
  46. package/dist/editor/languageServiceHost.js +2 -2
  47. package/dist/editor/lsp/hostLanguageOperation.d.ts +17 -0
  48. package/dist/editor/lsp/hostLanguageOperation.d.ts.map +1 -0
  49. package/dist/editor/lsp/hostLanguageOperation.js +436 -0
  50. package/dist/editor/lsp/hostLanguageProviders.d.ts +26 -0
  51. package/dist/editor/lsp/hostLanguageProviders.d.ts.map +1 -0
  52. package/dist/editor/lsp/hostLanguageProviders.js +161 -0
  53. package/dist/editor/lsp/lspFrameCodec.d.ts +13 -0
  54. package/dist/editor/lsp/lspFrameCodec.d.ts.map +1 -0
  55. package/dist/editor/lsp/lspFrameCodec.js +164 -0
  56. package/dist/editor/lsp/lspJsonRpcClient.d.ts +34 -0
  57. package/dist/editor/lsp/lspJsonRpcClient.d.ts.map +1 -0
  58. package/dist/editor/lsp/lspJsonRpcClient.js +173 -0
  59. package/dist/editor/lsp/lspLanguageProvider.d.ts +7 -0
  60. package/dist/editor/lsp/lspLanguageProvider.d.ts.map +1 -0
  61. package/dist/editor/lsp/lspLanguageProvider.js +29 -0
  62. package/dist/editor/lsp/lspLifecycleLedger.d.ts +5 -0
  63. package/dist/editor/lsp/lspLifecycleLedger.d.ts.map +1 -0
  64. package/dist/editor/lsp/lspLifecycleLedger.js +37 -0
  65. package/dist/editor/lsp/lspNodeAdapter.d.ts +31 -0
  66. package/dist/editor/lsp/lspNodeAdapter.d.ts.map +1 -0
  67. package/dist/editor/lsp/lspNodeAdapter.js +230 -0
  68. package/dist/editor/lsp/lspProcessManager.d.ts +24 -0
  69. package/dist/editor/lsp/lspProcessManager.d.ts.map +1 -0
  70. package/dist/editor/lsp/lspProcessManager.js +255 -0
  71. package/dist/editor/lsp/lspRestartThrottle.d.ts +6 -0
  72. package/dist/editor/lsp/lspRestartThrottle.d.ts.map +1 -0
  73. package/dist/editor/lsp/lspRestartThrottle.js +24 -0
  74. package/dist/editor/lsp/lspStatusRoute.d.ts +8 -0
  75. package/dist/editor/lsp/lspStatusRoute.d.ts.map +1 -0
  76. package/dist/editor/lsp/lspStatusRoute.js +22 -0
  77. package/dist/editor/lsp/lspTransport.d.ts +19 -0
  78. package/dist/editor/lsp/lspTransport.d.ts.map +1 -0
  79. package/dist/editor/lsp/lspTransport.js +55 -0
  80. package/dist/editor/lsp/testing/fakeLspProcess.d.ts +23 -0
  81. package/dist/editor/lsp/testing/fakeLspProcess.d.ts.map +1 -0
  82. package/dist/editor/lsp/testing/fakeLspProcess.js +132 -0
  83. package/dist/files.d.ts +63 -0
  84. package/dist/files.d.ts.map +1 -1
  85. package/dist/files.js +799 -1
  86. package/dist/gateway-readiness.d.ts +6 -0
  87. package/dist/gateway-readiness.d.ts.map +1 -0
  88. package/dist/gateway-readiness.js +624 -0
  89. package/dist/gateway-setup.d.ts +2 -0
  90. package/dist/gateway-setup.d.ts.map +1 -1
  91. package/dist/gateway-setup.js +275 -11
  92. package/dist/gitDelivery/actionSheetProjection.d.ts +30 -0
  93. package/dist/gitDelivery/actionSheetProjection.d.ts.map +1 -0
  94. package/dist/gitDelivery/actionSheetProjection.js +206 -0
  95. package/dist/gitDelivery/actionSheetRoutes.d.ts +29 -0
  96. package/dist/gitDelivery/actionSheetRoutes.d.ts.map +1 -0
  97. package/dist/gitDelivery/actionSheetRoutes.js +293 -0
  98. package/dist/gitDelivery/agentOperationsRoutes.d.ts +33 -0
  99. package/dist/gitDelivery/agentOperationsRoutes.d.ts.map +1 -0
  100. package/dist/gitDelivery/agentOperationsRoutes.js +405 -0
  101. package/dist/gitDelivery/commitRoutes.d.ts +23 -0
  102. package/dist/gitDelivery/commitRoutes.d.ts.map +1 -0
  103. package/dist/gitDelivery/commitRoutes.js +204 -0
  104. package/dist/gitDelivery/evidenceRoutes.d.ts +9 -0
  105. package/dist/gitDelivery/evidenceRoutes.d.ts.map +1 -0
  106. package/dist/gitDelivery/evidenceRoutes.js +101 -0
  107. package/dist/gitDelivery/execution.d.ts +38 -0
  108. package/dist/gitDelivery/execution.d.ts.map +1 -0
  109. package/dist/gitDelivery/execution.js +117 -0
  110. package/dist/gitDelivery/localMutationRoutes.d.ts +30 -0
  111. package/dist/gitDelivery/localMutationRoutes.d.ts.map +1 -0
  112. package/dist/gitDelivery/localMutationRoutes.js +165 -0
  113. package/dist/gitDelivery/mergeExecution.d.ts +63 -0
  114. package/dist/gitDelivery/mergeExecution.d.ts.map +1 -0
  115. package/dist/gitDelivery/mergeExecution.js +168 -0
  116. package/dist/gitDelivery/mergeRoutes.d.ts +12 -0
  117. package/dist/gitDelivery/mergeRoutes.d.ts.map +1 -0
  118. package/dist/gitDelivery/mergeRoutes.js +218 -0
  119. package/dist/gitDelivery/mutationEvidenceLedger.d.ts +23 -0
  120. package/dist/gitDelivery/mutationEvidenceLedger.d.ts.map +1 -0
  121. package/dist/gitDelivery/mutationEvidenceLedger.js +87 -0
  122. package/dist/gitDelivery/prExecution.d.ts +54 -0
  123. package/dist/gitDelivery/prExecution.d.ts.map +1 -0
  124. package/dist/gitDelivery/prExecution.js +192 -0
  125. package/dist/gitDelivery/prRoutes.d.ts +12 -0
  126. package/dist/gitDelivery/prRoutes.d.ts.map +1 -0
  127. package/dist/gitDelivery/prRoutes.js +256 -0
  128. package/dist/gitDelivery/pushExecution.d.ts +43 -0
  129. package/dist/gitDelivery/pushExecution.d.ts.map +1 -0
  130. package/dist/gitDelivery/pushExecution.js +124 -0
  131. package/dist/gitDelivery/pushRoutes.d.ts +12 -0
  132. package/dist/gitDelivery/pushRoutes.d.ts.map +1 -0
  133. package/dist/gitDelivery/pushRoutes.js +200 -0
  134. package/dist/gitDelivery/requestGuards.d.ts +15 -0
  135. package/dist/gitDelivery/requestGuards.d.ts.map +1 -0
  136. package/dist/gitDelivery/requestGuards.js +97 -0
  137. package/dist/gitDelivery/syncEvidence.d.ts +37 -0
  138. package/dist/gitDelivery/syncEvidence.d.ts.map +1 -0
  139. package/dist/gitDelivery/syncEvidence.js +85 -0
  140. package/dist/gitDelivery/syncExecution.d.ts +30 -0
  141. package/dist/gitDelivery/syncExecution.d.ts.map +1 -0
  142. package/dist/gitDelivery/syncExecution.js +266 -0
  143. package/dist/gitDelivery/syncRoutes.d.ts +13 -0
  144. package/dist/gitDelivery/syncRoutes.d.ts.map +1 -0
  145. package/dist/gitDelivery/syncRoutes.js +200 -0
  146. package/dist/gitPorcelainStatus.d.ts +15 -0
  147. package/dist/gitPorcelainStatus.d.ts.map +1 -0
  148. package/dist/gitPorcelainStatus.js +104 -0
  149. package/dist/gitRepositoryReads.d.ts +10 -0
  150. package/dist/gitRepositoryReads.d.ts.map +1 -0
  151. package/dist/gitRepositoryReads.js +314 -0
  152. package/dist/gitRepositoryRoutes.d.ts +7 -0
  153. package/dist/gitRepositoryRoutes.d.ts.map +1 -0
  154. package/dist/gitRepositoryRoutes.js +221 -0
  155. package/dist/gitRoutes.d.ts +66 -0
  156. package/dist/gitRoutes.d.ts.map +1 -0
  157. package/dist/gitRoutes.js +543 -0
  158. package/dist/governed-workflow.d.ts +2 -0
  159. package/dist/governed-workflow.d.ts.map +1 -1
  160. package/dist/governed-workflow.js +4 -0
  161. package/dist/grounded-qa-hybrid.d.ts.map +1 -1
  162. package/dist/grounded-qa-hybrid.js +2 -0
  163. package/dist/grounded-qa-multi-source.d.ts.map +1 -1
  164. package/dist/grounded-qa-multi-source.js +1 -0
  165. package/dist/grounded-qa.d.ts +11 -0
  166. package/dist/grounded-qa.d.ts.map +1 -1
  167. package/dist/grounded-qa.js +14 -4
  168. package/dist/headers.d.ts +4 -1
  169. package/dist/headers.d.ts.map +1 -1
  170. package/dist/headers.js +11 -4
  171. package/dist/index.d.ts +8 -1
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +9 -1
  174. package/dist/local-knowledge-grounded-qa.d.ts.map +1 -1
  175. package/dist/local-knowledge-grounded-qa.js +11 -2
  176. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts +1 -1
  177. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts.map +1 -1
  178. package/dist/qualityIntelligence/figmaSnapshotRoutes.js +1 -1
  179. package/dist/read-handlers.d.ts +5 -0
  180. package/dist/read-handlers.d.ts.map +1 -1
  181. package/dist/read-handlers.js +57 -1
  182. package/dist/routes.d.ts.map +1 -1
  183. package/dist/routes.js +260 -12
  184. package/dist/run-engine.d.ts.map +1 -1
  185. package/dist/run-engine.js +3 -0
  186. package/dist/run-handlers.d.ts +0 -1
  187. package/dist/run-handlers.d.ts.map +1 -1
  188. package/dist/run-handlers.js +64 -211
  189. package/dist/run-request.d.ts +11 -0
  190. package/dist/run-request.d.ts.map +1 -1
  191. package/dist/run-request.js +158 -10
  192. package/dist/runtime/capabilityDetector.d.ts +38 -0
  193. package/dist/runtime/capabilityDetector.d.ts.map +1 -0
  194. package/dist/runtime/capabilityDetector.js +443 -0
  195. package/dist/runtime/capabilityRoutes.d.ts +9 -0
  196. package/dist/runtime/capabilityRoutes.d.ts.map +1 -0
  197. package/dist/runtime/capabilityRoutes.js +45 -0
  198. package/dist/runtime/containerEngineDetector.d.ts +17 -0
  199. package/dist/runtime/containerEngineDetector.d.ts.map +1 -0
  200. package/dist/runtime/containerEngineDetector.js +222 -0
  201. package/dist/runtime/containerRoutes.d.ts +8 -0
  202. package/dist/runtime/containerRoutes.d.ts.map +1 -0
  203. package/dist/runtime/containerRoutes.js +207 -0
  204. package/dist/runtime/containerRunner-errors.d.ts +18 -0
  205. package/dist/runtime/containerRunner-errors.d.ts.map +1 -0
  206. package/dist/runtime/containerRunner-errors.js +42 -0
  207. package/dist/runtime/containerRunner-evidence.d.ts +24 -0
  208. package/dist/runtime/containerRunner-evidence.d.ts.map +1 -0
  209. package/dist/runtime/containerRunner-evidence.js +74 -0
  210. package/dist/runtime/containerRunner.d.ts +37 -0
  211. package/dist/runtime/containerRunner.d.ts.map +1 -0
  212. package/dist/runtime/containerRunner.js +443 -0
  213. package/dist/server.d.ts.map +1 -1
  214. package/dist/server.js +24 -4
  215. package/dist/store/db.d.ts.map +1 -1
  216. package/dist/store/db.js +2 -1
  217. package/dist/store/index.d.ts +1 -1
  218. package/dist/store/index.d.ts.map +1 -1
  219. package/dist/store/messages.d.ts +2 -1
  220. package/dist/store/messages.d.ts.map +1 -1
  221. package/dist/store/messages.js +46 -4
  222. package/dist/store/schema.d.ts +1 -1
  223. package/dist/store/schema.d.ts.map +1 -1
  224. package/dist/store/schema.js +68 -1
  225. package/dist/store/types.d.ts +3 -2
  226. package/dist/store/types.d.ts.map +1 -1
  227. package/dist/task-workspace/active-store.d.ts +21 -0
  228. package/dist/task-workspace/active-store.d.ts.map +1 -0
  229. package/dist/task-workspace/active-store.js +55 -0
  230. package/dist/task-workspace/authorization.d.ts +7 -0
  231. package/dist/task-workspace/authorization.d.ts.map +1 -0
  232. package/dist/task-workspace/authorization.js +54 -0
  233. package/dist/task-workspace/binding.d.ts +3 -0
  234. package/dist/task-workspace/binding.d.ts.map +1 -0
  235. package/dist/task-workspace/binding.js +22 -0
  236. package/dist/task-workspace/cleanup.d.ts +4 -0
  237. package/dist/task-workspace/cleanup.d.ts.map +1 -0
  238. package/dist/task-workspace/cleanup.js +428 -0
  239. package/dist/task-workspace/errors.d.ts +14 -0
  240. package/dist/task-workspace/errors.d.ts.map +1 -0
  241. package/dist/task-workspace/errors.js +81 -0
  242. package/dist/task-workspace/evidence.d.ts +32 -0
  243. package/dist/task-workspace/evidence.d.ts.map +1 -0
  244. package/dist/task-workspace/evidence.js +52 -0
  245. package/dist/task-workspace/field-safety.d.ts +3 -0
  246. package/dist/task-workspace/field-safety.d.ts.map +1 -0
  247. package/dist/task-workspace/field-safety.js +42 -0
  248. package/dist/task-workspace/health.d.ts +4 -0
  249. package/dist/task-workspace/health.d.ts.map +1 -0
  250. package/dist/task-workspace/health.js +163 -0
  251. package/dist/task-workspace/lifecycle.d.ts +3 -0
  252. package/dist/task-workspace/lifecycle.d.ts.map +1 -0
  253. package/dist/task-workspace/lifecycle.js +248 -0
  254. package/dist/task-workspace/locks.d.ts +13 -0
  255. package/dist/task-workspace/locks.d.ts.map +1 -0
  256. package/dist/task-workspace/locks.js +44 -0
  257. package/dist/task-workspace/managed-root.d.ts +7 -0
  258. package/dist/task-workspace/managed-root.d.ts.map +1 -0
  259. package/dist/task-workspace/managed-root.js +98 -0
  260. package/dist/task-workspace/mutex.d.ts +8 -0
  261. package/dist/task-workspace/mutex.d.ts.map +1 -0
  262. package/dist/task-workspace/mutex.js +82 -0
  263. package/dist/task-workspace/naming.d.ts +15 -0
  264. package/dist/task-workspace/naming.d.ts.map +1 -0
  265. package/dist/task-workspace/naming.js +0 -0
  266. package/dist/task-workspace/provisioning.d.ts +3 -0
  267. package/dist/task-workspace/provisioning.d.ts.map +1 -0
  268. package/dist/task-workspace/provisioning.js +528 -0
  269. package/dist/task-workspace/reconciliation.d.ts +15 -0
  270. package/dist/task-workspace/reconciliation.d.ts.map +1 -0
  271. package/dist/task-workspace/reconciliation.js +274 -0
  272. package/dist/task-workspace/repair.d.ts +3 -0
  273. package/dist/task-workspace/repair.d.ts.map +1 -0
  274. package/dist/task-workspace/repair.js +286 -0
  275. package/dist/task-workspace/routes.d.ts +19 -0
  276. package/dist/task-workspace/routes.d.ts.map +1 -0
  277. package/dist/task-workspace/routes.js +481 -0
  278. package/dist/task-workspace/store.d.ts +12 -0
  279. package/dist/task-workspace/store.d.ts.map +1 -0
  280. package/dist/task-workspace/store.js +128 -0
  281. package/dist/task-workspace/types.d.ts +170 -0
  282. package/dist/task-workspace/types.d.ts.map +1 -0
  283. package/dist/task-workspace/types.js +5 -0
  284. package/dist/voice-action-governance.d.ts +23 -0
  285. package/dist/voice-action-governance.d.ts.map +1 -0
  286. package/dist/voice-action-governance.js +126 -0
  287. package/dist/voice-handlers.d.ts +6 -0
  288. package/dist/voice-handlers.d.ts.map +1 -0
  289. package/dist/voice-handlers.js +570 -0
  290. package/dist/voice-realtime-grounded-tool.d.ts +31 -0
  291. package/dist/voice-realtime-grounded-tool.d.ts.map +1 -0
  292. package/dist/voice-realtime-grounded-tool.js +322 -0
  293. package/dist/voice-realtime.d.ts +69 -0
  294. package/dist/voice-realtime.d.ts.map +1 -0
  295. package/dist/voice-realtime.js +787 -0
  296. package/dist/workspace-state-handlers.d.ts +5 -0
  297. package/dist/workspace-state-handlers.d.ts.map +1 -0
  298. package/dist/workspace-state-handlers.js +106 -0
  299. package/package.json +20 -19
  300. package/dist/grounded-handoff.d.ts +0 -4
  301. package/dist/grounded-handoff.d.ts.map +0 -1
  302. package/dist/grounded-handoff.js +0 -445
@@ -0,0 +1,428 @@
1
+ // Governed cleanup for managed task workspaces (Issue #448, Epic #443).
2
+ //
3
+ // Cleanup is the destructive counterpart to health. It NEVER removes anything without (a) operator
4
+ // approval (the #444 request-cleanup / complete-cleanup operations both require it), (b) a LIVE
5
+ // re-verification of containment + ownership + cleanliness + lock liveness at the moment of removal —
6
+ // the persisted `managedWorktreePath` is NEVER trusted (SC2) — and (c) the pure cleanup-safety gate
7
+ // allowing it. A refusal is a FIRST-CLASS successful safety outcome, audited and returned, never thrown
8
+ // (SC4). Removal goes through the governed worktree adapter first, falling back only to a single
9
+ // ownership-and-realpath-gated `safelyRemoveManagedPath` choke point — the ONLY place a filesystem
10
+ // deletion happens, defended so it can never escape the Keiko-owned managed root (SC1).
11
+ //
12
+ // It REUSES the existing engines and adds none: containment + ownership are the #445 managed-root
13
+ // helpers, worktree removal is the narrow #445 adapter, fact gathering is the #447 reconciliation path,
14
+ // the lifecycle transition is the #444 contract gate, and every outcome writes the same content-free
15
+ // lifecycle evidence as provisioning/reconciliation/repair.
16
+ import { existsSync, readdirSync, realpathSync, rmSync } from "node:fs";
17
+ import { join, relative } from "node:path";
18
+ import { detectWorkspaceAt } from "@oscharko-dev/keiko-workspace";
19
+ import { TASK_WORKSPACE_SCHEMA_VERSION, evaluateWorkspaceCleanupSafety, isCleanupEligibleLifecycleState, validateTaskWorkspaceTransition, } from "@oscharko-dev/keiko-contracts";
20
+ import { deriveRepositoryId } from "./naming.js";
21
+ import { assertManagedTargetContained, isManagedRootOwned, isManagedTargetContained, } from "./managed-root.js";
22
+ import { deriveOrphanId } from "./health.js";
23
+ import { assertSafeFieldValue } from "./field-safety.js";
24
+ import { gatherInstanceReconciliationFacts } from "./reconciliation.js";
25
+ import { lockIsLive, makeWorkspaceLock, resolveLockTtl } from "./locks.js";
26
+ import { workspaceKey } from "./mutex.js";
27
+ import { TaskWorkspaceError } from "./errors.js";
28
+ import { appendWorkspaceLifecycleEvidence, buildWorkspaceEvent, WORKSPACE_LIFECYCLE_EVIDENCE_KIND, } from "./evidence.js";
29
+ const MAX_FIELD_LENGTH = 512;
30
+ function isBoundedNonEmpty(value) {
31
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_FIELD_LENGTH;
32
+ }
33
+ function isoFrom(nowMs) {
34
+ return new Date(nowMs).toISOString();
35
+ }
36
+ // The SINGLE filesystem-deletion choke point (SC1). Before `rmSync` it proves: (1) Keiko owns the
37
+ // managed root (the marker file is present — never created here), (2) the target realpath-resolves
38
+ // inside the managed root (rejects symlink escape, parent traversal, out-of-root absolute targets),
39
+ // and (3) the target is a strict `<repositoryId>/<leaf>` descendant (never the managed root itself nor
40
+ // a bare repository-id directory). Any failure throws a content-free error and removes nothing.
41
+ // Exported so the security negative matrix exercises the guard directly.
42
+ export function safelyRemoveManagedPath(managedRoot, target) {
43
+ if (!isManagedRootOwned(managedRoot)) {
44
+ throw new TaskWorkspaceError("UNSAFE_PATH", "managed root ownership could not be proven");
45
+ }
46
+ // Throws UNSAFE_PATH on any lexical/realpath containment failure (delegated to keiko-workspace).
47
+ assertManagedTargetContained(managedRoot, target);
48
+ const rootReal = realpathSync(managedRoot);
49
+ const targetReal = realpathSync(target);
50
+ const rel = relative(rootReal, targetReal);
51
+ const segments = rel.split(/[\\/]/u).filter((segment) => segment.length > 0);
52
+ if (rel.length === 0 || rel.startsWith("..") || segments.includes("..") || segments.length < 2) {
53
+ throw new TaskWorkspaceError("UNSAFE_PATH", "refusing to remove a non-leaf managed path");
54
+ }
55
+ rmSync(targetReal, { recursive: true, force: false });
56
+ }
57
+ function emit(ctx, input) {
58
+ const event = buildWorkspaceEvent({
59
+ eventId: ctx.deps.newId(),
60
+ workspaceId: input.workspaceId,
61
+ taskId: input.taskId,
62
+ type: input.type,
63
+ at: isoFrom(input.nowMs),
64
+ correlationId: input.correlationId,
65
+ ...(input.fromState !== undefined ? { fromState: input.fromState } : {}),
66
+ ...(input.toState !== undefined ? { toState: input.toState } : {}),
67
+ });
68
+ appendWorkspaceLifecycleEvidence(ctx.deps.evidenceStore, {
69
+ kind: WORKSPACE_LIFECYCLE_EVIDENCE_KIND,
70
+ schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
71
+ recordedAt: input.nowMs,
72
+ operation: "cleanup",
73
+ outcome: input.outcome,
74
+ attempt: 1,
75
+ durationMs: 0,
76
+ worktreeCount: 0,
77
+ event,
78
+ }, ctx.deps.redactString);
79
+ }
80
+ function loadInstance(ctx, workspaceId) {
81
+ if (!isBoundedNonEmpty(workspaceId)) {
82
+ throw new TaskWorkspaceError("INVALID_REQUEST", "invalid workspaceId");
83
+ }
84
+ const instance = ctx.deps.store.getById(workspaceId);
85
+ if (instance === undefined) {
86
+ throw new TaskWorkspaceError("WORKSPACE_NOT_FOUND", "workspace not found");
87
+ }
88
+ return instance;
89
+ }
90
+ function hasPersistedWorkspaceForCandidate(ctx, repositoryId, leaf, candidate) {
91
+ const byWorkspaceId = ctx.deps.store.getById(leaf);
92
+ if (byWorkspaceId?.repositoryId === repositoryId &&
93
+ byWorkspaceId.managedWorktreePath === candidate) {
94
+ return true;
95
+ }
96
+ return ctx.deps.store
97
+ .listAll()
98
+ .some((instance) => instance.repositoryId === repositoryId && instance.managedWorktreePath === candidate);
99
+ }
100
+ // request-cleanup: a settled (cleanup-eligible) instance → cleanup-pending. Operator-approval gated by
101
+ // the #444 transition precondition table; no physical removal happens here.
102
+ // The *->cleanup-pending transition gate (operator-approval is the precondition the contract table
103
+ // carries for it). Extracted so requestCleanupImpl stays small.
104
+ function assertCleanupTransitionApproved(instance, operatorApproved) {
105
+ const transition = validateTaskWorkspaceTransition({
106
+ from: instance.lifecycleState,
107
+ to: "cleanup-pending",
108
+ context: {
109
+ lockHeldByActor: true,
110
+ pathContained: false,
111
+ worktreeClean: false,
112
+ branchReady: false,
113
+ providerReady: false,
114
+ operatorApproved,
115
+ },
116
+ });
117
+ if (!transition.ok) {
118
+ throw new TaskWorkspaceError("OPERATOR_APPROVAL_REQUIRED", "cleanup request requires operator approval", transition.reasons);
119
+ }
120
+ }
121
+ function requestCleanupImpl(ctx, request) {
122
+ const instance = loadInstance(ctx, request.workspaceId);
123
+ if (!isBoundedNonEmpty(request.requestedBy)) {
124
+ throw new TaskWorkspaceError("INVALID_REQUEST", "requestedBy is required");
125
+ }
126
+ assertSafeFieldValue(request.requestedBy, "requestedBy");
127
+ if (!isCleanupEligibleLifecycleState(instance.lifecycleState)) {
128
+ throw new TaskWorkspaceError("CLEANUP_NOT_ELIGIBLE", `workspace in ${instance.lifecycleState} is not eligible for cleanup`);
129
+ }
130
+ if (instance.lifecycleState === "cleanup-pending") {
131
+ // Idempotent: already requested.
132
+ return { outcome: "requested", workspaceId: instance.workspaceId, instance };
133
+ }
134
+ assertCleanupTransitionApproved(instance, request.operatorApproved);
135
+ const nowMs = ctx.deps.now();
136
+ const fromState = instance.lifecycleState;
137
+ const persisted = ctx.deps.store.upsert({
138
+ ...instance,
139
+ lifecycleState: "cleanup-pending",
140
+ updatedAt: isoFrom(nowMs),
141
+ });
142
+ emit(ctx, {
143
+ outcome: "cleanup-requested",
144
+ type: "cleanup-requested",
145
+ workspaceId: persisted.workspaceId,
146
+ taskId: persisted.taskId,
147
+ correlationId: persisted.auditCorrelationId,
148
+ fromState,
149
+ toState: persisted.lifecycleState,
150
+ nowMs,
151
+ });
152
+ return { outcome: "requested", workspaceId: persisted.workspaceId, instance: persisted };
153
+ }
154
+ // Re-gathers the LIVE facts for one instance (never trusting the persisted row) and runs the pure
155
+ // safety gate. Builds its own repo-root adapter for structural facts and a worktree-bound adapter for
156
+ // the dirty probe.
157
+ // Live dirty probe for the DESTRUCTIVE path. Unlike the read-only health probe (health.ts, which treats
158
+ // an inconclusive probe as not-dirty because it never deletes), this fails CLOSED: an existing,
159
+ // contained worktree whose `git status` is inconclusive (`ok:false` — a corrupt/missing `.git` pointer
160
+ // leaves the directory and its uncommitted/untracked files on disk while git refuses to read it) is
161
+ // treated as DIRTY. A structurally-broken tree that may still hold real work is therefore refused
162
+ // rather than force-removed (SC4). A missing worktree (nothing to lose) probes not-dirty.
163
+ async function probeCleanupDirty(ctx, worktreePath, probeable) {
164
+ if (!probeable)
165
+ return false;
166
+ const status = await ctx.deps.createAdapter(detectWorkspaceAt(worktreePath)).worktreeStatus();
167
+ return !status.ok || status.dirty;
168
+ }
169
+ async function evaluateLiveCleanupSafety(ctx, instance) {
170
+ const adapter = ctx.deps.createAdapter(detectWorkspaceAt(instance.repositoryRoot));
171
+ const worktrees = await adapter.listWorktrees();
172
+ const { facts } = await gatherInstanceReconciliationFacts(ctx.deps, adapter, worktrees, instance, ctx.deps.now());
173
+ const worktreeDirty = await probeCleanupDirty(ctx, instance.managedWorktreePath, facts.worktreeDirExists && facts.pathContained);
174
+ return evaluateWorkspaceCleanupSafety({
175
+ lifecycleState: instance.lifecycleState,
176
+ hasRecord: true,
177
+ pathContained: facts.pathContained,
178
+ ownershipProven: isManagedRootOwned(ctx.deps.managedRoot),
179
+ worktreeDirty,
180
+ lockLive: facts.lockLive,
181
+ });
182
+ }
183
+ // Governed physical removal of a managed worktree: the narrow adapter `remove --force` first (the
184
+ // safety gate already proved the tree is clean, contained, and owned), then a `prune` to clear the
185
+ // admin entry; if the directory git no longer tracks survives, the gated `safelyRemoveManagedPath`
186
+ // fallback removes it. Throws CLEANUP_FAILED only on an unexpected IO error.
187
+ async function removeManagedWorktree(ctx, repositoryRoot, worktreePath) {
188
+ try {
189
+ const adapter = ctx.deps.createAdapter(detectWorkspaceAt(repositoryRoot));
190
+ const removal = await adapter.removeWorktree({ worktreePath, force: true });
191
+ await adapter.pruneWorktrees();
192
+ if (existsSync(worktreePath)) {
193
+ const dirty = await probeCleanupDirty(ctx, worktreePath, true);
194
+ if (dirty || !removal.ok) {
195
+ return { removed: false, refusalReason: "worktree-dirty" };
196
+ }
197
+ safelyRemoveManagedPath(ctx.deps.managedRoot, worktreePath);
198
+ }
199
+ return { removed: true };
200
+ }
201
+ catch (error) {
202
+ if (error instanceof TaskWorkspaceError)
203
+ throw error;
204
+ throw new TaskWorkspaceError("CLEANUP_FAILED", "governed worktree removal failed");
205
+ }
206
+ }
207
+ function cleanupLock(ctx, requestedBy, nowMs) {
208
+ return makeWorkspaceLock({
209
+ newId: ctx.deps.newId,
210
+ owner: requestedBy,
211
+ reason: "cleanup",
212
+ nowMs,
213
+ ttlMs: ctx.lockTtlMs,
214
+ });
215
+ }
216
+ // Emits the content-free refusal event and returns the refused result (a successful safety outcome).
217
+ function refuseCleanup(ctx, instance, refusalReason) {
218
+ emit(ctx, {
219
+ outcome: "cleanup-refused",
220
+ type: "transition-rejected",
221
+ workspaceId: instance.workspaceId,
222
+ taskId: instance.taskId,
223
+ correlationId: instance.auditCorrelationId,
224
+ fromState: instance.lifecycleState,
225
+ nowMs: ctx.deps.now(),
226
+ });
227
+ return {
228
+ outcome: "refused",
229
+ workspaceId: instance.workspaceId,
230
+ ...(refusalReason !== undefined ? { refusalReason } : {}),
231
+ };
232
+ }
233
+ // Performs the approved removal: acquire the cleanup lock (the #444 complete-cleanup operation requires
234
+ // one) as a concurrency marker, remove the worktree through the governed path, clear the active pointer,
235
+ // delete the retired row, and audit. The content-free history survives in the evidence ledger.
236
+ async function finalizeCleanup(ctx, instance, requestedBy, nowMs) {
237
+ const locked = ctx.deps.store.upsert({
238
+ ...instance,
239
+ lock: cleanupLock(ctx, requestedBy, nowMs),
240
+ updatedAt: isoFrom(nowMs),
241
+ });
242
+ const removal = await removeManagedWorktree(ctx, locked.repositoryRoot, locked.managedWorktreePath);
243
+ if (!removal.removed) {
244
+ const unlocked = ctx.deps.store.upsert({
245
+ ...locked,
246
+ lock: null,
247
+ updatedAt: isoFrom(ctx.deps.now()),
248
+ });
249
+ return refuseCleanup(ctx, unlocked, removal.refusalReason);
250
+ }
251
+ if (ctx.deps.activePointerStore.get()?.workspaceId === locked.workspaceId) {
252
+ ctx.deps.activePointerStore.clear();
253
+ }
254
+ ctx.deps.store.delete(locked.workspaceId);
255
+ emit(ctx, {
256
+ outcome: "cleanup-completed",
257
+ type: "cleanup-completed",
258
+ workspaceId: locked.workspaceId,
259
+ taskId: locked.taskId,
260
+ correlationId: locked.auditCorrelationId,
261
+ fromState: "cleanup-pending",
262
+ nowMs: ctx.deps.now(),
263
+ });
264
+ return { outcome: "completed", workspaceId: locked.workspaceId };
265
+ }
266
+ // The pre-removal guards: bounded actor, operator approval, no foreign live lock, and a cleanup-pending
267
+ // lifecycle. Throws on any violation (these are caller errors, not safety refusals).
268
+ function assertCompleteCleanupAllowed(ctx, request, instance, nowMs) {
269
+ if (!request.operatorApproved) {
270
+ throw new TaskWorkspaceError("OPERATOR_APPROVAL_REQUIRED", "cleanup requires operator approval");
271
+ }
272
+ if (lockIsLive(instance.lock, nowMs, ctx.lockTtlMs) &&
273
+ instance.lock?.owner !== request.requestedBy) {
274
+ throw new TaskWorkspaceError("LOCK_CONTENTION", "workspace is locked by another actor");
275
+ }
276
+ if (instance.lifecycleState !== "cleanup-pending") {
277
+ throw new TaskWorkspaceError("CLEANUP_NOT_ELIGIBLE", "complete-cleanup requires a cleanup-pending workspace (request cleanup first)");
278
+ }
279
+ }
280
+ // complete-cleanup: the live-verified governed removal of a cleanup-pending instance (lock + operator
281
+ // approval). Re-verifies safety LIVE; on refusal it audits and returns (never throws).
282
+ async function completeCleanupImpl(ctx, request) {
283
+ if (!isBoundedNonEmpty(request.requestedBy)) {
284
+ throw new TaskWorkspaceError("INVALID_REQUEST", "requestedBy is required");
285
+ }
286
+ assertSafeFieldValue(request.requestedBy, "requestedBy");
287
+ const instance = loadInstance(ctx, request.workspaceId);
288
+ const nowMs = ctx.deps.now();
289
+ assertCompleteCleanupAllowed(ctx, request, instance, nowMs);
290
+ const safety = await evaluateLiveCleanupSafety(ctx, instance);
291
+ if (!safety.allowed)
292
+ return refuseCleanup(ctx, instance, safety.refusalReason);
293
+ return finalizeCleanup(ctx, instance, request.requestedBy, nowMs);
294
+ }
295
+ // Governed removal of orphaned managed worktrees: directories under the managed root with no persisted
296
+ // record. Each is realpath-contained, ownership-proven, and dirty-probed before the gated fs removal;
297
+ // an unsafe orphan is refused with its content-free reason, never removed.
298
+ async function cleanupOneOrphan(ctx, repositoryId, leaf, ownershipProven) {
299
+ const orphanId = deriveOrphanId(repositoryId, leaf);
300
+ const candidate = join(ctx.deps.managedRoot, repositoryId, leaf);
301
+ if (hasPersistedWorkspaceForCandidate(ctx, repositoryId, leaf, candidate)) {
302
+ return { removed: false };
303
+ }
304
+ const pathContained = isManagedTargetContained(ctx.deps.managedRoot, candidate);
305
+ // Fail closed: an orphan whose `git status` is inconclusive (broken pointer) is treated as dirty and
306
+ // refused, never force-removed (SC4) — it may still hold uncommitted work.
307
+ const worktreeDirty = await probeCleanupDirty(ctx, candidate, pathContained);
308
+ const decision = evaluateWorkspaceCleanupSafety({
309
+ lifecycleState: "abandoned",
310
+ hasRecord: false,
311
+ pathContained,
312
+ ownershipProven,
313
+ worktreeDirty,
314
+ lockLive: false,
315
+ });
316
+ if (!decision.allowed) {
317
+ return {
318
+ removed: false,
319
+ refusal: { orphanId, refusalReason: decision.refusalReason ?? "path-escape" },
320
+ };
321
+ }
322
+ const stillDirty = await probeCleanupDirty(ctx, candidate, pathContained);
323
+ if (stillDirty) {
324
+ return {
325
+ removed: false,
326
+ refusal: { orphanId, refusalReason: "worktree-dirty" },
327
+ };
328
+ }
329
+ safelyRemoveManagedPath(ctx.deps.managedRoot, candidate);
330
+ emit(ctx, {
331
+ outcome: "cleanup-completed",
332
+ type: "cleanup-completed",
333
+ workspaceId: orphanId,
334
+ taskId: orphanId,
335
+ correlationId: orphanId,
336
+ nowMs: ctx.deps.now(),
337
+ });
338
+ return { removed: true };
339
+ }
340
+ function orphanLeavesFor(ctx, repositoryId, knownPaths) {
341
+ const repoDir = join(ctx.deps.managedRoot, repositoryId);
342
+ if (!existsSync(repoDir))
343
+ return [];
344
+ try {
345
+ return readdirSync(repoDir, { withFileTypes: true })
346
+ .filter((entry) => entry.isDirectory())
347
+ .map((entry) => entry.name)
348
+ .filter((leaf) => !knownPaths.has(join(repoDir, leaf)));
349
+ }
350
+ catch {
351
+ return [];
352
+ }
353
+ }
354
+ // The repository-id set to sweep: the requested repository only, or — for a global sweep — every
355
+ // repository with a persisted record plus every repository-id directory still on disk. The
356
+ // persisted-record ids are read from the already-built `knownByRepo` map (its keys ARE the distinct
357
+ // persisted repository ids) rather than a second `store.listAll()`, so the global sweep scans the store
358
+ // once instead of twice (#449 perf review — avoids re-running rowToInstance + validate over every row).
359
+ function resolveOrphanRepositoryIds(ctx, request, knownByRepo) {
360
+ if (request.repositoryRoot !== undefined && request.repositoryRoot.length > 0) {
361
+ return new Set([deriveRepositoryId(request.repositoryRoot)]);
362
+ }
363
+ const ids = new Set(knownByRepo.keys());
364
+ for (const onDisk of managedRepoDirs(ctx))
365
+ ids.add(onDisk);
366
+ return ids;
367
+ }
368
+ // The managed worktree paths a persisted record claims, grouped by repository id, so the orphan sweep
369
+ // can exclude them.
370
+ function buildKnownPathsByRepo(ctx) {
371
+ const knownByRepo = new Map();
372
+ for (const instance of ctx.deps.store.listAll()) {
373
+ const set = knownByRepo.get(instance.repositoryId) ?? new Set();
374
+ set.add(instance.managedWorktreePath);
375
+ knownByRepo.set(instance.repositoryId, set);
376
+ }
377
+ return knownByRepo;
378
+ }
379
+ async function cleanupOrphansImpl(ctx, request) {
380
+ if (!isBoundedNonEmpty(request.requestedBy)) {
381
+ throw new TaskWorkspaceError("INVALID_REQUEST", "requestedBy is required");
382
+ }
383
+ assertSafeFieldValue(request.requestedBy, "requestedBy");
384
+ if (!request.operatorApproved) {
385
+ throw new TaskWorkspaceError("OPERATOR_APPROVAL_REQUIRED", "cleanup requires operator approval");
386
+ }
387
+ const ownershipProven = isManagedRootOwned(ctx.deps.managedRoot);
388
+ const knownByRepo = buildKnownPathsByRepo(ctx);
389
+ let removed = 0;
390
+ const refused = [];
391
+ for (const repositoryId of resolveOrphanRepositoryIds(ctx, request, knownByRepo)) {
392
+ const known = knownByRepo.get(repositoryId) ?? new Set();
393
+ for (const leaf of orphanLeavesFor(ctx, repositoryId, known)) {
394
+ // Serialize each candidate leaf and re-check persisted liveness inside the critical section. The
395
+ // initial known-path snapshot may be stale if a provision finishes while a sweep is walking disk.
396
+ const outcome = await ctx.deps.mutex.runExclusive([workspaceKey(leaf)], () => cleanupOneOrphan(ctx, repositoryId, leaf, ownershipProven));
397
+ if (outcome.removed)
398
+ removed += 1;
399
+ if (outcome.refusal !== undefined)
400
+ refused.push(outcome.refusal);
401
+ }
402
+ }
403
+ return { removed, refused };
404
+ }
405
+ function managedRepoDirs(ctx) {
406
+ if (!existsSync(ctx.deps.managedRoot))
407
+ return [];
408
+ try {
409
+ return readdirSync(ctx.deps.managedRoot, { withFileTypes: true })
410
+ .filter((entry) => entry.isDirectory())
411
+ .map((entry) => entry.name);
412
+ }
413
+ catch {
414
+ return [];
415
+ }
416
+ }
417
+ export function createWorkspaceCleanupService(deps) {
418
+ const ctx = { deps, lockTtlMs: resolveLockTtl(deps.lockTtlMs) };
419
+ return {
420
+ // Serialized under the workspace's `ws:` key (#449, ADR-0093 D1) so a governed removal cannot race a
421
+ // concurrent activate/pause/repair of the same workspace. `runExclusive` also turns a synchronous
422
+ // validation throw from requestCleanupImpl into a rejected promise (the consistent async contract).
423
+ cleanup: (request) => ctx.deps.mutex.runExclusive([workspaceKey(request.workspaceId)], () => request.mode === "request"
424
+ ? requestCleanupImpl(ctx, request)
425
+ : completeCleanupImpl(ctx, request)),
426
+ cleanupOrphans: (request) => cleanupOrphansImpl(ctx, request),
427
+ };
428
+ }
@@ -0,0 +1,14 @@
1
+ export type TaskWorkspaceErrorCode = "INVALID_REQUEST" | "MISSING_REPOSITORY" | "INVALID_BASE_BRANCH" | "UNSAFE_PATH" | "BRANCH_CONFLICT" | "EXISTING_UNMANAGED_PATH" | "LOCK_CONTENTION" | "POINTER_DRIFT" | "PROVISIONING_FAILED" | "WORKSPACE_NOT_FOUND" | "ILLEGAL_TRANSITION" | "OPERATOR_APPROVAL_REQUIRED" | "REPAIR_NOT_APPLICABLE" | "REPAIR_FAILED" | "CLEANUP_NOT_ELIGIBLE" | "CLEANUP_FAILED";
2
+ import type { WorkspaceFailureClass } from "@oscharko-dev/keiko-contracts";
3
+ export type WorkspaceFailureOutcome = "blocked" | "failed" | "retry-required";
4
+ export declare function classifyTaskWorkspaceError(code: TaskWorkspaceErrorCode): WorkspaceFailureClass;
5
+ export declare class TaskWorkspaceError extends Error {
6
+ readonly code: TaskWorkspaceErrorCode;
7
+ readonly status: number;
8
+ readonly outcome: WorkspaceFailureOutcome;
9
+ readonly reasons: readonly string[];
10
+ constructor(code: TaskWorkspaceErrorCode, message: string, reasons?: readonly string[]);
11
+ get failureClass(): WorkspaceFailureClass;
12
+ }
13
+ export declare function taskWorkspaceErrorOutcome(code: TaskWorkspaceErrorCode): WorkspaceFailureOutcome;
14
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/task-workspace/errors.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,sBAAsB,GAC9B,iBAAiB,GACjB,oBAAoB,GACpB,qBAAqB,GACrB,aAAa,GACb,iBAAiB,GACjB,yBAAyB,GACzB,iBAAiB,GACjB,eAAe,GACf,qBAAqB,GACrB,qBAAqB,GACrB,oBAAoB,GAIpB,4BAA4B,GAC5B,uBAAuB,GACvB,eAAe,GAIf,sBAAsB,GACtB,gBAAgB,CAAC;AAErB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AAK3E,MAAM,MAAM,uBAAuB,GAAG,SAAS,GAAG,QAAQ,GAAG,gBAAgB,CAAC;AA2D9E,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,sBAAsB,GAAG,qBAAqB,CAE9F;AAED,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,SAAgB,IAAI,EAAE,sBAAsB,CAAC;IAC7C,SAAgB,MAAM,EAAE,MAAM,CAAC;IAC/B,SAAgB,OAAO,EAAE,uBAAuB,CAAC;IACjD,SAAgB,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;gBAGzC,IAAI,EAAE,sBAAsB,EAC5B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,SAAS,MAAM,EAAO;IAYjC,IAAW,YAAY,IAAI,qBAAqB,CAE/C;CACF;AAED,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,GAAG,uBAAuB,CAE/F"}
@@ -0,0 +1,81 @@
1
+ // Structured failure taxonomy for managed task-workspace provisioning + activation (Issue #445).
2
+ //
3
+ // Every failure mode the Issue enumerates — invalid base branch, unsafe path, branch conflict,
4
+ // existing unmanaged path, lock contention, missing repository, worktree pointer drift — maps to ONE
5
+ // explicit code with a deterministic HTTP status and a content-free evidence outcome. A partial
6
+ // failure therefore always leaves a CLASSIFIED, visible state rather than an unclassified one (SC4):
7
+ // the service persists the instance in `failed`/`recovery-required` and emits the matching outcome
8
+ // before the error propagates to the route.
9
+ const ERROR_SPECS = {
10
+ INVALID_REQUEST: { status: 400, outcome: "blocked" },
11
+ MISSING_REPOSITORY: { status: 400, outcome: "blocked" },
12
+ INVALID_BASE_BRANCH: { status: 400, outcome: "blocked" },
13
+ UNSAFE_PATH: { status: 400, outcome: "blocked" },
14
+ BRANCH_CONFLICT: { status: 409, outcome: "blocked" },
15
+ EXISTING_UNMANAGED_PATH: { status: 409, outcome: "blocked" },
16
+ LOCK_CONTENTION: { status: 409, outcome: "retry-required" },
17
+ POINTER_DRIFT: { status: 409, outcome: "retry-required" },
18
+ PROVISIONING_FAILED: { status: 500, outcome: "failed" },
19
+ WORKSPACE_NOT_FOUND: { status: 404, outcome: "blocked" },
20
+ ILLEGAL_TRANSITION: { status: 409, outcome: "blocked" },
21
+ OPERATOR_APPROVAL_REQUIRED: { status: 403, outcome: "blocked" },
22
+ REPAIR_NOT_APPLICABLE: { status: 409, outcome: "blocked" },
23
+ REPAIR_FAILED: { status: 500, outcome: "failed" },
24
+ CLEANUP_NOT_ELIGIBLE: { status: 409, outcome: "blocked" },
25
+ CLEANUP_FAILED: { status: 500, outcome: "failed" },
26
+ };
27
+ // The caller-facing failure classification (Issue #449, ADR-0093 D3). This is a DISTINCT axis from the
28
+ // evidence `outcome` above: the outcome records what the ledger saw (`blocked`/`failed`/`retry-required`),
29
+ // this class tells a BFF/UI caller what to DO next (retry as-is, route to repair, fix inputs, seek
30
+ // approval, or give up). The map is total by construction — `Record<TaskWorkspaceErrorCode, ...>` requires
31
+ // an entry for every code, so adding a future code without classifying it is a compile-time error and the
32
+ // taxonomy can never silently fall out of date.
33
+ //
34
+ // retryable — LOCK_CONTENTION: a live advisory lock held by another actor is TTL-bounded; retry.
35
+ // repairable — POINTER_DRIFT: a stale/missing pointer re-fails a bare retry; route to #447 repair.
36
+ // blocked — a precondition/validation/conflict/applicability gate: change inputs or state.
37
+ // policy-denied — OPERATOR_APPROVAL_REQUIRED: needs governance approval, not a retry.
38
+ // terminal — a mutation errored after the safety gate authorized it: needs intervention.
39
+ const WORKSPACE_FAILURE_CLASS_BY_CODE = {
40
+ INVALID_REQUEST: "blocked",
41
+ MISSING_REPOSITORY: "blocked",
42
+ INVALID_BASE_BRANCH: "blocked",
43
+ UNSAFE_PATH: "blocked",
44
+ BRANCH_CONFLICT: "blocked",
45
+ EXISTING_UNMANAGED_PATH: "blocked",
46
+ LOCK_CONTENTION: "retryable",
47
+ POINTER_DRIFT: "repairable",
48
+ PROVISIONING_FAILED: "terminal",
49
+ WORKSPACE_NOT_FOUND: "blocked",
50
+ ILLEGAL_TRANSITION: "blocked",
51
+ OPERATOR_APPROVAL_REQUIRED: "policy-denied",
52
+ REPAIR_NOT_APPLICABLE: "blocked",
53
+ REPAIR_FAILED: "terminal",
54
+ CLEANUP_NOT_ELIGIBLE: "blocked",
55
+ CLEANUP_FAILED: "terminal",
56
+ };
57
+ export function classifyTaskWorkspaceError(code) {
58
+ return WORKSPACE_FAILURE_CLASS_BY_CODE[code];
59
+ }
60
+ export class TaskWorkspaceError extends Error {
61
+ code;
62
+ status;
63
+ outcome;
64
+ reasons;
65
+ constructor(code, message, reasons = []) {
66
+ super(message);
67
+ this.name = "TaskWorkspaceError";
68
+ this.code = code;
69
+ this.status = ERROR_SPECS[code].status;
70
+ this.outcome = ERROR_SPECS[code].outcome;
71
+ this.reasons = reasons;
72
+ }
73
+ // The caller-facing failure class, derived (never stored) from the code so it can never drift from the
74
+ // taxonomy. Route mappers surface it in the error body so the BFF/UI can branch on a stable signal.
75
+ get failureClass() {
76
+ return classifyTaskWorkspaceError(this.code);
77
+ }
78
+ }
79
+ export function taskWorkspaceErrorOutcome(code) {
80
+ return ERROR_SPECS[code].outcome;
81
+ }
@@ -0,0 +1,32 @@
1
+ import type { EvidenceStore } from "@oscharko-dev/keiko-evidence";
2
+ import { TASK_WORKSPACE_SCHEMA_VERSION, type TaskWorkspaceDriftMarker, type TaskWorkspaceHealth, type TaskWorkspaceLifecycleState, type WorkspaceEvent, type WorkspaceEventType } from "@oscharko-dev/keiko-contracts";
3
+ export declare const WORKSPACE_LIFECYCLE_EVIDENCE_KIND: "task-workspace-lifecycle";
4
+ export type WorkspaceLifecycleOperation = "provision" | "activate" | "pause" | "resume" | "handoff" | "reconcile" | "repair" | "cleanup";
5
+ export type WorkspaceLifecycleOutcome = "provisioned" | "activated" | "resumed" | "paused" | "handoff-prepared" | "blocked" | "failed" | "retry-required" | "reconciled" | "repaired" | "operator-required" | "cleanup-requested" | "cleanup-completed" | "cleanup-refused";
6
+ export interface WorkspaceLifecycleEvidenceRecord {
7
+ readonly kind: typeof WORKSPACE_LIFECYCLE_EVIDENCE_KIND;
8
+ readonly schemaVersion: typeof TASK_WORKSPACE_SCHEMA_VERSION;
9
+ readonly recordedAt: number;
10
+ readonly operation: WorkspaceLifecycleOperation;
11
+ readonly outcome: WorkspaceLifecycleOutcome;
12
+ readonly attempt: number;
13
+ readonly durationMs: number;
14
+ readonly worktreeCount: number;
15
+ readonly event: WorkspaceEvent;
16
+ }
17
+ export interface BuildWorkspaceEventInput {
18
+ readonly eventId: string;
19
+ readonly workspaceId: string;
20
+ readonly taskId: string;
21
+ readonly type: WorkspaceEventType;
22
+ readonly at: string;
23
+ readonly correlationId: string;
24
+ readonly fromState?: TaskWorkspaceLifecycleState | undefined;
25
+ readonly toState?: TaskWorkspaceLifecycleState | undefined;
26
+ readonly health?: TaskWorkspaceHealth | undefined;
27
+ readonly driftMarkers?: readonly TaskWorkspaceDriftMarker[] | undefined;
28
+ readonly lockId?: string | undefined;
29
+ }
30
+ export declare function buildWorkspaceEvent(input: BuildWorkspaceEventInput): WorkspaceEvent;
31
+ export declare function appendWorkspaceLifecycleEvidence(store: EvidenceStore, record: WorkspaceLifecycleEvidenceRecord, redact: (input: string) => string): string | undefined;
32
+ //# sourceMappingURL=evidence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evidence.d.ts","sourceRoot":"","sources":["../../src/task-workspace/evidence.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAClE,OAAO,EACL,6BAA6B,EAE7B,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EACxB,KAAK,2BAA2B,EAChC,KAAK,cAAc,EACnB,KAAK,kBAAkB,EACxB,MAAM,+BAA+B,CAAC;AAEvC,eAAO,MAAM,iCAAiC,EAAG,0BAAmC,CAAC;AAOrF,MAAM,MAAM,2BAA2B,GACnC,WAAW,GACX,UAAU,GACV,OAAO,GACP,QAAQ,GACR,SAAS,GACT,WAAW,GACX,QAAQ,GACR,SAAS,CAAC;AAEd,MAAM,MAAM,yBAAyB,GACjC,aAAa,GACb,WAAW,GACX,SAAS,GACT,QAAQ,GACR,kBAAkB,GAClB,SAAS,GACT,QAAQ,GACR,gBAAgB,GAIhB,YAAY,GACZ,UAAU,GACV,mBAAmB,GAInB,mBAAmB,GACnB,mBAAmB,GACnB,iBAAiB,CAAC;AAEtB,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,CAAC,IAAI,EAAE,OAAO,iCAAiC,CAAC;IACxD,QAAQ,CAAC,aAAa,EAAE,OAAO,6BAA6B,CAAC;IAC7D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,2BAA2B,CAAC;IAChD,QAAQ,CAAC,OAAO,EAAE,yBAAyB,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;CAChC;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;IAClC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IAC7D,QAAQ,CAAC,OAAO,CAAC,EAAE,2BAA2B,GAAG,SAAS,CAAC;IAC3D,QAAQ,CAAC,MAAM,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;IAClD,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,wBAAwB,EAAE,GAAG,SAAS,CAAC;IACxE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACtC;AAMD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,GAAG,cAAc,CAsBnF;AAMD,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,gCAAgC,EACxC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,GAAG,SAAS,CAOpB"}
@@ -0,0 +1,52 @@
1
+ // Content-free lifecycle evidence for managed task workspaces (Issue #445, Epic #443).
2
+ //
3
+ // Each provision / activate / block / fail / retry-required outcome writes ONE evidence document
4
+ // through the shared EvidenceStore.put port, redacted via the same deepRedactStrings defense-in-depth
5
+ // as the command-runner and memory-audit ledgers. The payload is a #444 `WorkspaceEvent` — validated
6
+ // here against the contract's closed allowlist so a smuggled source/secret field is rejected before
7
+ // persistence (SC3) — plus counts/enums only (operation, outcome, duration, attempt, worktree count).
8
+ // It carries NO repository root, NO worktree path, NO branch name, NO command output.
9
+ //
10
+ // `EvidenceTaskType` is a closed union, so this is NOT an EvidenceManifest: it is a self-describing
11
+ // content-free document keyed by the event id, exactly like the memory-audit ledger entries.
12
+ import { deepRedactStrings } from "@oscharko-dev/keiko-evidence";
13
+ import { TASK_WORKSPACE_SCHEMA_VERSION, validateWorkspaceEvent, } from "@oscharko-dev/keiko-contracts";
14
+ export const WORKSPACE_LIFECYCLE_EVIDENCE_KIND = "task-workspace-lifecycle";
15
+ // Assembles and VALIDATES a content-free WorkspaceEvent. The validation is defense in depth: with the
16
+ // typed inputs above the event is always well-formed, but routing it through the contract validator
17
+ // guarantees no caller can ever persist a non-content-free shape through this path (SC3). Throws on a
18
+ // validation failure rather than persisting a malformed audit record.
19
+ export function buildWorkspaceEvent(input) {
20
+ const event = {
21
+ schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
22
+ eventId: input.eventId,
23
+ workspaceId: input.workspaceId,
24
+ taskId: input.taskId,
25
+ type: input.type,
26
+ at: input.at,
27
+ correlationId: input.correlationId,
28
+ ...(input.fromState !== undefined ? { fromState: input.fromState } : {}),
29
+ ...(input.toState !== undefined ? { toState: input.toState } : {}),
30
+ ...(input.health !== undefined ? { health: input.health } : {}),
31
+ ...(input.driftMarkers !== undefined ? { driftMarkers: input.driftMarkers } : {}),
32
+ ...(input.lockId !== undefined ? { lockId: input.lockId } : {}),
33
+ };
34
+ const validation = validateWorkspaceEvent(event);
35
+ if (!validation.ok) {
36
+ throw new Error(`content-free workspace event invariant violated: ${validation.reasons.join("; ")}`);
37
+ }
38
+ return event;
39
+ }
40
+ // Persists ONE lifecycle evidence document, redacting every string leaf first. Best-effort by
41
+ // construction: an evidence-store error must never corrupt the real provisioning result, so it is
42
+ // swallowed and reported as `undefined` (mirrors the command-runner evidence write). Returns the
43
+ // stored location on success.
44
+ export function appendWorkspaceLifecycleEvidence(store, record, redact) {
45
+ try {
46
+ const safe = deepRedactStrings(record, redact);
47
+ return store.put(safe.event.eventId, JSON.stringify(safe, null, 2));
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
@@ -0,0 +1,3 @@
1
+ export declare function containsUnsafeFieldChars(value: string): boolean;
2
+ export declare function assertSafeFieldValue(value: string, field: string): void;
3
+ //# sourceMappingURL=field-safety.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-safety.d.ts","sourceRoot":"","sources":["../../src/task-workspace/field-safety.ts"],"names":[],"mappings":"AA4BA,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAO/D;AAID,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIvE"}