@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,55 @@
1
+ // Durable singleton active task-workspace pointer (Issue #446, Epic #443).
2
+ //
3
+ // Studio binds exactly ONE active task workspace at a time. The pointer is the durable source of truth
4
+ // for "which workspace is active"; the WorkspaceBinding surfaces consume is DERIVED from the referenced
5
+ // WorkspaceInstance (see binding.ts), never stored here — so there is no second copy of the root to
6
+ // drift. The table is pinned to a single row (id = 'active', CHECK-enforced) over the SAME node:sqlite
7
+ // handle as the UI store and the #445 instance store (schema.ts §V8), so the V8 sibling row shares the
8
+ // single-writer transaction model.
9
+ //
10
+ // The pointer is content-free: an opaque workspace id + an opaque actor id + ISO timestamps. No root,
11
+ // no branch, no path is persisted here. "Clear active" deletes the row → unbound mode.
12
+ const SQL_GET = `SELECT workspace_id, set_by, set_at, updated_at FROM task_workspace_active_pointer WHERE id = 'active'`;
13
+ const SQL_CLEAR = `DELETE FROM task_workspace_active_pointer WHERE id = 'active'`;
14
+ // `set_at` records when the CURRENT workspace became active, `updated_at` the last touch. On conflict
15
+ // the CASE keeps the original `set_at` when the SAME workspace is re-set (an idempotent re-activation
16
+ // preserves its "became active" time) and resets it when switching to a DIFFERENT workspace.
17
+ const SQL_SET = `
18
+ INSERT INTO task_workspace_active_pointer (id, workspace_id, set_by, set_at, updated_at)
19
+ VALUES ('active', ?, ?, ?, ?)
20
+ ON CONFLICT(id) DO UPDATE SET
21
+ set_at = CASE
22
+ WHEN task_workspace_active_pointer.workspace_id = excluded.workspace_id
23
+ THEN task_workspace_active_pointer.set_at
24
+ ELSE excluded.set_at
25
+ END,
26
+ workspace_id = excluded.workspace_id,
27
+ set_by = excluded.set_by,
28
+ updated_at = excluded.updated_at
29
+ RETURNING workspace_id, set_by, set_at, updated_at
30
+ `;
31
+ function rowToPointer(row) {
32
+ return {
33
+ workspaceId: row.workspace_id,
34
+ setBy: row.set_by,
35
+ setAt: row.set_at,
36
+ updatedAt: row.updated_at,
37
+ };
38
+ }
39
+ export function buildActiveWorkspacePointerStoreOverDatabase(db) {
40
+ return {
41
+ get: () => {
42
+ const row = db.prepare(SQL_GET).get();
43
+ return row === undefined ? undefined : rowToPointer(row);
44
+ },
45
+ set: (input) => {
46
+ const row = db
47
+ .prepare(SQL_SET)
48
+ .get(input.workspaceId, input.setBy, input.atIso, input.atIso);
49
+ return rowToPointer(row);
50
+ },
51
+ clear: () => {
52
+ db.prepare(SQL_CLEAR).run();
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,7 @@
1
+ import type { WorkspaceInfo } from "@oscharko-dev/keiko-workspace";
2
+ import type { UiHandlerDeps } from "../deps.js";
3
+ type ManagedRootDeps = Pick<UiHandlerDeps, "managedTaskWorkspaceRoot" | "store" | "workspaceProvisioning">;
4
+ export declare function resolveManagedTaskWorkspaceRoot(deps: Pick<ManagedRootDeps, "managedTaskWorkspaceRoot" | "workspaceProvisioning">, root: string): WorkspaceInfo | undefined;
5
+ export declare function resolveRegisteredOrManagedWorkspaceRoot(deps: ManagedRootDeps, root: string): WorkspaceInfo | undefined;
6
+ export {};
7
+ //# sourceMappingURL=authorization.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authorization.d.ts","sourceRoot":"","sources":["../../src/task-workspace/authorization.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAIhD,KAAK,eAAe,GAAG,IAAI,CACzB,aAAa,EACb,0BAA0B,GAAG,OAAO,GAAG,uBAAuB,CAC/D,CAAC;AAmBF,wBAAgB,+BAA+B,CAC7C,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,0BAA0B,GAAG,uBAAuB,CAAC,EACjF,IAAI,EAAE,MAAM,GACX,aAAa,GAAG,SAAS,CAiB3B;AAED,wBAAgB,uCAAuC,CACrD,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,MAAM,GACX,aAAa,GAAG,SAAS,CAK3B"}
@@ -0,0 +1,54 @@
1
+ // Authorization helpers for task-bound execution roots (Epic #443).
2
+ //
3
+ // Legacy BFF routes authorize `projectId` / `workspaceRoot` against the UI project store. A bound task
4
+ // workspace intentionally points those surfaces at a managed worktree instead, so the route boundary
5
+ // must accept that root only when it can be re-proven from the persisted WorkspaceInstance and the
6
+ // Keiko-owned managed root. This does not grant arbitrary filesystem authority: the leaf id, persisted
7
+ // path, derived managed path, realpath containment, and on-disk presence must all agree.
8
+ import { basename } from "node:path";
9
+ import { isManagedTargetContained, managedTargetExists } from "./managed-root.js";
10
+ import { deriveManagedWorktreePath } from "./naming.js";
11
+ function workspaceInfo(root) {
12
+ return {
13
+ root,
14
+ name: undefined,
15
+ version: undefined,
16
+ testFramework: "unknown",
17
+ sourceDirs: [],
18
+ testDirs: [],
19
+ languages: [],
20
+ ignoreLines: [],
21
+ };
22
+ }
23
+ function pathLeaf(path) {
24
+ return basename(path.replace(/[/\\]+$/u, ""));
25
+ }
26
+ export function resolveManagedTaskWorkspaceRoot(deps, root) {
27
+ const managedRoot = deps.managedTaskWorkspaceRoot;
28
+ const provisioning = deps.workspaceProvisioning;
29
+ if (managedRoot === undefined || provisioning === undefined || root.length === 0) {
30
+ return undefined;
31
+ }
32
+ const instance = provisioning.getInstance(pathLeaf(root));
33
+ if (instance === undefined)
34
+ return undefined;
35
+ const expected = deriveManagedWorktreePath({
36
+ managedRoot,
37
+ repositoryId: instance.repositoryId,
38
+ workspaceId: instance.workspaceId,
39
+ });
40
+ if (root !== expected || instance.managedWorktreePath !== expected)
41
+ return undefined;
42
+ if (!isManagedTargetContained(managedRoot, instance.managedWorktreePath))
43
+ return undefined;
44
+ if (!managedTargetExists(instance.managedWorktreePath))
45
+ return undefined;
46
+ return workspaceInfo(instance.managedWorktreePath);
47
+ }
48
+ export function resolveRegisteredOrManagedWorkspaceRoot(deps, root) {
49
+ for (const project of deps.store.listProjects()) {
50
+ if (project.path === root)
51
+ return workspaceInfo(project.path);
52
+ }
53
+ return resolveManagedTaskWorkspaceRoot(deps, root);
54
+ }
@@ -0,0 +1,3 @@
1
+ import { type WorkspaceBinding, type WorkspaceInstance } from "@oscharko-dev/keiko-contracts";
2
+ export declare function buildBinding(instance: WorkspaceInstance): WorkspaceBinding;
3
+ //# sourceMappingURL=binding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"binding.d.ts","sourceRoot":"","sources":["../../src/task-workspace/binding.ts"],"names":[],"mappings":"AAWA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACvB,MAAM,+BAA+B,CAAC;AAEvC,wBAAgB,YAAY,CAAC,QAAQ,EAAE,iBAAiB,GAAG,gBAAgB,CAU1E"}
@@ -0,0 +1,22 @@
1
+ // Derive the authoritative active-workspace binding from a persisted instance (Issue #446, Epic #443).
2
+ //
3
+ // The #444 contract makes the WorkspaceBinding the single source of truth surfaces consume, with the
4
+ // invariant gitDeliveryRoot === activeRoot === editorProjectRoot enforced by validateWorkspaceBinding.
5
+ // #445 first computed this binding privately inside provisioning; #446 promotes it to a shared helper
6
+ // so BOTH the provisioning/activation path AND the #446 lifecycle/active-pointer path derive the same
7
+ // binding from a stored instance — there is exactly one derivation, never a recomputed second one.
8
+ //
9
+ // The active root is the managed worktree path: every bound surface (editor/runtime/git-delivery/
10
+ // terminal/files/...) operates on the task's isolated worktree, never the bare repository root.
11
+ import { TASK_WORKSPACE_SCHEMA_VERSION, TASK_WORKSPACE_SURFACES, } from "@oscharko-dev/keiko-contracts";
12
+ export function buildBinding(instance) {
13
+ return {
14
+ schemaVersion: TASK_WORKSPACE_SCHEMA_VERSION,
15
+ workspaceId: instance.workspaceId,
16
+ taskId: instance.taskId,
17
+ activeRoot: instance.managedWorktreePath,
18
+ boundSurfaces: TASK_WORKSPACE_SURFACES,
19
+ gitDeliveryRoot: instance.managedWorktreePath,
20
+ editorProjectRoot: instance.managedWorktreePath,
21
+ };
22
+ }
@@ -0,0 +1,4 @@
1
+ import type { WorkspaceCleanupService, WorkspaceCleanupServiceDeps } from "./types.js";
2
+ export declare function safelyRemoveManagedPath(managedRoot: string, target: string): void;
3
+ export declare function createWorkspaceCleanupService(deps: WorkspaceCleanupServiceDeps): WorkspaceCleanupService;
4
+ //# sourceMappingURL=cleanup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cleanup.d.ts","sourceRoot":"","sources":["../../src/task-workspace/cleanup.ts"],"names":[],"mappings":"AAgDA,OAAO,KAAK,EAGV,uBAAuB,EACvB,2BAA2B,EAI5B,MAAM,YAAY,CAAC;AAiCpB,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAcjF;AAgeD,wBAAgB,6BAA6B,CAC3C,IAAI,EAAE,2BAA2B,GAChC,uBAAuB,CAgBzB"}
@@ -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"}