@oscharko-dev/keiko-server 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/chat-handlers.d.ts +18 -2
  3. package/dist/chat-handlers.d.ts.map +1 -1
  4. package/dist/chat-handlers.js +185 -3
  5. package/dist/command-runner-errors.d.ts +17 -0
  6. package/dist/command-runner-errors.d.ts.map +1 -0
  7. package/dist/command-runner-errors.js +37 -0
  8. package/dist/command-runner-evidence.d.ts +23 -0
  9. package/dist/command-runner-evidence.d.ts.map +1 -0
  10. package/dist/command-runner-evidence.js +69 -0
  11. package/dist/command-runner-routes.d.ts +7 -0
  12. package/dist/command-runner-routes.d.ts.map +1 -0
  13. package/dist/command-runner-routes.js +175 -0
  14. package/dist/command-runner.d.ts +29 -0
  15. package/dist/command-runner.d.ts.map +1 -0
  16. package/dist/command-runner.js +348 -0
  17. package/dist/conversation-prompt.d.ts +2 -2
  18. package/dist/conversation-prompt.d.ts.map +1 -1
  19. package/dist/conversation-prompt.js +17 -1
  20. package/dist/csp.d.ts.map +1 -1
  21. package/dist/csp.js +3 -0
  22. package/dist/deps.d.ts +28 -1
  23. package/dist/deps.d.ts.map +1 -1
  24. package/dist/deps.js +288 -13
  25. package/dist/discussion-prompt.d.ts +4 -0
  26. package/dist/discussion-prompt.d.ts.map +1 -0
  27. package/dist/discussion-prompt.js +19 -0
  28. package/dist/editor/agentActionAudit.d.ts +18 -0
  29. package/dist/editor/agentActionAudit.d.ts.map +1 -0
  30. package/dist/editor/agentActionAudit.js +80 -0
  31. package/dist/editor/agentRoutes.d.ts +1 -0
  32. package/dist/editor/agentRoutes.d.ts.map +1 -1
  33. package/dist/editor/agentRoutes.js +292 -55
  34. package/dist/editor/agentSessionRegistry.d.ts +35 -0
  35. package/dist/editor/agentSessionRegistry.d.ts.map +1 -0
  36. package/dist/editor/agentSessionRegistry.js +243 -0
  37. package/dist/editor/completionRoutes.d.ts.map +1 -1
  38. package/dist/editor/completionRoutes.js +5 -10
  39. package/dist/editor/languageRoutes.d.ts +12 -1
  40. package/dist/editor/languageRoutes.d.ts.map +1 -1
  41. package/dist/editor/languageRoutes.js +71 -8
  42. package/dist/editor/languageService.d.ts +3 -2
  43. package/dist/editor/languageService.d.ts.map +1 -1
  44. package/dist/editor/languageService.js +41 -3
  45. package/dist/editor/languageServiceHost.d.ts.map +1 -1
  46. package/dist/editor/languageServiceHost.js +2 -2
  47. package/dist/editor/lsp/hostLanguageOperation.d.ts +17 -0
  48. package/dist/editor/lsp/hostLanguageOperation.d.ts.map +1 -0
  49. package/dist/editor/lsp/hostLanguageOperation.js +436 -0
  50. package/dist/editor/lsp/hostLanguageProviders.d.ts +26 -0
  51. package/dist/editor/lsp/hostLanguageProviders.d.ts.map +1 -0
  52. package/dist/editor/lsp/hostLanguageProviders.js +161 -0
  53. package/dist/editor/lsp/lspFrameCodec.d.ts +13 -0
  54. package/dist/editor/lsp/lspFrameCodec.d.ts.map +1 -0
  55. package/dist/editor/lsp/lspFrameCodec.js +164 -0
  56. package/dist/editor/lsp/lspJsonRpcClient.d.ts +34 -0
  57. package/dist/editor/lsp/lspJsonRpcClient.d.ts.map +1 -0
  58. package/dist/editor/lsp/lspJsonRpcClient.js +173 -0
  59. package/dist/editor/lsp/lspLanguageProvider.d.ts +7 -0
  60. package/dist/editor/lsp/lspLanguageProvider.d.ts.map +1 -0
  61. package/dist/editor/lsp/lspLanguageProvider.js +29 -0
  62. package/dist/editor/lsp/lspLifecycleLedger.d.ts +5 -0
  63. package/dist/editor/lsp/lspLifecycleLedger.d.ts.map +1 -0
  64. package/dist/editor/lsp/lspLifecycleLedger.js +37 -0
  65. package/dist/editor/lsp/lspNodeAdapter.d.ts +31 -0
  66. package/dist/editor/lsp/lspNodeAdapter.d.ts.map +1 -0
  67. package/dist/editor/lsp/lspNodeAdapter.js +230 -0
  68. package/dist/editor/lsp/lspProcessManager.d.ts +24 -0
  69. package/dist/editor/lsp/lspProcessManager.d.ts.map +1 -0
  70. package/dist/editor/lsp/lspProcessManager.js +255 -0
  71. package/dist/editor/lsp/lspRestartThrottle.d.ts +6 -0
  72. package/dist/editor/lsp/lspRestartThrottle.d.ts.map +1 -0
  73. package/dist/editor/lsp/lspRestartThrottle.js +24 -0
  74. package/dist/editor/lsp/lspStatusRoute.d.ts +8 -0
  75. package/dist/editor/lsp/lspStatusRoute.d.ts.map +1 -0
  76. package/dist/editor/lsp/lspStatusRoute.js +22 -0
  77. package/dist/editor/lsp/lspTransport.d.ts +19 -0
  78. package/dist/editor/lsp/lspTransport.d.ts.map +1 -0
  79. package/dist/editor/lsp/lspTransport.js +55 -0
  80. package/dist/editor/lsp/testing/fakeLspProcess.d.ts +23 -0
  81. package/dist/editor/lsp/testing/fakeLspProcess.d.ts.map +1 -0
  82. package/dist/editor/lsp/testing/fakeLspProcess.js +132 -0
  83. package/dist/files.d.ts +63 -0
  84. package/dist/files.d.ts.map +1 -1
  85. package/dist/files.js +799 -1
  86. package/dist/gateway-readiness.d.ts +6 -0
  87. package/dist/gateway-readiness.d.ts.map +1 -0
  88. package/dist/gateway-readiness.js +624 -0
  89. package/dist/gateway-setup.d.ts +2 -0
  90. package/dist/gateway-setup.d.ts.map +1 -1
  91. package/dist/gateway-setup.js +275 -11
  92. package/dist/gitDelivery/actionSheetProjection.d.ts +30 -0
  93. package/dist/gitDelivery/actionSheetProjection.d.ts.map +1 -0
  94. package/dist/gitDelivery/actionSheetProjection.js +206 -0
  95. package/dist/gitDelivery/actionSheetRoutes.d.ts +29 -0
  96. package/dist/gitDelivery/actionSheetRoutes.d.ts.map +1 -0
  97. package/dist/gitDelivery/actionSheetRoutes.js +293 -0
  98. package/dist/gitDelivery/agentOperationsRoutes.d.ts +33 -0
  99. package/dist/gitDelivery/agentOperationsRoutes.d.ts.map +1 -0
  100. package/dist/gitDelivery/agentOperationsRoutes.js +405 -0
  101. package/dist/gitDelivery/commitRoutes.d.ts +23 -0
  102. package/dist/gitDelivery/commitRoutes.d.ts.map +1 -0
  103. package/dist/gitDelivery/commitRoutes.js +204 -0
  104. package/dist/gitDelivery/evidenceRoutes.d.ts +9 -0
  105. package/dist/gitDelivery/evidenceRoutes.d.ts.map +1 -0
  106. package/dist/gitDelivery/evidenceRoutes.js +101 -0
  107. package/dist/gitDelivery/execution.d.ts +38 -0
  108. package/dist/gitDelivery/execution.d.ts.map +1 -0
  109. package/dist/gitDelivery/execution.js +117 -0
  110. package/dist/gitDelivery/localMutationRoutes.d.ts +30 -0
  111. package/dist/gitDelivery/localMutationRoutes.d.ts.map +1 -0
  112. package/dist/gitDelivery/localMutationRoutes.js +165 -0
  113. package/dist/gitDelivery/mergeExecution.d.ts +63 -0
  114. package/dist/gitDelivery/mergeExecution.d.ts.map +1 -0
  115. package/dist/gitDelivery/mergeExecution.js +168 -0
  116. package/dist/gitDelivery/mergeRoutes.d.ts +12 -0
  117. package/dist/gitDelivery/mergeRoutes.d.ts.map +1 -0
  118. package/dist/gitDelivery/mergeRoutes.js +218 -0
  119. package/dist/gitDelivery/mutationEvidenceLedger.d.ts +23 -0
  120. package/dist/gitDelivery/mutationEvidenceLedger.d.ts.map +1 -0
  121. package/dist/gitDelivery/mutationEvidenceLedger.js +87 -0
  122. package/dist/gitDelivery/prExecution.d.ts +54 -0
  123. package/dist/gitDelivery/prExecution.d.ts.map +1 -0
  124. package/dist/gitDelivery/prExecution.js +192 -0
  125. package/dist/gitDelivery/prRoutes.d.ts +12 -0
  126. package/dist/gitDelivery/prRoutes.d.ts.map +1 -0
  127. package/dist/gitDelivery/prRoutes.js +256 -0
  128. package/dist/gitDelivery/pushExecution.d.ts +43 -0
  129. package/dist/gitDelivery/pushExecution.d.ts.map +1 -0
  130. package/dist/gitDelivery/pushExecution.js +124 -0
  131. package/dist/gitDelivery/pushRoutes.d.ts +12 -0
  132. package/dist/gitDelivery/pushRoutes.d.ts.map +1 -0
  133. package/dist/gitDelivery/pushRoutes.js +200 -0
  134. package/dist/gitDelivery/requestGuards.d.ts +15 -0
  135. package/dist/gitDelivery/requestGuards.d.ts.map +1 -0
  136. package/dist/gitDelivery/requestGuards.js +97 -0
  137. package/dist/gitDelivery/syncEvidence.d.ts +37 -0
  138. package/dist/gitDelivery/syncEvidence.d.ts.map +1 -0
  139. package/dist/gitDelivery/syncEvidence.js +85 -0
  140. package/dist/gitDelivery/syncExecution.d.ts +30 -0
  141. package/dist/gitDelivery/syncExecution.d.ts.map +1 -0
  142. package/dist/gitDelivery/syncExecution.js +266 -0
  143. package/dist/gitDelivery/syncRoutes.d.ts +13 -0
  144. package/dist/gitDelivery/syncRoutes.d.ts.map +1 -0
  145. package/dist/gitDelivery/syncRoutes.js +200 -0
  146. package/dist/gitPorcelainStatus.d.ts +15 -0
  147. package/dist/gitPorcelainStatus.d.ts.map +1 -0
  148. package/dist/gitPorcelainStatus.js +104 -0
  149. package/dist/gitRepositoryReads.d.ts +10 -0
  150. package/dist/gitRepositoryReads.d.ts.map +1 -0
  151. package/dist/gitRepositoryReads.js +314 -0
  152. package/dist/gitRepositoryRoutes.d.ts +7 -0
  153. package/dist/gitRepositoryRoutes.d.ts.map +1 -0
  154. package/dist/gitRepositoryRoutes.js +221 -0
  155. package/dist/gitRoutes.d.ts +66 -0
  156. package/dist/gitRoutes.d.ts.map +1 -0
  157. package/dist/gitRoutes.js +543 -0
  158. package/dist/governed-workflow.d.ts +2 -0
  159. package/dist/governed-workflow.d.ts.map +1 -1
  160. package/dist/governed-workflow.js +4 -0
  161. package/dist/grounded-qa-hybrid.d.ts.map +1 -1
  162. package/dist/grounded-qa-hybrid.js +2 -0
  163. package/dist/grounded-qa-multi-source.d.ts.map +1 -1
  164. package/dist/grounded-qa-multi-source.js +1 -0
  165. package/dist/grounded-qa.d.ts +11 -0
  166. package/dist/grounded-qa.d.ts.map +1 -1
  167. package/dist/grounded-qa.js +14 -4
  168. package/dist/headers.d.ts +4 -1
  169. package/dist/headers.d.ts.map +1 -1
  170. package/dist/headers.js +11 -4
  171. package/dist/index.d.ts +8 -1
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +9 -1
  174. package/dist/local-knowledge-grounded-qa.d.ts.map +1 -1
  175. package/dist/local-knowledge-grounded-qa.js +11 -2
  176. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts +1 -1
  177. package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts.map +1 -1
  178. package/dist/qualityIntelligence/figmaSnapshotRoutes.js +1 -1
  179. package/dist/read-handlers.d.ts +5 -0
  180. package/dist/read-handlers.d.ts.map +1 -1
  181. package/dist/read-handlers.js +57 -1
  182. package/dist/routes.d.ts.map +1 -1
  183. package/dist/routes.js +260 -12
  184. package/dist/run-engine.d.ts.map +1 -1
  185. package/dist/run-engine.js +3 -0
  186. package/dist/run-handlers.d.ts +0 -1
  187. package/dist/run-handlers.d.ts.map +1 -1
  188. package/dist/run-handlers.js +64 -211
  189. package/dist/run-request.d.ts +11 -0
  190. package/dist/run-request.d.ts.map +1 -1
  191. package/dist/run-request.js +158 -10
  192. package/dist/runtime/capabilityDetector.d.ts +38 -0
  193. package/dist/runtime/capabilityDetector.d.ts.map +1 -0
  194. package/dist/runtime/capabilityDetector.js +443 -0
  195. package/dist/runtime/capabilityRoutes.d.ts +9 -0
  196. package/dist/runtime/capabilityRoutes.d.ts.map +1 -0
  197. package/dist/runtime/capabilityRoutes.js +45 -0
  198. package/dist/runtime/containerEngineDetector.d.ts +17 -0
  199. package/dist/runtime/containerEngineDetector.d.ts.map +1 -0
  200. package/dist/runtime/containerEngineDetector.js +222 -0
  201. package/dist/runtime/containerRoutes.d.ts +8 -0
  202. package/dist/runtime/containerRoutes.d.ts.map +1 -0
  203. package/dist/runtime/containerRoutes.js +207 -0
  204. package/dist/runtime/containerRunner-errors.d.ts +18 -0
  205. package/dist/runtime/containerRunner-errors.d.ts.map +1 -0
  206. package/dist/runtime/containerRunner-errors.js +42 -0
  207. package/dist/runtime/containerRunner-evidence.d.ts +24 -0
  208. package/dist/runtime/containerRunner-evidence.d.ts.map +1 -0
  209. package/dist/runtime/containerRunner-evidence.js +74 -0
  210. package/dist/runtime/containerRunner.d.ts +37 -0
  211. package/dist/runtime/containerRunner.d.ts.map +1 -0
  212. package/dist/runtime/containerRunner.js +443 -0
  213. package/dist/server.d.ts.map +1 -1
  214. package/dist/server.js +24 -4
  215. package/dist/store/db.d.ts.map +1 -1
  216. package/dist/store/db.js +2 -1
  217. package/dist/store/index.d.ts +1 -1
  218. package/dist/store/index.d.ts.map +1 -1
  219. package/dist/store/messages.d.ts +2 -1
  220. package/dist/store/messages.d.ts.map +1 -1
  221. package/dist/store/messages.js +46 -4
  222. package/dist/store/schema.d.ts +1 -1
  223. package/dist/store/schema.d.ts.map +1 -1
  224. package/dist/store/schema.js +68 -1
  225. package/dist/store/types.d.ts +3 -2
  226. package/dist/store/types.d.ts.map +1 -1
  227. package/dist/task-workspace/active-store.d.ts +21 -0
  228. package/dist/task-workspace/active-store.d.ts.map +1 -0
  229. package/dist/task-workspace/active-store.js +55 -0
  230. package/dist/task-workspace/authorization.d.ts +7 -0
  231. package/dist/task-workspace/authorization.d.ts.map +1 -0
  232. package/dist/task-workspace/authorization.js +54 -0
  233. package/dist/task-workspace/binding.d.ts +3 -0
  234. package/dist/task-workspace/binding.d.ts.map +1 -0
  235. package/dist/task-workspace/binding.js +22 -0
  236. package/dist/task-workspace/cleanup.d.ts +4 -0
  237. package/dist/task-workspace/cleanup.d.ts.map +1 -0
  238. package/dist/task-workspace/cleanup.js +428 -0
  239. package/dist/task-workspace/errors.d.ts +14 -0
  240. package/dist/task-workspace/errors.d.ts.map +1 -0
  241. package/dist/task-workspace/errors.js +81 -0
  242. package/dist/task-workspace/evidence.d.ts +32 -0
  243. package/dist/task-workspace/evidence.d.ts.map +1 -0
  244. package/dist/task-workspace/evidence.js +52 -0
  245. package/dist/task-workspace/field-safety.d.ts +3 -0
  246. package/dist/task-workspace/field-safety.d.ts.map +1 -0
  247. package/dist/task-workspace/field-safety.js +42 -0
  248. package/dist/task-workspace/health.d.ts +4 -0
  249. package/dist/task-workspace/health.d.ts.map +1 -0
  250. package/dist/task-workspace/health.js +163 -0
  251. package/dist/task-workspace/lifecycle.d.ts +3 -0
  252. package/dist/task-workspace/lifecycle.d.ts.map +1 -0
  253. package/dist/task-workspace/lifecycle.js +248 -0
  254. package/dist/task-workspace/locks.d.ts +13 -0
  255. package/dist/task-workspace/locks.d.ts.map +1 -0
  256. package/dist/task-workspace/locks.js +44 -0
  257. package/dist/task-workspace/managed-root.d.ts +7 -0
  258. package/dist/task-workspace/managed-root.d.ts.map +1 -0
  259. package/dist/task-workspace/managed-root.js +98 -0
  260. package/dist/task-workspace/mutex.d.ts +8 -0
  261. package/dist/task-workspace/mutex.d.ts.map +1 -0
  262. package/dist/task-workspace/mutex.js +82 -0
  263. package/dist/task-workspace/naming.d.ts +15 -0
  264. package/dist/task-workspace/naming.d.ts.map +1 -0
  265. package/dist/task-workspace/naming.js +0 -0
  266. package/dist/task-workspace/provisioning.d.ts +3 -0
  267. package/dist/task-workspace/provisioning.d.ts.map +1 -0
  268. package/dist/task-workspace/provisioning.js +528 -0
  269. package/dist/task-workspace/reconciliation.d.ts +15 -0
  270. package/dist/task-workspace/reconciliation.d.ts.map +1 -0
  271. package/dist/task-workspace/reconciliation.js +274 -0
  272. package/dist/task-workspace/repair.d.ts +3 -0
  273. package/dist/task-workspace/repair.d.ts.map +1 -0
  274. package/dist/task-workspace/repair.js +286 -0
  275. package/dist/task-workspace/routes.d.ts +19 -0
  276. package/dist/task-workspace/routes.d.ts.map +1 -0
  277. package/dist/task-workspace/routes.js +481 -0
  278. package/dist/task-workspace/store.d.ts +12 -0
  279. package/dist/task-workspace/store.d.ts.map +1 -0
  280. package/dist/task-workspace/store.js +128 -0
  281. package/dist/task-workspace/types.d.ts +170 -0
  282. package/dist/task-workspace/types.d.ts.map +1 -0
  283. package/dist/task-workspace/types.js +5 -0
  284. package/dist/voice-action-governance.d.ts +23 -0
  285. package/dist/voice-action-governance.d.ts.map +1 -0
  286. package/dist/voice-action-governance.js +126 -0
  287. package/dist/voice-handlers.d.ts +6 -0
  288. package/dist/voice-handlers.d.ts.map +1 -0
  289. package/dist/voice-handlers.js +570 -0
  290. package/dist/voice-realtime-grounded-tool.d.ts +31 -0
  291. package/dist/voice-realtime-grounded-tool.d.ts.map +1 -0
  292. package/dist/voice-realtime-grounded-tool.js +322 -0
  293. package/dist/voice-realtime.d.ts +69 -0
  294. package/dist/voice-realtime.d.ts.map +1 -0
  295. package/dist/voice-realtime.js +787 -0
  296. package/dist/workspace-state-handlers.d.ts +5 -0
  297. package/dist/workspace-state-handlers.d.ts.map +1 -0
  298. package/dist/workspace-state-handlers.js +106 -0
  299. package/package.json +20 -19
  300. package/dist/grounded-handoff.d.ts +0 -4
  301. package/dist/grounded-handoff.d.ts.map +0 -1
  302. package/dist/grounded-handoff.js +0 -445
