@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.
Files changed (281) 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 +27 -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 +45 -0
  84. package/dist/files.d.ts.map +1 -1
  85. package/dist/files.js +631 -7
  86. package/dist/gateway-readiness.js +3 -3
  87. package/dist/gateway-setup.d.ts +2 -0
  88. package/dist/gateway-setup.d.ts.map +1 -1
  89. package/dist/gateway-setup.js +275 -11
  90. package/dist/gitDelivery/actionSheetProjection.d.ts +30 -0
  91. package/dist/gitDelivery/actionSheetProjection.d.ts.map +1 -0
  92. package/dist/gitDelivery/actionSheetProjection.js +206 -0
  93. package/dist/gitDelivery/actionSheetRoutes.d.ts +29 -0
  94. package/dist/gitDelivery/actionSheetRoutes.d.ts.map +1 -0
  95. package/dist/gitDelivery/actionSheetRoutes.js +293 -0
  96. package/dist/gitDelivery/agentOperationsRoutes.d.ts +33 -0
  97. package/dist/gitDelivery/agentOperationsRoutes.d.ts.map +1 -0
  98. package/dist/gitDelivery/agentOperationsRoutes.js +405 -0
  99. package/dist/gitDelivery/commitRoutes.d.ts +23 -0
  100. package/dist/gitDelivery/commitRoutes.d.ts.map +1 -0
  101. package/dist/gitDelivery/commitRoutes.js +204 -0
  102. package/dist/gitDelivery/evidenceRoutes.d.ts +9 -0
  103. package/dist/gitDelivery/evidenceRoutes.d.ts.map +1 -0
  104. package/dist/gitDelivery/evidenceRoutes.js +101 -0
  105. package/dist/gitDelivery/execution.d.ts +38 -0
  106. package/dist/gitDelivery/execution.d.ts.map +1 -0
  107. package/dist/gitDelivery/execution.js +117 -0
  108. package/dist/gitDelivery/localMutationRoutes.d.ts +30 -0
  109. package/dist/gitDelivery/localMutationRoutes.d.ts.map +1 -0
  110. package/dist/gitDelivery/localMutationRoutes.js +165 -0
  111. package/dist/gitDelivery/mergeExecution.d.ts +63 -0
  112. package/dist/gitDelivery/mergeExecution.d.ts.map +1 -0
  113. package/dist/gitDelivery/mergeExecution.js +168 -0
  114. package/dist/gitDelivery/mergeRoutes.d.ts +12 -0
  115. package/dist/gitDelivery/mergeRoutes.d.ts.map +1 -0
  116. package/dist/gitDelivery/mergeRoutes.js +218 -0
  117. package/dist/gitDelivery/mutationEvidenceLedger.d.ts +23 -0
  118. package/dist/gitDelivery/mutationEvidenceLedger.d.ts.map +1 -0
  119. package/dist/gitDelivery/mutationEvidenceLedger.js +87 -0
  120. package/dist/gitDelivery/prExecution.d.ts +54 -0
  121. package/dist/gitDelivery/prExecution.d.ts.map +1 -0
  122. package/dist/gitDelivery/prExecution.js +192 -0
  123. package/dist/gitDelivery/prRoutes.d.ts +12 -0
  124. package/dist/gitDelivery/prRoutes.d.ts.map +1 -0
  125. package/dist/gitDelivery/prRoutes.js +256 -0
  126. package/dist/gitDelivery/pushExecution.d.ts +43 -0
  127. package/dist/gitDelivery/pushExecution.d.ts.map +1 -0
  128. package/dist/gitDelivery/pushExecution.js +124 -0
  129. package/dist/gitDelivery/pushRoutes.d.ts +12 -0
  130. package/dist/gitDelivery/pushRoutes.d.ts.map +1 -0
  131. package/dist/gitDelivery/pushRoutes.js +200 -0
  132. package/dist/gitDelivery/requestGuards.d.ts +15 -0
  133. package/dist/gitDelivery/requestGuards.d.ts.map +1 -0
  134. package/dist/gitDelivery/requestGuards.js +97 -0
  135. package/dist/gitDelivery/syncEvidence.d.ts +37 -0
  136. package/dist/gitDelivery/syncEvidence.d.ts.map +1 -0
  137. package/dist/gitDelivery/syncEvidence.js +85 -0
  138. package/dist/gitDelivery/syncExecution.d.ts +30 -0
  139. package/dist/gitDelivery/syncExecution.d.ts.map +1 -0
  140. package/dist/gitDelivery/syncExecution.js +266 -0
  141. package/dist/gitDelivery/syncRoutes.d.ts +13 -0
  142. package/dist/gitDelivery/syncRoutes.d.ts.map +1 -0
  143. package/dist/gitDelivery/syncRoutes.js +200 -0
  144. package/dist/gitPorcelainStatus.d.ts +15 -0
  145. package/dist/gitPorcelainStatus.d.ts.map +1 -0
  146. package/dist/gitPorcelainStatus.js +104 -0
  147. package/dist/gitRepositoryReads.d.ts +10 -0
  148. package/dist/gitRepositoryReads.d.ts.map +1 -0
  149. package/dist/gitRepositoryReads.js +314 -0
  150. package/dist/gitRepositoryRoutes.d.ts +7 -0
  151. package/dist/gitRepositoryRoutes.d.ts.map +1 -0
  152. package/dist/gitRepositoryRoutes.js +221 -0
  153. package/dist/gitRoutes.d.ts +66 -0
  154. package/dist/gitRoutes.d.ts.map +1 -0
  155. package/dist/gitRoutes.js +543 -0
  156. package/dist/governed-workflow.d.ts +2 -0
  157. package/dist/governed-workflow.d.ts.map +1 -1
  158. package/dist/governed-workflow.js +4 -0
  159. package/dist/grounded-qa.d.ts +11 -0
  160. package/dist/grounded-qa.d.ts.map +1 -1
  161. package/dist/grounded-qa.js +13 -4
  162. package/dist/headers.d.ts +4 -1
  163. package/dist/headers.d.ts.map +1 -1
  164. package/dist/headers.js +11 -4
  165. package/dist/index.d.ts +8 -1
  166. package/dist/index.d.ts.map +1 -1
  167. package/dist/index.js +9 -1
  168. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts +1 -1
  169. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts.map +1 -1
  170. package/dist/qualityIntelligence/figmaSnapshotRoutes.js +1 -1
  171. package/dist/read-handlers.d.ts +5 -0
  172. package/dist/read-handlers.d.ts.map +1 -1
  173. package/dist/read-handlers.js +57 -1
  174. package/dist/routes.d.ts.map +1 -1
  175. package/dist/routes.js +259 -6
  176. package/dist/run-engine.d.ts.map +1 -1
  177. package/dist/run-engine.js +3 -0
  178. package/dist/run-handlers.d.ts.map +1 -1
  179. package/dist/run-handlers.js +74 -4
  180. package/dist/run-request.d.ts +11 -0
  181. package/dist/run-request.d.ts.map +1 -1
  182. package/dist/run-request.js +158 -10
  183. package/dist/runtime/capabilityDetector.d.ts +38 -0
  184. package/dist/runtime/capabilityDetector.d.ts.map +1 -0
  185. package/dist/runtime/capabilityDetector.js +443 -0
  186. package/dist/runtime/capabilityRoutes.d.ts +9 -0
  187. package/dist/runtime/capabilityRoutes.d.ts.map +1 -0
  188. package/dist/runtime/capabilityRoutes.js +45 -0
  189. package/dist/runtime/containerEngineDetector.d.ts +17 -0
  190. package/dist/runtime/containerEngineDetector.d.ts.map +1 -0
  191. package/dist/runtime/containerEngineDetector.js +222 -0
  192. package/dist/runtime/containerRoutes.d.ts +8 -0
  193. package/dist/runtime/containerRoutes.d.ts.map +1 -0
  194. package/dist/runtime/containerRoutes.js +207 -0
  195. package/dist/runtime/containerRunner-errors.d.ts +18 -0
  196. package/dist/runtime/containerRunner-errors.d.ts.map +1 -0
  197. package/dist/runtime/containerRunner-errors.js +42 -0
  198. package/dist/runtime/containerRunner-evidence.d.ts +24 -0
  199. package/dist/runtime/containerRunner-evidence.d.ts.map +1 -0
  200. package/dist/runtime/containerRunner-evidence.js +74 -0
  201. package/dist/runtime/containerRunner.d.ts +37 -0
  202. package/dist/runtime/containerRunner.d.ts.map +1 -0
  203. package/dist/runtime/containerRunner.js +443 -0
  204. package/dist/server.d.ts.map +1 -1
  205. package/dist/server.js +24 -4
  206. package/dist/store/schema.d.ts +1 -1
  207. package/dist/store/schema.d.ts.map +1 -1
  208. package/dist/store/schema.js +62 -1
  209. package/dist/task-workspace/active-store.d.ts +21 -0
  210. package/dist/task-workspace/active-store.d.ts.map +1 -0
  211. package/dist/task-workspace/active-store.js +55 -0
  212. package/dist/task-workspace/authorization.d.ts +7 -0
  213. package/dist/task-workspace/authorization.d.ts.map +1 -0
  214. package/dist/task-workspace/authorization.js +54 -0
  215. package/dist/task-workspace/binding.d.ts +3 -0
  216. package/dist/task-workspace/binding.d.ts.map +1 -0
  217. package/dist/task-workspace/binding.js +22 -0
  218. package/dist/task-workspace/cleanup.d.ts +4 -0
  219. package/dist/task-workspace/cleanup.d.ts.map +1 -0
  220. package/dist/task-workspace/cleanup.js +428 -0
  221. package/dist/task-workspace/errors.d.ts +14 -0
  222. package/dist/task-workspace/errors.d.ts.map +1 -0
  223. package/dist/task-workspace/errors.js +81 -0
  224. package/dist/task-workspace/evidence.d.ts +32 -0
  225. package/dist/task-workspace/evidence.d.ts.map +1 -0
  226. package/dist/task-workspace/evidence.js +52 -0
  227. package/dist/task-workspace/field-safety.d.ts +3 -0
  228. package/dist/task-workspace/field-safety.d.ts.map +1 -0
  229. package/dist/task-workspace/field-safety.js +42 -0
  230. package/dist/task-workspace/health.d.ts +4 -0
  231. package/dist/task-workspace/health.d.ts.map +1 -0
  232. package/dist/task-workspace/health.js +163 -0
  233. package/dist/task-workspace/lifecycle.d.ts +3 -0
  234. package/dist/task-workspace/lifecycle.d.ts.map +1 -0
  235. package/dist/task-workspace/lifecycle.js +248 -0
  236. package/dist/task-workspace/locks.d.ts +13 -0
  237. package/dist/task-workspace/locks.d.ts.map +1 -0
  238. package/dist/task-workspace/locks.js +44 -0
  239. package/dist/task-workspace/managed-root.d.ts +7 -0
  240. package/dist/task-workspace/managed-root.d.ts.map +1 -0
  241. package/dist/task-workspace/managed-root.js +98 -0
  242. package/dist/task-workspace/mutex.d.ts +8 -0
  243. package/dist/task-workspace/mutex.d.ts.map +1 -0
  244. package/dist/task-workspace/mutex.js +82 -0
  245. package/dist/task-workspace/naming.d.ts +15 -0
  246. package/dist/task-workspace/naming.d.ts.map +1 -0
  247. package/dist/task-workspace/naming.js +0 -0
  248. package/dist/task-workspace/provisioning.d.ts +3 -0
  249. package/dist/task-workspace/provisioning.d.ts.map +1 -0
  250. package/dist/task-workspace/provisioning.js +528 -0
  251. package/dist/task-workspace/reconciliation.d.ts +15 -0
  252. package/dist/task-workspace/reconciliation.d.ts.map +1 -0
  253. package/dist/task-workspace/reconciliation.js +274 -0
  254. package/dist/task-workspace/repair.d.ts +3 -0
  255. package/dist/task-workspace/repair.d.ts.map +1 -0
  256. package/dist/task-workspace/repair.js +286 -0
  257. package/dist/task-workspace/routes.d.ts +19 -0
  258. package/dist/task-workspace/routes.d.ts.map +1 -0
  259. package/dist/task-workspace/routes.js +481 -0
  260. package/dist/task-workspace/store.d.ts +12 -0
  261. package/dist/task-workspace/store.d.ts.map +1 -0
  262. package/dist/task-workspace/store.js +128 -0
  263. package/dist/task-workspace/types.d.ts +170 -0
  264. package/dist/task-workspace/types.d.ts.map +1 -0
  265. package/dist/task-workspace/types.js +5 -0
  266. package/dist/voice-action-governance.d.ts +23 -0
  267. package/dist/voice-action-governance.d.ts.map +1 -0
  268. package/dist/voice-action-governance.js +126 -0
  269. package/dist/voice-handlers.d.ts +6 -0
  270. package/dist/voice-handlers.d.ts.map +1 -0
  271. package/dist/voice-handlers.js +570 -0
  272. package/dist/voice-realtime-grounded-tool.d.ts +31 -0
  273. package/dist/voice-realtime-grounded-tool.d.ts.map +1 -0
  274. package/dist/voice-realtime-grounded-tool.js +322 -0
  275. package/dist/voice-realtime.d.ts +69 -0
  276. package/dist/voice-realtime.d.ts.map +1 -0
  277. package/dist/voice-realtime.js +787 -0
  278. package/dist/workspace-state-handlers.d.ts +5 -0
  279. package/dist/workspace-state-handlers.d.ts.map +1 -0
  280. package/dist/workspace-state-handlers.js +106 -0
  281. package/package.json +20 -19
