@oscharko-dev/keiko-server 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/chat-handlers.d.ts +18 -2
- package/dist/chat-handlers.d.ts.map +1 -1
- package/dist/chat-handlers.js +185 -3
- package/dist/command-runner-errors.d.ts +17 -0
- package/dist/command-runner-errors.d.ts.map +1 -0
- package/dist/command-runner-errors.js +37 -0
- package/dist/command-runner-evidence.d.ts +23 -0
- package/dist/command-runner-evidence.d.ts.map +1 -0
- package/dist/command-runner-evidence.js +69 -0
- package/dist/command-runner-routes.d.ts +7 -0
- package/dist/command-runner-routes.d.ts.map +1 -0
- package/dist/command-runner-routes.js +175 -0
- package/dist/command-runner.d.ts +29 -0
- package/dist/command-runner.d.ts.map +1 -0
- package/dist/command-runner.js +348 -0
- package/dist/conversation-prompt.d.ts +2 -2
- package/dist/conversation-prompt.d.ts.map +1 -1
- package/dist/conversation-prompt.js +17 -1
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +3 -0
- package/dist/deps.d.ts +27 -1
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +288 -13
- package/dist/discussion-prompt.d.ts +4 -0
- package/dist/discussion-prompt.d.ts.map +1 -0
- package/dist/discussion-prompt.js +19 -0
- package/dist/editor/agentActionAudit.d.ts +18 -0
- package/dist/editor/agentActionAudit.d.ts.map +1 -0
- package/dist/editor/agentActionAudit.js +80 -0
- package/dist/editor/agentRoutes.d.ts +1 -0
- package/dist/editor/agentRoutes.d.ts.map +1 -1
- package/dist/editor/agentRoutes.js +292 -55
- package/dist/editor/agentSessionRegistry.d.ts +35 -0
- package/dist/editor/agentSessionRegistry.d.ts.map +1 -0
- package/dist/editor/agentSessionRegistry.js +243 -0
- package/dist/editor/completionRoutes.d.ts.map +1 -1
- package/dist/editor/completionRoutes.js +5 -10
- package/dist/editor/languageRoutes.d.ts +12 -1
- package/dist/editor/languageRoutes.d.ts.map +1 -1
- package/dist/editor/languageRoutes.js +71 -8
- package/dist/editor/languageService.d.ts +3 -2
- package/dist/editor/languageService.d.ts.map +1 -1
- package/dist/editor/languageService.js +41 -3
- package/dist/editor/languageServiceHost.d.ts.map +1 -1
- package/dist/editor/languageServiceHost.js +2 -2
- package/dist/editor/lsp/hostLanguageOperation.d.ts +17 -0
- package/dist/editor/lsp/hostLanguageOperation.d.ts.map +1 -0
- package/dist/editor/lsp/hostLanguageOperation.js +436 -0
- package/dist/editor/lsp/hostLanguageProviders.d.ts +26 -0
- package/dist/editor/lsp/hostLanguageProviders.d.ts.map +1 -0
- package/dist/editor/lsp/hostLanguageProviders.js +161 -0
- package/dist/editor/lsp/lspFrameCodec.d.ts +13 -0
- package/dist/editor/lsp/lspFrameCodec.d.ts.map +1 -0
- package/dist/editor/lsp/lspFrameCodec.js +164 -0
- package/dist/editor/lsp/lspJsonRpcClient.d.ts +34 -0
- package/dist/editor/lsp/lspJsonRpcClient.d.ts.map +1 -0
- package/dist/editor/lsp/lspJsonRpcClient.js +173 -0
- package/dist/editor/lsp/lspLanguageProvider.d.ts +7 -0
- package/dist/editor/lsp/lspLanguageProvider.d.ts.map +1 -0
- package/dist/editor/lsp/lspLanguageProvider.js +29 -0
- package/dist/editor/lsp/lspLifecycleLedger.d.ts +5 -0
- package/dist/editor/lsp/lspLifecycleLedger.d.ts.map +1 -0
- package/dist/editor/lsp/lspLifecycleLedger.js +37 -0
- package/dist/editor/lsp/lspNodeAdapter.d.ts +31 -0
- package/dist/editor/lsp/lspNodeAdapter.d.ts.map +1 -0
- package/dist/editor/lsp/lspNodeAdapter.js +230 -0
- package/dist/editor/lsp/lspProcessManager.d.ts +24 -0
- package/dist/editor/lsp/lspProcessManager.d.ts.map +1 -0
- package/dist/editor/lsp/lspProcessManager.js +255 -0
- package/dist/editor/lsp/lspRestartThrottle.d.ts +6 -0
- package/dist/editor/lsp/lspRestartThrottle.d.ts.map +1 -0
- package/dist/editor/lsp/lspRestartThrottle.js +24 -0
- package/dist/editor/lsp/lspStatusRoute.d.ts +8 -0
- package/dist/editor/lsp/lspStatusRoute.d.ts.map +1 -0
- package/dist/editor/lsp/lspStatusRoute.js +22 -0
- package/dist/editor/lsp/lspTransport.d.ts +19 -0
- package/dist/editor/lsp/lspTransport.d.ts.map +1 -0
- package/dist/editor/lsp/lspTransport.js +55 -0
- package/dist/editor/lsp/testing/fakeLspProcess.d.ts +23 -0
- package/dist/editor/lsp/testing/fakeLspProcess.d.ts.map +1 -0
- package/dist/editor/lsp/testing/fakeLspProcess.js +132 -0
- package/dist/files.d.ts +45 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +631 -7
- package/dist/gateway-readiness.js +3 -3
- package/dist/gateway-setup.d.ts +2 -0
- package/dist/gateway-setup.d.ts.map +1 -1
- package/dist/gateway-setup.js +275 -11
- package/dist/gitDelivery/actionSheetProjection.d.ts +30 -0
- package/dist/gitDelivery/actionSheetProjection.d.ts.map +1 -0
- package/dist/gitDelivery/actionSheetProjection.js +206 -0
- package/dist/gitDelivery/actionSheetRoutes.d.ts +29 -0
- package/dist/gitDelivery/actionSheetRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/actionSheetRoutes.js +293 -0
- package/dist/gitDelivery/agentOperationsRoutes.d.ts +33 -0
- package/dist/gitDelivery/agentOperationsRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/agentOperationsRoutes.js +405 -0
- package/dist/gitDelivery/commitRoutes.d.ts +23 -0
- package/dist/gitDelivery/commitRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/commitRoutes.js +204 -0
- package/dist/gitDelivery/evidenceRoutes.d.ts +9 -0
- package/dist/gitDelivery/evidenceRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/evidenceRoutes.js +101 -0
- package/dist/gitDelivery/execution.d.ts +38 -0
- package/dist/gitDelivery/execution.d.ts.map +1 -0
- package/dist/gitDelivery/execution.js +117 -0
- package/dist/gitDelivery/localMutationRoutes.d.ts +30 -0
- package/dist/gitDelivery/localMutationRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/localMutationRoutes.js +165 -0
- package/dist/gitDelivery/mergeExecution.d.ts +63 -0
- package/dist/gitDelivery/mergeExecution.d.ts.map +1 -0
- package/dist/gitDelivery/mergeExecution.js +168 -0
- package/dist/gitDelivery/mergeRoutes.d.ts +12 -0
- package/dist/gitDelivery/mergeRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/mergeRoutes.js +218 -0
- package/dist/gitDelivery/mutationEvidenceLedger.d.ts +23 -0
- package/dist/gitDelivery/mutationEvidenceLedger.d.ts.map +1 -0
- package/dist/gitDelivery/mutationEvidenceLedger.js +87 -0
- package/dist/gitDelivery/prExecution.d.ts +54 -0
- package/dist/gitDelivery/prExecution.d.ts.map +1 -0
- package/dist/gitDelivery/prExecution.js +192 -0
- package/dist/gitDelivery/prRoutes.d.ts +12 -0
- package/dist/gitDelivery/prRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/prRoutes.js +256 -0
- package/dist/gitDelivery/pushExecution.d.ts +43 -0
- package/dist/gitDelivery/pushExecution.d.ts.map +1 -0
- package/dist/gitDelivery/pushExecution.js +124 -0
- package/dist/gitDelivery/pushRoutes.d.ts +12 -0
- package/dist/gitDelivery/pushRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/pushRoutes.js +200 -0
- package/dist/gitDelivery/requestGuards.d.ts +15 -0
- package/dist/gitDelivery/requestGuards.d.ts.map +1 -0
- package/dist/gitDelivery/requestGuards.js +97 -0
- package/dist/gitDelivery/syncEvidence.d.ts +37 -0
- package/dist/gitDelivery/syncEvidence.d.ts.map +1 -0
- package/dist/gitDelivery/syncEvidence.js +85 -0
- package/dist/gitDelivery/syncExecution.d.ts +30 -0
- package/dist/gitDelivery/syncExecution.d.ts.map +1 -0
- package/dist/gitDelivery/syncExecution.js +266 -0
- package/dist/gitDelivery/syncRoutes.d.ts +13 -0
- package/dist/gitDelivery/syncRoutes.d.ts.map +1 -0
- package/dist/gitDelivery/syncRoutes.js +200 -0
- package/dist/gitPorcelainStatus.d.ts +15 -0
- package/dist/gitPorcelainStatus.d.ts.map +1 -0
- package/dist/gitPorcelainStatus.js +104 -0
- package/dist/gitRepositoryReads.d.ts +10 -0
- package/dist/gitRepositoryReads.d.ts.map +1 -0
- package/dist/gitRepositoryReads.js +314 -0
- package/dist/gitRepositoryRoutes.d.ts +7 -0
- package/dist/gitRepositoryRoutes.d.ts.map +1 -0
- package/dist/gitRepositoryRoutes.js +221 -0
- package/dist/gitRoutes.d.ts +66 -0
- package/dist/gitRoutes.d.ts.map +1 -0
- package/dist/gitRoutes.js +543 -0
- package/dist/governed-workflow.d.ts +2 -0
- package/dist/governed-workflow.d.ts.map +1 -1
- package/dist/governed-workflow.js +4 -0
- package/dist/grounded-qa.d.ts +11 -0
- package/dist/grounded-qa.d.ts.map +1 -1
- package/dist/grounded-qa.js +13 -4
- package/dist/headers.d.ts +4 -1
- package/dist/headers.d.ts.map +1 -1
- package/dist/headers.js +11 -4
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts +1 -1
- package/dist/qualityIntelligence/figmaSnapshotRoutes.d.ts.map +1 -1
- package/dist/qualityIntelligence/figmaSnapshotRoutes.js +1 -1
- package/dist/read-handlers.d.ts +5 -0
- package/dist/read-handlers.d.ts.map +1 -1
- package/dist/read-handlers.js +57 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +259 -6
- package/dist/run-engine.d.ts.map +1 -1
- package/dist/run-engine.js +3 -0
- package/dist/run-handlers.d.ts.map +1 -1
- package/dist/run-handlers.js +74 -4
- package/dist/run-request.d.ts +11 -0
- package/dist/run-request.d.ts.map +1 -1
- package/dist/run-request.js +158 -10
- package/dist/runtime/capabilityDetector.d.ts +38 -0
- package/dist/runtime/capabilityDetector.d.ts.map +1 -0
- package/dist/runtime/capabilityDetector.js +443 -0
- package/dist/runtime/capabilityRoutes.d.ts +9 -0
- package/dist/runtime/capabilityRoutes.d.ts.map +1 -0
- package/dist/runtime/capabilityRoutes.js +45 -0
- package/dist/runtime/containerEngineDetector.d.ts +17 -0
- package/dist/runtime/containerEngineDetector.d.ts.map +1 -0
- package/dist/runtime/containerEngineDetector.js +222 -0
- package/dist/runtime/containerRoutes.d.ts +8 -0
- package/dist/runtime/containerRoutes.d.ts.map +1 -0
- package/dist/runtime/containerRoutes.js +207 -0
- package/dist/runtime/containerRunner-errors.d.ts +18 -0
- package/dist/runtime/containerRunner-errors.d.ts.map +1 -0
- package/dist/runtime/containerRunner-errors.js +42 -0
- package/dist/runtime/containerRunner-evidence.d.ts +24 -0
- package/dist/runtime/containerRunner-evidence.d.ts.map +1 -0
- package/dist/runtime/containerRunner-evidence.js +74 -0
- package/dist/runtime/containerRunner.d.ts +37 -0
- package/dist/runtime/containerRunner.d.ts.map +1 -0
- package/dist/runtime/containerRunner.js +443 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +24 -4
- package/dist/store/schema.d.ts +1 -1
- package/dist/store/schema.d.ts.map +1 -1
- package/dist/store/schema.js +62 -1
- package/dist/task-workspace/active-store.d.ts +21 -0
- package/dist/task-workspace/active-store.d.ts.map +1 -0
- package/dist/task-workspace/active-store.js +55 -0
- package/dist/task-workspace/authorization.d.ts +7 -0
- package/dist/task-workspace/authorization.d.ts.map +1 -0
- package/dist/task-workspace/authorization.js +54 -0
- package/dist/task-workspace/binding.d.ts +3 -0
- package/dist/task-workspace/binding.d.ts.map +1 -0
- package/dist/task-workspace/binding.js +22 -0
- package/dist/task-workspace/cleanup.d.ts +4 -0
- package/dist/task-workspace/cleanup.d.ts.map +1 -0
- package/dist/task-workspace/cleanup.js +428 -0
- package/dist/task-workspace/errors.d.ts +14 -0
- package/dist/task-workspace/errors.d.ts.map +1 -0
- package/dist/task-workspace/errors.js +81 -0
- package/dist/task-workspace/evidence.d.ts +32 -0
- package/dist/task-workspace/evidence.d.ts.map +1 -0
- package/dist/task-workspace/evidence.js +52 -0
- package/dist/task-workspace/field-safety.d.ts +3 -0
- package/dist/task-workspace/field-safety.d.ts.map +1 -0
- package/dist/task-workspace/field-safety.js +42 -0
- package/dist/task-workspace/health.d.ts +4 -0
- package/dist/task-workspace/health.d.ts.map +1 -0
- package/dist/task-workspace/health.js +163 -0
- package/dist/task-workspace/lifecycle.d.ts +3 -0
- package/dist/task-workspace/lifecycle.d.ts.map +1 -0
- package/dist/task-workspace/lifecycle.js +248 -0
- package/dist/task-workspace/locks.d.ts +13 -0
- package/dist/task-workspace/locks.d.ts.map +1 -0
- package/dist/task-workspace/locks.js +44 -0
- package/dist/task-workspace/managed-root.d.ts +7 -0
- package/dist/task-workspace/managed-root.d.ts.map +1 -0
- package/dist/task-workspace/managed-root.js +98 -0
- package/dist/task-workspace/mutex.d.ts +8 -0
- package/dist/task-workspace/mutex.d.ts.map +1 -0
- package/dist/task-workspace/mutex.js +82 -0
- package/dist/task-workspace/naming.d.ts +15 -0
- package/dist/task-workspace/naming.d.ts.map +1 -0
- package/dist/task-workspace/naming.js +0 -0
- package/dist/task-workspace/provisioning.d.ts +3 -0
- package/dist/task-workspace/provisioning.d.ts.map +1 -0
- package/dist/task-workspace/provisioning.js +528 -0
- package/dist/task-workspace/reconciliation.d.ts +15 -0
- package/dist/task-workspace/reconciliation.d.ts.map +1 -0
- package/dist/task-workspace/reconciliation.js +274 -0
- package/dist/task-workspace/repair.d.ts +3 -0
- package/dist/task-workspace/repair.d.ts.map +1 -0
- package/dist/task-workspace/repair.js +286 -0
- package/dist/task-workspace/routes.d.ts +19 -0
- package/dist/task-workspace/routes.d.ts.map +1 -0
- package/dist/task-workspace/routes.js +481 -0
- package/dist/task-workspace/store.d.ts +12 -0
- package/dist/task-workspace/store.d.ts.map +1 -0
- package/dist/task-workspace/store.js +128 -0
- package/dist/task-workspace/types.d.ts +170 -0
- package/dist/task-workspace/types.d.ts.map +1 -0
- package/dist/task-workspace/types.js +5 -0
- package/dist/voice-action-governance.d.ts +23 -0
- package/dist/voice-action-governance.d.ts.map +1 -0
- package/dist/voice-action-governance.js +126 -0
- package/dist/voice-handlers.d.ts +6 -0
- package/dist/voice-handlers.d.ts.map +1 -0
- package/dist/voice-handlers.js +570 -0
- package/dist/voice-realtime-grounded-tool.d.ts +31 -0
- package/dist/voice-realtime-grounded-tool.d.ts.map +1 -0
- package/dist/voice-realtime-grounded-tool.js +322 -0
- package/dist/voice-realtime.d.ts +69 -0
- package/dist/voice-realtime.d.ts.map +1 -0
- package/dist/voice-realtime.js +787 -0
- package/dist/workspace-state-handlers.d.ts +5 -0
- package/dist/workspace-state-handlers.d.ts.map +1 -0
- package/dist/workspace-state-handlers.js +106 -0
- package/package.json +20 -19
|
@@ -0,0 +1,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 @@
|
|
|
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"}
|