@@ -0,0 +1,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
+ }
@@ -0,0 +1,13 @@
1
+ import type { WorkspaceLock, WorkspaceLockReason } from "@oscharko-dev/keiko-contracts";
2
+ export declare const DEFAULT_LOCK_TTL_MS: number;
3
+ export declare function lockIsLive(lock: WorkspaceLock | null, nowMs: number, ttlMs: number): boolean;
4
+ export interface MakeWorkspaceLockArgs {
5
+ readonly newId: () => string;
6
+ readonly owner: string;
7
+ readonly reason: WorkspaceLockReason;
8
+ readonly nowMs: number;
9
+ readonly ttlMs: number;
10
+ }
11
+ export declare function makeWorkspaceLock(args: MakeWorkspaceLockArgs): WorkspaceLock;
12
+ export declare function resolveLockTtl(lockTtlMs?: number): number;
13
+ //# sourceMappingURL=locks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locks.d.ts","sourceRoot":"","sources":["../../src/task-workspace/locks.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,+BAA+B,CAAC;AAIxF,eAAO,MAAM,mBAAmB,QAAa,CAAC;AAI9C,wBAAgB,UAAU,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAQ5F;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,mBAAmB,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAKD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,GAAG,aAAa,CAQ5E;AAID,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzD"}
@@ -0,0 +1,44 @@
1
+ // Consolidated advisory-lock model for managed task workspaces (Issue #449, Epic #443, ADR-0093 D2).
2
+ //
3
+ // Before #449 the lock-liveness predicate, the default TTL, and the lock builder were copied verbatim
4
+ // into provisioning.ts, lifecycle.ts, reconciliation.ts, cleanup.ts, and repair.ts — five places a TTL
5
+ // or expiry-parsing fix could silently diverge. This module is the single home: the behaviour is
6
+ // byte-for-byte the previous copies (an explicit `expiresAt` wins; otherwise the TTL since
7
+ // `acquiredAt`; a non-finite timestamp fails closed → treated as not live), now defined once.
8
+ //
9
+ // The advisory `WorkspaceLock` is the ACROSS-RESTART / ACROSS-ACTOR coordination record persisted on the
10
+ // instance row; it is checked optimistically and is NOT an intra-process serializer (that is the
11
+ // in-process `WorkspaceMutexRegistry`, ADR-0093 D1). On a process crash a held advisory lock TTL-expires
12
+ // here and #447 reconciliation/repair clears it.
13
+ // The default span a provisioning/activation/mutation/repair/cleanup lock stays valid before it is
14
+ // treated as stale. Mirrors the value the five services each previously declared.
15
+ export const DEFAULT_LOCK_TTL_MS = 5 * 60_000;
16
+ // Whether an advisory lock is still live at `nowMs`. An explicit expiry wins; otherwise the TTL since
17
+ // acquisition; a non-finite timestamp fails closed (treated as not live). `null` is never live.
18
+ export function lockIsLive(lock, nowMs, ttlMs) {
19
+ if (lock === null)
20
+ return false;
21
+ if (lock.expiresAt !== undefined) {
22
+ const expiry = Date.parse(lock.expiresAt);
23
+ return Number.isFinite(expiry) ? nowMs < expiry : false;
24
+ }
25
+ const acquired = Date.parse(lock.acquiredAt);
26
+ return Number.isFinite(acquired) ? nowMs - acquired < ttlMs : false;
27
+ }
28
+ // Builds an advisory lock with a content-free id, the requesting actor as owner, the reason, and an
29
+ // explicit expiry `ttlMs` after acquisition. Generalizes the previous per-service `makeLock` /
30
+ // `makeRepairLock` / `cleanupLock` builders over the lock reason.
31
+ export function makeWorkspaceLock(args) {
32
+ return {
33
+ lockId: args.newId(),
34
+ owner: args.owner,
35
+ reason: args.reason,
36
+ acquiredAt: new Date(args.nowMs).toISOString(),
37
+ expiresAt: new Date(args.nowMs + args.ttlMs).toISOString(),
38
+ };
39
+ }
40
+ // Applies the single default-TTL fallback the five services each inlined as `deps.lockTtlMs ??
41
+ // DEFAULT_LOCK_TTL_MS`, so the default lives in exactly one place.
42
+ export function resolveLockTtl(lockTtlMs) {
43
+ return lockTtlMs ?? DEFAULT_LOCK_TTL_MS;
44
+ }
@@ -0,0 +1,7 @@
1
+ export declare function assertManagedRootOwned(managedRoot: string): void;
2
+ export declare function assertManagedTargetContained(managedRoot: string, worktreePath: string): void;
3
+ export declare function ensureManagedWorktreeParent(worktreePath: string): void;
4
+ export declare function managedTargetExists(worktreePath: string): boolean;
5
+ export declare function isManagedRootOwned(managedRoot: string): boolean;
6
+ export declare function isManagedTargetContained(managedRoot: string, target: string): boolean;
7
+ //# sourceMappingURL=managed-root.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"managed-root.d.ts","sourceRoot":"","sources":["../../src/task-workspace/managed-root.ts"],"names":[],"mappings":"AAkCA,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAmBhE;AAKD,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAU5F;AAID,wBAAgB,2BAA2B,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAEtE;AAID,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAEjE;AAMD,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAO/D;AAMD,wBAAgB,wBAAwB,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAOrF"}
@@ -0,0 +1,98 @@
1
+ // Keiko-owned managed-worktree root: ownership proof + realpath containment (Issue #445, Epic #443).
2
+ //
3
+ // Before any worktree is written, the server must PROVE it owns the managed root (SC2) and that the
4
+ // derived worktree path stays inside it even after symlink resolution (AC2). Ownership is proven by a
5
+ // marker file Keiko creates with restrictive permissions; containment delegates entirely to
6
+ // @oscharko-dev/keiko-workspace (no second containment engine, ADR-0088 D5): a lexical check followed
7
+ // by a realpath check that walks the existing parent chain, so a symlinked ancestor that escapes the
8
+ // root is rejected even though the leaf worktree directory does not yet exist.
9
+ import { chmodSync, existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { assertContainedRealPath, PathEscapeError, resolveWithinWorkspace, } from "@oscharko-dev/keiko-workspace";
12
+ import { nodeWorkspaceFs } from "@oscharko-dev/keiko-workspace/internal/fs";
13
+ import { MANAGED_ROOT_MARKER_FILENAME } from "./naming.js";
14
+ import { TaskWorkspaceError } from "./errors.js";
15
+ const MARKER_CONTENT = JSON.stringify({ keikoManagedRoot: true, schemaVersion: "1" });
16
+ function chmodBestEffort(target, mode) {
17
+ if (process.platform === "win32")
18
+ return;
19
+ try {
20
+ chmodSync(target, mode);
21
+ }
22
+ catch {
23
+ // best-effort permission hardening; ownership is proven by the marker, not by perms alone.
24
+ }
25
+ }
26
+ // Creates (if absent) and proves ownership of the managed-worktree root. The marker file is the
27
+ // ownership proof: provisioning refuses to write under a root Keiko cannot establish and mark as its
28
+ // own (SC2). Throws a content-free UNSAFE_PATH error when ownership cannot be proven.
29
+ export function assertManagedRootOwned(managedRoot) {
30
+ try {
31
+ mkdirSync(managedRoot, { recursive: true, mode: 0o700 });
32
+ chmodBestEffort(managedRoot, 0o700);
33
+ const markerPath = join(managedRoot, MANAGED_ROOT_MARKER_FILENAME);
34
+ if (!existsSync(markerPath)) {
35
+ writeFileSync(markerPath, MARKER_CONTENT, { mode: 0o600 });
36
+ }
37
+ chmodBestEffort(markerPath, 0o600);
38
+ if (!statSync(managedRoot).isDirectory() || !existsSync(markerPath)) {
39
+ throw new Error("marker absent after creation");
40
+ }
41
+ }
42
+ catch (error) {
43
+ if (error instanceof TaskWorkspaceError)
44
+ throw error;
45
+ throw new TaskWorkspaceError("UNSAFE_PATH", "managed worktree root ownership could not be established");
46
+ }
47
+ }
48
+ // Asserts the derived worktree path is contained inside the managed root lexically AND after realpath
49
+ // resolution. Delegates to keiko-workspace; any escape (parent traversal, NUL, symlinked ancestor,
50
+ // absolute outside-root target) becomes a content-free UNSAFE_PATH error.
51
+ export function assertManagedTargetContained(managedRoot, worktreePath) {
52
+ try {
53
+ resolveWithinWorkspace(managedRoot, worktreePath);
54
+ assertContainedRealPath(nodeWorkspaceFs, managedRoot, worktreePath, "managed worktree path");
55
+ }
56
+ catch (error) {
57
+ if (error instanceof PathEscapeError) {
58
+ throw new TaskWorkspaceError("UNSAFE_PATH", "worktree path escapes the managed root");
59
+ }
60
+ throw new TaskWorkspaceError("UNSAFE_PATH", "worktree path failed containment validation");
61
+ }
62
+ }
63
+ // Ensures the worktree's PARENT directory (`<managedRoot>/<repositoryId>`) exists before `git worktree
64
+ // add`, which creates only the leaf directory. Must run AFTER containment is asserted.
65
+ export function ensureManagedWorktreeParent(worktreePath) {
66
+ mkdirSync(dirname(worktreePath), { recursive: true, mode: 0o700 });
67
+ }
68
+ // Whether the managed worktree directory currently exists on disk. Combined with the store lookup by
69
+ // the service to classify a target as managed (resume) vs. unmanaged (reject).
70
+ export function managedTargetExists(worktreePath) {
71
+ return existsSync(worktreePath);
72
+ }
73
+ // Non-throwing, read-only ownership check (#448): the managed root is a directory AND Keiko's marker
74
+ // file is present. Unlike assertManagedRootOwned it never creates the root or the marker, so it is the
75
+ // correct gate for read-only health evaluation and for cleanup (which must REFUSE when ownership cannot
76
+ // be proven rather than establish it). Any IO error fails closed (not owned).
77
+ export function isManagedRootOwned(managedRoot) {
78
+ try {
79
+ const markerPath = join(managedRoot, MANAGED_ROOT_MARKER_FILENAME);
80
+ return statSync(managedRoot).isDirectory() && existsSync(markerPath);
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ // Non-throwing containment check (#448): true iff `target` resolves inside the managed root lexically
87
+ // AND after realpath resolution. Delegates to the same keiko-workspace engine as the throwing assert
88
+ // (no second containment engine); any escape — parent traversal, NUL, symlinked ancestor, out-of-root
89
+ // absolute path — or an unverifiable parent chain fails closed (not contained).
90
+ export function isManagedTargetContained(managedRoot, target) {
91
+ try {
92
+ assertManagedTargetContained(managedRoot, target);
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
@@ -0,0 +1,8 @@
1
+ export interface WorkspaceMutexRegistry {
2
+ readonly runExclusive: <T>(keys: readonly string[], fn: () => Promise<T> | T) => Promise<T>;
3
+ }
4
+ export declare function activePointerKey(repositoryId: string): string;
5
+ export declare function workspaceKey(workspaceId: string): string;
6
+ export declare function provisionKey(repositoryId: string, taskId: string): string;
7
+ export declare function createWorkspaceMutexRegistry(): WorkspaceMutexRegistry;
8
+ //# sourceMappingURL=mutex.d.ts.map