@@ -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"}
@@ -0,0 +1,42 @@
1
+ // Strict free-form identity-field guard for managed task workspaces
2
+ // (Issue #449 / PR #1587 security follow-up — LOW, defense in depth).
3
+ //
4
+ // `requestedBy` and `taskId` are length-bounded but otherwise free-form. They flow into the advisory
5
+ // lock `owner` (locks.ts makeWorkspaceLock), the active-pointer `setBy` (lifecycle.ts setActiveImpl),
6
+ // the persisted WorkspaceInstance, and the content-free lifecycle evidence — all of which may later be
7
+ // surfaced to an operator. There is no injection sink in the server layer today, so this is a
8
+ // fail-closed hardening: a C0/C1/DEL control, a zero-width, or a bidirectional-override
9
+ // ("Trojan-source") code point in an identity field signals a bug or a spoofing attempt (a forged
10
+ // operator name on an audit line, a reordered task id), never legitimate input.
11
+ //
12
+ // We REJECT rather than strip. `taskId` is the idempotency key — it is hashed raw into the workspace
13
+ // id, the task branch, and the managed worktree path (naming.ts) — and `requestedBy` is compared by
14
+ // equality for advisory-lock ownership. Silently stripping would change the derived identity and could
15
+ // collapse two distinct actors to the same owner string; rejecting keeps the persisted identity
16
+ // faithful to the caller's input.
17
+ //
18
+ // The unsafe character set IS the canonical `stripUnsafeFormatChars` primitive (keiko-contracts
19
+ // text-safety), reused so the security-critical Unicode ranges live in exactly one place, made
20
+ // STRICTER: identity fields are single-line tokens, so the TAB/LF/CR that text-safety deliberately
21
+ // preserves for multi-line evidence are forbidden here too (they would otherwise enable log-line
22
+ // spoofing in an operator audit view).
23
+ import { stripUnsafeFormatChars } from "@oscharko-dev/keiko-contracts/text-safety";
24
+ import { TaskWorkspaceError } from "./errors.js";
25
+ // True when `value` carries any control (C0/C1/DEL, incl. TAB/LF/CR), zero-width, BOM, or
26
+ // bidirectional-override code point. Pure; no allocation on the common (clean) path.
27
+ export function containsUnsafeFieldChars(value) {
28
+ // stripUnsafeFormatChars removes C0/C1/DEL (except TAB/LF/CR) + bidi/zero-width/BOM and returns the
29
+ // SAME reference when nothing was removed, so a reference inequality means an unsafe code point was
30
+ // present. The TAB/LF/CR that text-safety preserves are caught explicitly to keep identity fields
31
+ // strictly single-line.
32
+ if (stripUnsafeFormatChars(value) !== value)
33
+ return true;
34
+ return /[\t\n\r]/u.test(value);
35
+ }
36
+ // Throws INVALID_REQUEST when a free-form identity field carries a forbidden code point. `field` names
37
+ // the offending field in the reason list WITHOUT echoing its (untrusted) value.
38
+ export function assertSafeFieldValue(value, field) {
39
+ if (containsUnsafeFieldChars(value)) {
40
+ throw new TaskWorkspaceError("INVALID_REQUEST", "field contains forbidden characters", [field]);
41
+ }
42
+ }
@@ -0,0 +1,4 @@
1
+ import type { WorkspaceHealthService, WorkspaceHealthServiceDeps } from "./types.js";
2
+ export declare function deriveOrphanId(repositoryId: string, leaf: string): string;
3
+ export declare function createWorkspaceHealthService(deps: WorkspaceHealthServiceDeps): WorkspaceHealthService;
4
+ //# sourceMappingURL=health.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/task-workspace/health.ts"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAWrF,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKzE;AAsKD,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,0BAA0B,GAC/B,sBAAsB,CAKxB"}
@@ -0,0 +1,163 @@
1
+ // Operational health + drift evaluation and orphan detection for managed task workspaces (Issue #448,
2
+ // Epic #443).
3
+ //
4
+ // This is the READ-ONLY observation surface. For each persisted instance it gathers the SAME
5
+ // content-free facts the #447 reconciler uses — realpath containment, the `.git` pointer/HEAD/branch
6
+ // identity, and lock liveness (delegated to gatherInstanceReconciliationFacts, no second engine) — then
7
+ // adds the two #448-specific LIVE signals the narrow #445 adapter could not provide before: a
8
+ // `git status --porcelain` dirty probe (through the read-only `status` verb added in #448) and the
9
+ // managed-root ownership proof. Every classification decision is deferred to the pure contract
10
+ // (classifyWorkspaceHealth), so health stays deterministic and 100%-testable.
11
+ //
12
+ // It additionally detects ORPHANED managed worktrees: directories under the Keiko-owned managed root
13
+ // that no persisted record references. Each candidate is realpath-contained before it is reported, and
14
+ // it is surfaced with a content-free `orphanId` (a hash of its managed-root-relative location) so the
15
+ // report carries no path. The service performs NO store writes and emits NO evidence — reconciliation
16
+ // (#447) owns the persisted health columns and their events; health is pure observation.
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, readdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { detectWorkspaceAt } from "@oscharko-dev/keiko-workspace";
21
+ import { TASK_WORKSPACE_SCHEMA_VERSION, classifyWorkspaceHealth, deriveOrphanWorktreeHealthEntry, deriveWorkspaceHealthEntry, evaluateWorkspaceCleanupSafety, } from "@oscharko-dev/keiko-contracts";
22
+ import { deriveRepositoryId } from "./naming.js";
23
+ import { isManagedRootOwned, isManagedTargetContained } from "./managed-root.js";
24
+ import { gatherInstanceReconciliationFacts } from "./reconciliation.js";
25
+ const ORPHAN_ID_PREFIX = "orph_";
26
+ function isoFrom(nowMs) {
27
+ return new Date(nowMs).toISOString();
28
+ }
29
+ // Content-free identity for an orphaned managed directory: a hash of its managed-root-relative
30
+ // `<repositoryId>/<leaf>` location, so the report references the orphan without leaking any path.
31
+ // Exported so the cleanup service references the SAME id when it acts on or refuses an orphan.
32
+ export function deriveOrphanId(repositoryId, leaf) {
33
+ return (ORPHAN_ID_PREFIX +
34
+ createHash("sha256").update(`${repositoryId}/${leaf}`, "utf8").digest("hex").slice(0, 24));
35
+ }
36
+ // A live dirty probe through a worktree-bound adapter. Only meaningful when the worktree exists and is
37
+ // contained; an inconclusive probe (broken pointer, unreadable tree) reports not-dirty — the structural
38
+ // classification already surfaces a broken/missing worktree, and containment + ownership remain the
39
+ // authoritative cleanup guards.
40
+ async function probeDirty(deps, worktreePath, probeable) {
41
+ if (!probeable)
42
+ return false;
43
+ const adapter = deps.createAdapter(detectWorkspaceAt(worktreePath));
44
+ const status = await adapter.worktreeStatus();
45
+ return status.ok && status.dirty;
46
+ }
47
+ // Classifies ONE persisted instance against pre-fetched repository worktree state, layering the live
48
+ // dirty + ownership signals onto the reused reconciliation facts. Pure decisioning is delegated to the
49
+ // contract; this only does the IO.
50
+ async function evaluateInstance(deps, adapter, worktrees, instance, ownershipProven, nowMs) {
51
+ const { facts } = await gatherInstanceReconciliationFacts(deps, adapter, worktrees, instance, nowMs);
52
+ const worktreeDirty = await probeDirty(deps, instance.managedWorktreePath, facts.worktreeDirExists && facts.pathContained);
53
+ const evaluation = classifyWorkspaceHealth({
54
+ reconciliation: facts,
55
+ worktreeDirty,
56
+ ownershipProven,
57
+ });
58
+ return deriveWorkspaceHealthEntry({
59
+ workspaceId: instance.workspaceId,
60
+ taskId: instance.taskId,
61
+ lifecycleState: instance.lifecycleState,
62
+ health: instance.health,
63
+ evaluation,
64
+ ...(instance.lastVerifiedAt !== undefined ? { lastVerifiedAt: instance.lastVerifiedAt } : {}),
65
+ });
66
+ }
67
+ // Detects orphaned managed worktrees for one repository: directories under `<managedRoot>/<repoId>`
68
+ // that no persisted instance references. Each candidate is realpath-contained before it is reported,
69
+ // and its live cleanup-eligibility is evaluated (owned + contained + clean; orphans hold no lock).
70
+ async function detectOrphans(deps, repositoryId, knownPaths, ownershipProven) {
71
+ const repoDir = join(deps.managedRoot, repositoryId);
72
+ if (!existsSync(repoDir))
73
+ return [];
74
+ let leaves;
75
+ try {
76
+ leaves = readdirSync(repoDir, { withFileTypes: true })
77
+ .filter((entry) => entry.isDirectory())
78
+ .map((entry) => entry.name);
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const entries = [];
84
+ for (const leaf of leaves) {
85
+ const candidate = join(repoDir, leaf);
86
+ if (knownPaths.has(candidate))
87
+ continue;
88
+ const contained = isManagedTargetContained(deps.managedRoot, candidate);
89
+ if (!contained) {
90
+ // An uncontained directory (e.g. a symlink escape) is reported as an orphan but NEVER
91
+ // cleanup-eligible — the live safety gate refuses it.
92
+ entries.push(deriveOrphanWorktreeHealthEntry({
93
+ orphanId: deriveOrphanId(repositoryId, leaf),
94
+ cleanupEligible: false,
95
+ }));
96
+ continue;
97
+ }
98
+ const worktreeDirty = await probeDirty(deps, candidate, true);
99
+ const decision = evaluateWorkspaceCleanupSafety({
100
+ lifecycleState: "abandoned",
101
+ hasRecord: false,
102
+ pathContained: true,
103
+ ownershipProven,
104
+ worktreeDirty,
105
+ lockLive: false,
106
+ });
107
+ entries.push(deriveOrphanWorktreeHealthEntry({
108
+ orphanId: deriveOrphanId(repositoryId, leaf),
109
+ cleanupEligible: decision.allowed,
110
+ }));
111
+ }
112
+ return entries;
113
+ }
114
+ // Groups the in-scope instances by repository root so each repository's worktree list is fetched once.
115
+ function groupByRepositoryRoot(instances) {
116
+ const byRepo = new Map();
117
+ for (const instance of instances) {
118
+ const group = byRepo.get(instance.repositoryRoot) ?? [];
119
+ group.push(instance);
120
+ byRepo.set(instance.repositoryRoot, group);
121
+ }
122
+ return byRepo;
123
+ }
124
+ function instancesFor(deps, repositoryRoot) {
125
+ if (repositoryRoot === undefined || repositoryRoot.length === 0)
126
+ return deps.store.listAll();
127
+ return deps.store.listByRepository(deriveRepositoryId(repositoryRoot));
128
+ }
129
+ async function reportImpl(deps, repositoryRoot) {
130
+ const instances = instancesFor(deps, repositoryRoot);
131
+ const ownershipProven = isManagedRootOwned(deps.managedRoot);
132
+ const byRepo = groupByRepositoryRoot(instances);
133
+ const entries = [];
134
+ const seenRepoIds = new Set();
135
+ for (const [root, group] of byRepo) {
136
+ const repositoryId = deriveRepositoryId(root);
137
+ seenRepoIds.add(repositoryId);
138
+ const adapter = deps.createAdapter(detectWorkspaceAt(root));
139
+ const worktrees = await adapter.listWorktrees();
140
+ const knownPaths = new Set(group.map((instance) => instance.managedWorktreePath));
141
+ for (const instance of group) {
142
+ entries.push(await evaluateInstance(deps, adapter, worktrees, instance, ownershipProven, deps.now()));
143
+ }
144
+ entries.push(...(await detectOrphans(deps, repositoryId, knownPaths, ownershipProven)));
145
+ }
146
+ // A scoped report whose repository has no persisted instances still surfaces its orphans.
147
+ if (repositoryRoot !== undefined && repositoryRoot.length > 0) {
148
+ const repositoryId = deriveRepositoryId(repositoryRoot);
149
+ if (!seenRepoIds.has(repositoryId)) {
150
+ entries.push(...(await detectOrphans(deps, repositoryId, new Set(), ownershipProven)));
151
+ }
152
+ }
153
+ return {
154
+ schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
155
+ generatedAt: isoFrom(deps.now()),
156
+ entries,
157
+ };
158
+ }
159
+ export function createWorkspaceHealthService(deps) {
160
+ return {
161
+ report: (repositoryRoot) => reportImpl(deps, repositoryRoot),
162
+ };
163
+ }
@@ -0,0 +1,3 @@
1
+ import type { WorkspaceLifecycleService, WorkspaceLifecycleServiceDeps } from "./types.js";
2
+ export declare function createWorkspaceLifecycleService(deps: WorkspaceLifecycleServiceDeps): WorkspaceLifecycleService;
3
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../src/task-workspace/lifecycle.ts"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAKV,yBAAyB,EACzB,6BAA6B,EAC9B,MAAM,YAAY,CAAC;AAyRpB,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,6BAA6B,GAClC,yBAAyB,CAyB3B"}
@@ -0,0 +1,248 @@
1
+ // Active task-workspace binding + lifecycle-action service (Issue #446, Epic #443).
2
+ //
3
+ // This is the cross-surface binding AUTHORITY. It owns the singleton active pointer (active-store.ts)
4
+ // and the operator-driven switch / pause / resume / handoff-preparation actions. The WorkspaceBinding
5
+ // every surface consumes is DERIVED from the active instance (binding.ts) — there is one derivation,
6
+ // never a recomputed second copy of the root, so a switch atomically retargets all surfaces.
7
+ //
8
+ // It REUSES #445 and does NOT duplicate any worktree / lock / transition engine (SC1):
9
+ // - setActive / resume delegate the lifecycle walk into `active` to provisioning.activate(), which
10
+ // already gates validateTaskWorkspaceTransition, resolves preconditions, persists, emits evidence,
11
+ // and rejects drift; this service only then records the active pointer.
12
+ // - pause / prepareHandoff load the instance, resolve the #444 TaskWorkspaceTransitionContext,
13
+ // gate validateTaskWorkspaceTransition, persist through the SAME store, and append the SAME
14
+ // content-free lifecycle evidence.
15
+ //
16
+ // The worktree-clean precondition is derived from the persisted instance's drift markers
17
+ // (uncommitted-changes ⇒ dirty) — the SAME signal the switcher renders as the dirty badge — so the
18
+ // service stays within the deliberately narrow #445 worktree adapter (no `git status` subcommand, no
19
+ // adapter allowlist widening). Live re-verification of cleanliness is #447/#448's responsibility.
20
+ import { TASK_WORKSPACE_SCHEMA_VERSION, validateTaskWorkspaceTransition, } from "@oscharko-dev/keiko-contracts";
21
+ import { buildBinding } from "./binding.js";
22
+ import { assertSafeFieldValue } from "./field-safety.js";
23
+ import { deriveManagedWorktreePath, deriveRepositoryId } from "./naming.js";
24
+ import { isManagedTargetContained, managedTargetExists } from "./managed-root.js";
25
+ import { lockIsLive, resolveLockTtl } from "./locks.js";
26
+ import { activePointerKey, 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
+ function emit(ctx, input) {
37
+ const event = buildWorkspaceEvent({
38
+ eventId: ctx.deps.newId(),
39
+ workspaceId: input.instance.workspaceId,
40
+ taskId: input.instance.taskId,
41
+ type: input.type,
42
+ at: isoFrom(input.nowMs),
43
+ correlationId: input.instance.workspaceId,
44
+ fromState: input.fromState,
45
+ toState: input.instance.lifecycleState,
46
+ });
47
+ appendWorkspaceLifecycleEvidence(ctx.deps.evidenceStore, {
48
+ kind: WORKSPACE_LIFECYCLE_EVIDENCE_KIND,
49
+ schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
50
+ recordedAt: input.nowMs,
51
+ operation: input.operation,
52
+ outcome: input.outcome,
53
+ attempt: 1,
54
+ durationMs: 0,
55
+ worktreeCount: 0,
56
+ event,
57
+ }, ctx.deps.redactString);
58
+ }
59
+ function loadInstance(ctx, workspaceId) {
60
+ if (!isBoundedNonEmpty(workspaceId)) {
61
+ throw new TaskWorkspaceError("INVALID_REQUEST", "invalid workspaceId");
62
+ }
63
+ const instance = ctx.deps.store.getById(workspaceId);
64
+ if (instance === undefined) {
65
+ throw new TaskWorkspaceError("WORKSPACE_NOT_FOUND", "workspace not found");
66
+ }
67
+ return instance;
68
+ }
69
+ function assertBindableManagedPath(ctx, instance) {
70
+ const expected = deriveManagedWorktreePath({
71
+ managedRoot: ctx.deps.managedRoot,
72
+ repositoryId: instance.repositoryId,
73
+ workspaceId: instance.workspaceId,
74
+ });
75
+ if (instance.managedWorktreePath !== expected ||
76
+ !isManagedTargetContained(ctx.deps.managedRoot, instance.managedWorktreePath)) {
77
+ throw new TaskWorkspaceError("POINTER_DRIFT", "persisted managed worktree path is not bindable");
78
+ }
79
+ }
80
+ function canExposeBinding(ctx, instance) {
81
+ if (instance.lifecycleState !== "active" && instance.lifecycleState !== "handoff-ready") {
82
+ return false;
83
+ }
84
+ try {
85
+ assertBindableManagedPath(ctx, instance);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ return managedTargetExists(instance.managedWorktreePath);
91
+ }
92
+ // Resolves the #444 transition context for a direct (pause / handoff) action. The actor holds the
93
+ // mutation lock by construction unless ANOTHER actor holds a live lock (then it is contention, not a
94
+ // transition rejection). worktree-clean is the persisted drift signal; path-contained is the managed
95
+ // worktree still being present on disk.
96
+ function resolveTransitionContext(ctx, instance, requestedBy, nowMs) {
97
+ if (lockIsLive(instance.lock, nowMs, ctx.lockTtlMs) && instance.lock?.owner !== requestedBy) {
98
+ throw new TaskWorkspaceError("LOCK_CONTENTION", "workspace is locked by another actor");
99
+ }
100
+ return {
101
+ lockHeldByActor: true,
102
+ pathContained: isManagedTargetContained(ctx.deps.managedRoot, instance.managedWorktreePath) &&
103
+ managedTargetExists(instance.managedWorktreePath),
104
+ worktreeClean: !instance.driftMarkers.includes("uncommitted-changes"),
105
+ branchReady: true,
106
+ providerReady: false,
107
+ operatorApproved: false,
108
+ };
109
+ }
110
+ function runDirectTransition(ctx, request, spec) {
111
+ if (!isBoundedNonEmpty(request.requestedBy)) {
112
+ throw new TaskWorkspaceError("INVALID_REQUEST", "requestedBy is required");
113
+ }
114
+ assertSafeFieldValue(request.requestedBy, "requestedBy");
115
+ const instance = loadInstance(ctx, request.workspaceId);
116
+ const nowMs = ctx.deps.now();
117
+ const context = resolveTransitionContext(ctx, instance, request.requestedBy, nowMs);
118
+ const validation = validateTaskWorkspaceTransition({
119
+ from: instance.lifecycleState,
120
+ to: spec.to,
121
+ context,
122
+ });
123
+ if (!validation.ok) {
124
+ throw new TaskWorkspaceError("ILLEGAL_TRANSITION", `cannot ${spec.operation} workspace from ${instance.lifecycleState}`, validation.reasons);
125
+ }
126
+ const fromState = instance.lifecycleState;
127
+ const persisted = ctx.deps.store.upsert({
128
+ ...instance,
129
+ lifecycleState: spec.to,
130
+ lock: null,
131
+ updatedAt: isoFrom(nowMs),
132
+ });
133
+ if (spec.clearPointerIfActive &&
134
+ ctx.deps.activePointerStore.get()?.workspaceId === persisted.workspaceId) {
135
+ ctx.deps.activePointerStore.clear();
136
+ }
137
+ emit(ctx, {
138
+ operation: spec.operation,
139
+ outcome: spec.outcome,
140
+ type: spec.eventType,
141
+ instance: persisted,
142
+ fromState,
143
+ nowMs,
144
+ });
145
+ return { instance: persisted, binding: buildBinding(persisted) };
146
+ }
147
+ async function setActiveImpl(ctx, request) {
148
+ if (!isBoundedNonEmpty(request.requestedBy)) {
149
+ throw new TaskWorkspaceError("INVALID_REQUEST", "requestedBy is required");
150
+ }
151
+ // requestedBy is persisted as the active-pointer `setBy`; reject control/zero-width/bidi here so the
152
+ // operator-visible pointer can never carry a spoofed actor name.
153
+ assertSafeFieldValue(request.requestedBy, "requestedBy");
154
+ // Delegate the lifecycle walk (paused/active → active, drift + lock checks, persistence, evidence)
155
+ // to the #445 service. That call already serializes on the target's `ws:` key (#449, ADR-0093 D1), so
156
+ // we must NOT re-acquire `ws:` here — the in-process mutex is not reentrant. Only on success do we
157
+ // record the active pointer — the switch is atomic from the surfaces' view because the derived binding
158
+ // flips in one persisted step.
159
+ const result = await ctx.deps.provisioning.activate({
160
+ workspaceId: request.workspaceId,
161
+ // taskId "" intentionally skips activate's optional taskId cross-check — at switch time identity is
162
+ // the workspaceId only, and the store lookup by workspaceId is the authoritative identity gate.
163
+ taskId: "",
164
+ requestedBy: request.requestedBy,
165
+ acquireLock: request.acquireLock,
166
+ });
167
+ // The pointer flip is serialized per repository under the `active:` key so two concurrent switches in
168
+ // the same repository cannot tear the singleton pointer write. Sequenced AFTER activate (whose `ws:`
169
+ // lock has already drained), never nested inside it. Because `ws:` was released between activate and
170
+ // here, a concurrent pause/abandon/cleanup on the same workspace may have moved it out of `active`; we
171
+ // RE-VERIFY the live state inside the `active:` critical section before binding the singleton pointer,
172
+ // so the switch can never durably advertise a paused/abandoned task as active (#449 AC1, ADR-0093 D1).
173
+ // getById + set are synchronous here, so no flow can interleave between the check and the write.
174
+ const pointer = await ctx.deps.mutex.runExclusive([activePointerKey(result.instance.repositoryId)], () => {
175
+ const current = ctx.deps.store.getById(result.instance.workspaceId);
176
+ if (current?.lifecycleState !== "active") {
177
+ throw new TaskWorkspaceError("LOCK_CONTENTION", "workspace state changed during activation; retry");
178
+ }
179
+ assertBindableManagedPath(ctx, current);
180
+ return ctx.deps.activePointerStore.set({
181
+ workspaceId: current.workspaceId,
182
+ setBy: request.requestedBy,
183
+ atIso: isoFrom(ctx.deps.now()),
184
+ });
185
+ });
186
+ return { instance: result.instance, binding: result.binding, pointer };
187
+ }
188
+ function getActiveImpl(ctx) {
189
+ const pointer = ctx.deps.activePointerStore.get();
190
+ if (pointer === undefined)
191
+ return undefined;
192
+ const instance = ctx.deps.store.getById(pointer.workspaceId);
193
+ if (instance === undefined) {
194
+ // Defensive: a dangling pointer (instance deleted without the FK cascade firing) self-heals to
195
+ // unbound mode rather than reporting a phantom active workspace.
196
+ ctx.deps.activePointerStore.clear();
197
+ return undefined;
198
+ }
199
+ if (!canExposeBinding(ctx, instance)) {
200
+ ctx.deps.activePointerStore.clear();
201
+ return undefined;
202
+ }
203
+ return { instance, binding: buildBinding(instance), pointer };
204
+ }
205
+ function listImpl(ctx, repositoryRoot) {
206
+ if (!isBoundedNonEmpty(repositoryRoot)) {
207
+ throw new TaskWorkspaceError("INVALID_REQUEST", "repository root is required");
208
+ }
209
+ return ctx.deps.store.listByRepository(deriveRepositoryId(repositoryRoot));
210
+ }
211
+ async function resumeImpl(ctx, request) {
212
+ const view = await setActiveImpl(ctx, {
213
+ workspaceId: request.workspaceId,
214
+ requestedBy: request.requestedBy,
215
+ acquireLock: false,
216
+ });
217
+ return { instance: view.instance, binding: view.binding };
218
+ }
219
+ const PAUSE_SPEC = {
220
+ to: "paused",
221
+ operation: "pause",
222
+ outcome: "paused",
223
+ eventType: "paused",
224
+ clearPointerIfActive: true,
225
+ };
226
+ const HANDOFF_SPEC = {
227
+ to: "handoff-ready",
228
+ operation: "handoff",
229
+ outcome: "handoff-prepared",
230
+ eventType: "handoff-prepared",
231
+ clearPointerIfActive: false,
232
+ };
233
+ export function createWorkspaceLifecycleService(deps) {
234
+ const ctx = { deps, lockTtlMs: resolveLockTtl(deps.lockTtlMs) };
235
+ return {
236
+ list: (repositoryRoot) => listImpl(ctx, repositoryRoot),
237
+ getActive: () => getActiveImpl(ctx),
238
+ setActive: (request) => setActiveImpl(ctx, request),
239
+ clearActive: () => {
240
+ deps.activePointerStore.clear();
241
+ },
242
+ // pause / handoff are direct transitions on one instance — serialized under its `ws:` key (#449,
243
+ // ADR-0093 D1) so they cannot race a concurrent activate/repair/cleanup of the same workspace.
244
+ pause: (request) => ctx.deps.mutex.runExclusive([workspaceKey(request.workspaceId)], () => runDirectTransition(ctx, request, PAUSE_SPEC)),
245
+ resume: (request) => resumeImpl(ctx, request),
246
+ prepareHandoff: (request) => ctx.deps.mutex.runExclusive([workspaceKey(request.workspaceId)], () => runDirectTransition(ctx, request, HANDOFF_SPEC)),
247
+ };
248
+ }