@paperclipai/server 2026.817.0 → 2026.818.0-beta.1
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/adapters/hermes-gateway-doc.d.ts +1 -1
- package/dist/adapters/hermes-gateway-doc.d.ts.map +1 -1
- package/dist/adapters/hermes-gateway-doc.js +1 -1
- package/dist/adapters/plugin-loader.d.ts.map +1 -1
- package/dist/adapters/plugin-loader.js +3 -2
- package/dist/adapters/plugin-loader.js.map +1 -1
- package/dist/agent-auth-jwt.d.ts.map +1 -1
- package/dist/agent-auth-jwt.js +8 -2
- package/dist/agent-auth-jwt.js.map +1 -1
- package/dist/app.d.ts +5 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +202 -15
- package/dist/app.js.map +1 -1
- package/dist/built-ins/agents/summarizer/AGENTS.md +2 -2
- package/dist/built-ins/agents/summarizer/routines/refresh-stale-summaries.md +1 -0
- package/dist/db-errors.d.ts +9 -0
- package/dist/db-errors.d.ts.map +1 -1
- package/dist/db-errors.js +19 -0
- package/dist/db-errors.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +162 -38
- package/dist/index.js.map +1 -1
- package/dist/lib/request-promise-memo.d.ts +6 -0
- package/dist/lib/request-promise-memo.d.ts.map +1 -0
- package/dist/lib/request-promise-memo.js +25 -0
- package/dist/lib/request-promise-memo.js.map +1 -0
- package/dist/lib/request-promise-memo.test.d.ts +2 -0
- package/dist/lib/request-promise-memo.test.d.ts.map +1 -0
- package/dist/lib/request-promise-memo.test.js +74 -0
- package/dist/lib/request-promise-memo.test.js.map +1 -0
- package/dist/middleware/auth.d.ts +13 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +92 -41
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/cloud-tenant-actor.test.js +26 -1
- package/dist/middleware/cloud-tenant-actor.test.js.map +1 -1
- package/dist/middleware/error-handler.d.ts.map +1 -1
- package/dist/middleware/error-handler.js +11 -2
- package/dist/middleware/error-handler.js.map +1 -1
- package/dist/middleware/logger.d.ts.map +1 -1
- package/dist/middleware/logger.js +7 -33
- package/dist/middleware/logger.js.map +1 -1
- package/dist/middleware/private-hostname-guard.d.ts.map +1 -1
- package/dist/middleware/private-hostname-guard.js +11 -6
- package/dist/middleware/private-hostname-guard.js.map +1 -1
- package/dist/middleware/private-json-etag.d.ts +3 -0
- package/dist/middleware/private-json-etag.d.ts.map +1 -0
- package/dist/middleware/private-json-etag.js +37 -0
- package/dist/middleware/private-json-etag.js.map +1 -0
- package/dist/middleware/redact-sensitive.d.ts.map +1 -1
- package/dist/middleware/redact-sensitive.js +11 -0
- package/dist/middleware/redact-sensitive.js.map +1 -1
- package/dist/realtime/live-events-ws.d.ts +12 -0
- package/dist/realtime/live-events-ws.d.ts.map +1 -1
- package/dist/realtime/live-events-ws.js +20 -0
- package/dist/realtime/live-events-ws.js.map +1 -1
- package/dist/redaction.js +1 -1
- package/dist/redaction.js.map +1 -1
- package/dist/routes/access.d.ts +2 -0
- package/dist/routes/access.d.ts.map +1 -1
- package/dist/routes/access.js +21 -10
- package/dist/routes/access.js.map +1 -1
- package/dist/routes/agents.d.ts +42 -0
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +875 -8
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/companies.d.ts +5 -1
- package/dist/routes/companies.d.ts.map +1 -1
- package/dist/routes/companies.js +467 -30
- package/dist/routes/companies.js.map +1 -1
- package/dist/routes/environments.d.ts +12 -3
- package/dist/routes/environments.d.ts.map +1 -1
- package/dist/routes/environments.js +139 -23
- package/dist/routes/environments.js.map +1 -1
- package/dist/routes/execution-workspaces.d.ts.map +1 -1
- package/dist/routes/execution-workspaces.js +240 -70
- package/dist/routes/execution-workspaces.js.map +1 -1
- package/dist/routes/file-resources.d.ts +14 -1
- package/dist/routes/file-resources.d.ts.map +1 -1
- package/dist/routes/file-resources.js +182 -3
- package/dist/routes/file-resources.js.map +1 -1
- package/dist/routes/index.d.ts +2 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -1
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/instance-settings.d.ts.map +1 -1
- package/dist/routes/instance-settings.js +20 -1
- package/dist/routes/instance-settings.js.map +1 -1
- package/dist/routes/issues.d.ts.map +1 -1
- package/dist/routes/issues.js +750 -235
- package/dist/routes/issues.js.map +1 -1
- package/dist/routes/onboarding-seed.d.ts +27 -0
- package/dist/routes/onboarding-seed.d.ts.map +1 -0
- package/dist/routes/onboarding-seed.js +56 -0
- package/dist/routes/onboarding-seed.js.map +1 -0
- package/dist/routes/openapi.d.ts.map +1 -1
- package/dist/routes/openapi.js +316 -2
- package/dist/routes/openapi.js.map +1 -1
- package/dist/routes/secrets.d.ts.map +1 -1
- package/dist/routes/secrets.js +11 -1
- package/dist/routes/secrets.js.map +1 -1
- package/dist/routes/setup-token-route.test.d.ts +2 -0
- package/dist/routes/setup-token-route.test.d.ts.map +1 -0
- package/dist/routes/setup-token-route.test.js +896 -0
- package/dist/routes/setup-token-route.test.js.map +1 -0
- package/dist/routes/tool-access.d.ts.map +1 -1
- package/dist/routes/tool-access.js +87 -52
- package/dist/routes/tool-access.js.map +1 -1
- package/dist/routes/workspace-runtime-service-authz.d.ts +11 -1
- package/dist/routes/workspace-runtime-service-authz.d.ts.map +1 -1
- package/dist/routes/workspace-runtime-service-authz.js +18 -16
- package/dist/routes/workspace-runtime-service-authz.js.map +1 -1
- package/dist/services/access.d.ts.map +1 -1
- package/dist/services/activity.d.ts +1 -1
- package/dist/services/agents.d.ts +21 -0
- package/dist/services/agents.d.ts.map +1 -1
- package/dist/services/agents.js +121 -1
- package/dist/services/agents.js.map +1 -1
- package/dist/services/attention-resolver-audience.test.d.ts +2 -0
- package/dist/services/attention-resolver-audience.test.d.ts.map +1 -0
- package/dist/services/attention-resolver-audience.test.js +81 -0
- package/dist/services/attention-resolver-audience.test.js.map +1 -0
- package/dist/services/attention.d.ts +20 -1
- package/dist/services/attention.d.ts.map +1 -1
- package/dist/services/attention.js +45 -1
- package/dist/services/attention.js.map +1 -1
- package/dist/services/authorization.d.ts +2 -2
- package/dist/services/authorization.d.ts.map +1 -1
- package/dist/services/authorization.js +89 -46
- package/dist/services/authorization.js.map +1 -1
- package/dist/services/board-auth.d.ts.map +1 -1
- package/dist/services/board-auth.js +40 -1
- package/dist/services/board-auth.js.map +1 -1
- package/dist/services/board-auth.test.d.ts +2 -0
- package/dist/services/board-auth.test.d.ts.map +1 -0
- package/dist/services/board-auth.test.js +34 -0
- package/dist/services/board-auth.test.js.map +1 -0
- package/dist/services/built-in-agents.d.ts.map +1 -1
- package/dist/services/built-in-agents.js +20 -4
- package/dist/services/built-in-agents.js.map +1 -1
- package/dist/services/bundled-plugins.d.ts +11 -1
- package/dist/services/bundled-plugins.d.ts.map +1 -1
- package/dist/services/bundled-plugins.js +46 -1
- package/dist/services/bundled-plugins.js.map +1 -1
- package/dist/services/codex-device-login-reaper.d.ts +61 -0
- package/dist/services/codex-device-login-reaper.d.ts.map +1 -0
- package/dist/services/codex-device-login-reaper.js +219 -0
- package/dist/services/codex-device-login-reaper.js.map +1 -0
- package/dist/services/codex-device-login-service.d.ts +355 -0
- package/dist/services/codex-device-login-service.d.ts.map +1 -0
- package/dist/services/codex-device-login-service.js +775 -0
- package/dist/services/codex-device-login-service.js.map +1 -0
- package/dist/services/companies.d.ts.map +1 -1
- package/dist/services/companies.js +7 -0
- package/dist/services/companies.js.map +1 -1
- package/dist/services/company-export-readme.js +1 -1
- package/dist/services/company-export-readme.js.map +1 -1
- package/dist/services/company-import-transfers.d.ts +55 -0
- package/dist/services/company-import-transfers.d.ts.map +1 -0
- package/dist/services/company-import-transfers.js +151 -0
- package/dist/services/company-import-transfers.js.map +1 -0
- package/dist/services/company-portability.d.ts.map +1 -1
- package/dist/services/company-portability.js +128 -3
- package/dist/services/company-portability.js.map +1 -1
- package/dist/services/company-transfer-runs.d.ts +136 -0
- package/dist/services/company-transfer-runs.d.ts.map +1 -0
- package/dist/services/company-transfer-runs.js +243 -0
- package/dist/services/company-transfer-runs.js.map +1 -0
- package/dist/services/cross-issue-influence-limit.d.ts +7 -1
- package/dist/services/cross-issue-influence-limit.d.ts.map +1 -1
- package/dist/services/cross-issue-influence-limit.js.map +1 -1
- package/dist/services/environment-config.d.ts +15 -0
- package/dist/services/environment-config.d.ts.map +1 -1
- package/dist/services/environment-config.js +56 -10
- package/dist/services/environment-config.js.map +1 -1
- package/dist/services/environment-execution-target.d.ts.map +1 -1
- package/dist/services/environment-execution-target.js +61 -11
- package/dist/services/environment-execution-target.js.map +1 -1
- package/dist/services/environment-run-orchestrator.d.ts +16 -1
- package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
- package/dist/services/environment-run-orchestrator.js +1 -1
- package/dist/services/environment-run-orchestrator.js.map +1 -1
- package/dist/services/environment-runtime.d.ts +178 -2
- package/dist/services/environment-runtime.d.ts.map +1 -1
- package/dist/services/environment-runtime.js +1035 -96
- package/dist/services/environment-runtime.js.map +1 -1
- package/dist/services/environments.d.ts +75 -0
- package/dist/services/environments.d.ts.map +1 -1
- package/dist/services/environments.js +199 -8
- package/dist/services/environments.js.map +1 -1
- package/dist/services/execution-allowlist.d.ts +16 -4
- package/dist/services/execution-allowlist.d.ts.map +1 -1
- package/dist/services/execution-allowlist.js +35 -16
- package/dist/services/execution-allowlist.js.map +1 -1
- package/dist/services/execution-allowlist.test.js +33 -0
- package/dist/services/execution-allowlist.test.js.map +1 -1
- package/dist/services/execution-workspace-policy.d.ts +14 -1
- package/dist/services/execution-workspace-policy.d.ts.map +1 -1
- package/dist/services/execution-workspace-policy.js +29 -12
- package/dist/services/execution-workspace-policy.js.map +1 -1
- package/dist/services/execution-workspaces.d.ts +89 -0
- package/dist/services/execution-workspaces.d.ts.map +1 -1
- package/dist/services/execution-workspaces.js +997 -125
- package/dist/services/execution-workspaces.js.map +1 -1
- package/dist/services/heartbeat-run-summary.d.ts +1 -0
- package/dist/services/heartbeat-run-summary.d.ts.map +1 -1
- package/dist/services/heartbeat-run-summary.js +19 -3
- package/dist/services/heartbeat-run-summary.js.map +1 -1
- package/dist/services/heartbeat.d.ts +18 -2
- package/dist/services/heartbeat.d.ts.map +1 -1
- package/dist/services/heartbeat.js +369 -16
- package/dist/services/heartbeat.js.map +1 -1
- package/dist/services/import-write-types.d.ts +12 -0
- package/dist/services/import-write-types.d.ts.map +1 -1
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/services/instance-settings.d.ts +10 -1
- package/dist/services/instance-settings.d.ts.map +1 -1
- package/dist/services/instance-settings.js +16 -9
- package/dist/services/instance-settings.js.map +1 -1
- package/dist/services/issue-review-policy.d.ts +4 -0
- package/dist/services/issue-review-policy.d.ts.map +1 -1
- package/dist/services/issue-review-policy.js +18 -7
- package/dist/services/issue-review-policy.js.map +1 -1
- package/dist/services/issue-thread-interaction-resolution.d.ts +92 -0
- package/dist/services/issue-thread-interaction-resolution.d.ts.map +1 -0
- package/dist/services/issue-thread-interaction-resolution.js +198 -0
- package/dist/services/issue-thread-interaction-resolution.js.map +1 -0
- package/dist/services/issue-thread-interaction-resolution.test.d.ts +2 -0
- package/dist/services/issue-thread-interaction-resolution.test.d.ts.map +1 -0
- package/dist/services/issue-thread-interaction-resolution.test.js +152 -0
- package/dist/services/issue-thread-interaction-resolution.test.js.map +1 -0
- package/dist/services/issue-thread-interactions.d.ts +24 -4
- package/dist/services/issue-thread-interactions.d.ts.map +1 -1
- package/dist/services/issue-thread-interactions.js +360 -145
- package/dist/services/issue-thread-interactions.js.map +1 -1
- package/dist/services/issue-thread-interactions.test.js +18 -11
- package/dist/services/issue-thread-interactions.test.js.map +1 -1
- package/dist/services/issues.d.ts +3 -0
- package/dist/services/issues.d.ts.map +1 -1
- package/dist/services/issues.js +157 -33
- package/dist/services/issues.js.map +1 -1
- package/dist/services/local-service-supervisor.d.ts +2 -0
- package/dist/services/local-service-supervisor.d.ts.map +1 -1
- package/dist/services/local-service-supervisor.js +53 -3
- package/dist/services/local-service-supervisor.js.map +1 -1
- package/dist/services/managed-environments.d.ts +4 -1
- package/dist/services/managed-environments.d.ts.map +1 -1
- package/dist/services/managed-environments.js +86 -2
- package/dist/services/managed-environments.js.map +1 -1
- package/dist/services/managed-environments.test.js +164 -0
- package/dist/services/managed-environments.test.js.map +1 -1
- package/dist/services/onboarding-greeting.d.ts +7 -0
- package/dist/services/onboarding-greeting.d.ts.map +1 -0
- package/dist/services/onboarding-greeting.js +27 -0
- package/dist/services/onboarding-greeting.js.map +1 -0
- package/dist/services/onboarding-greeting.test.d.ts +2 -0
- package/dist/services/onboarding-greeting.test.d.ts.map +1 -0
- package/dist/services/onboarding-greeting.test.js +32 -0
- package/dist/services/onboarding-greeting.test.js.map +1 -0
- package/dist/services/onboarding-seed.d.ts +57 -0
- package/dist/services/onboarding-seed.d.ts.map +1 -0
- package/dist/services/onboarding-seed.js +344 -0
- package/dist/services/onboarding-seed.js.map +1 -0
- package/dist/services/plan-review-context.d.ts +9 -1
- package/dist/services/plan-review-context.d.ts.map +1 -1
- package/dist/services/plan-review-context.js +192 -0
- package/dist/services/plan-review-context.js.map +1 -1
- package/dist/services/plugin-environment-driver.d.ts +46 -0
- package/dist/services/plugin-environment-driver.d.ts.map +1 -1
- package/dist/services/plugin-environment-driver.js +50 -0
- package/dist/services/plugin-environment-driver.js.map +1 -1
- package/dist/services/plugin-host-services.d.ts +1 -0
- package/dist/services/plugin-host-services.d.ts.map +1 -1
- package/dist/services/plugin-host-services.js +51 -28
- package/dist/services/plugin-host-services.js.map +1 -1
- package/dist/services/plugin-loader.d.ts +10 -1
- package/dist/services/plugin-loader.d.ts.map +1 -1
- package/dist/services/plugin-loader.js +43 -2
- package/dist/services/plugin-loader.js.map +1 -1
- package/dist/services/plugin-worker-manager.d.ts +61 -0
- package/dist/services/plugin-worker-manager.d.ts.map +1 -1
- package/dist/services/plugin-worker-manager.js +248 -1
- package/dist/services/plugin-worker-manager.js.map +1 -1
- package/dist/services/productivity-review.d.ts.map +1 -1
- package/dist/services/productivity-review.js +5 -0
- package/dist/services/productivity-review.js.map +1 -1
- package/dist/services/recovery/service.d.ts.map +1 -1
- package/dist/services/recovery/service.js +5 -1
- package/dist/services/recovery/service.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.d.ts +16 -1
- package/dist/services/recovery/successful-run-handoff.d.ts.map +1 -1
- package/dist/services/recovery/successful-run-handoff.js +32 -0
- package/dist/services/recovery/successful-run-handoff.js.map +1 -1
- package/dist/services/recovery/successful-run-handoff.test.js +51 -1
- package/dist/services/recovery/successful-run-handoff.test.js.map +1 -1
- package/dist/services/routines.d.ts.map +1 -1
- package/dist/services/routines.js +48 -4
- package/dist/services/routines.js.map +1 -1
- package/dist/services/runtime-exposure/broker-client.d.ts +88 -0
- package/dist/services/runtime-exposure/broker-client.d.ts.map +1 -0
- package/dist/services/runtime-exposure/broker-client.js +217 -0
- package/dist/services/runtime-exposure/broker-client.js.map +1 -0
- package/dist/services/runtime-exposure/broker-client.test.d.ts +2 -0
- package/dist/services/runtime-exposure/broker-client.test.d.ts.map +1 -0
- package/dist/services/runtime-exposure/broker-client.test.js +185 -0
- package/dist/services/runtime-exposure/broker-client.test.js.map +1 -0
- package/dist/services/runtime-exposure/exposure-manager.d.ts +135 -0
- package/dist/services/runtime-exposure/exposure-manager.d.ts.map +1 -0
- package/dist/services/runtime-exposure/exposure-manager.js +247 -0
- package/dist/services/runtime-exposure/exposure-manager.js.map +1 -0
- package/dist/services/runtime-exposure/exposure-manager.test.d.ts +2 -0
- package/dist/services/runtime-exposure/exposure-manager.test.d.ts.map +1 -0
- package/dist/services/runtime-exposure/exposure-manager.test.js +384 -0
- package/dist/services/runtime-exposure/exposure-manager.test.js.map +1 -0
- package/dist/services/runtime-exposure/loopback-listener.d.ts +33 -0
- package/dist/services/runtime-exposure/loopback-listener.d.ts.map +1 -0
- package/dist/services/runtime-exposure/loopback-listener.js +146 -0
- package/dist/services/runtime-exposure/loopback-listener.js.map +1 -0
- package/dist/services/runtime-exposure/loopback-listener.test.d.ts +2 -0
- package/dist/services/runtime-exposure/loopback-listener.test.d.ts.map +1 -0
- package/dist/services/runtime-exposure/loopback-listener.test.js +116 -0
- package/dist/services/runtime-exposure/loopback-listener.test.js.map +1 -0
- package/dist/services/runtime-exposure/port-pair.d.ts +47 -0
- package/dist/services/runtime-exposure/port-pair.d.ts.map +1 -0
- package/dist/services/runtime-exposure/port-pair.js +57 -0
- package/dist/services/runtime-exposure/port-pair.js.map +1 -0
- package/dist/services/runtime-exposure/port-pair.test.d.ts +2 -0
- package/dist/services/runtime-exposure/port-pair.test.d.ts.map +1 -0
- package/dist/services/runtime-exposure/port-pair.test.js +92 -0
- package/dist/services/runtime-exposure/port-pair.test.js.map +1 -0
- package/dist/services/runtime-exposure/port-reservation.d.ts +211 -0
- package/dist/services/runtime-exposure/port-reservation.d.ts.map +1 -0
- package/dist/services/runtime-exposure/port-reservation.js +380 -0
- package/dist/services/runtime-exposure/port-reservation.js.map +1 -0
- package/dist/services/runtime-exposure/port-reservation.test.d.ts +2 -0
- package/dist/services/runtime-exposure/port-reservation.test.d.ts.map +1 -0
- package/dist/services/runtime-exposure/port-reservation.test.js +456 -0
- package/dist/services/runtime-exposure/port-reservation.test.js.map +1 -0
- package/dist/services/runtime-exposure/tailscale-hostname.d.ts +11 -0
- package/dist/services/runtime-exposure/tailscale-hostname.d.ts.map +1 -0
- package/dist/services/runtime-exposure/tailscale-hostname.js +36 -0
- package/dist/services/runtime-exposure/tailscale-hostname.js.map +1 -0
- package/dist/services/runtime-exposure/tailscale-hostname.test.d.ts +2 -0
- package/dist/services/runtime-exposure/tailscale-hostname.test.d.ts.map +1 -0
- package/dist/services/runtime-exposure/tailscale-hostname.test.js +14 -0
- package/dist/services/runtime-exposure/tailscale-hostname.test.js.map +1 -0
- package/dist/services/sandbox-orphan-cleanup-spool.d.ts +64 -0
- package/dist/services/sandbox-orphan-cleanup-spool.d.ts.map +1 -0
- package/dist/services/sandbox-orphan-cleanup-spool.js +134 -0
- package/dist/services/sandbox-orphan-cleanup-spool.js.map +1 -0
- package/dist/services/sandbox-provider-runtime.d.ts +15 -0
- package/dist/services/sandbox-provider-runtime.d.ts.map +1 -1
- package/dist/services/sandbox-provider-runtime.js +1 -0
- package/dist/services/sandbox-provider-runtime.js.map +1 -1
- package/dist/services/secrets.d.ts +103 -0
- package/dist/services/secrets.d.ts.map +1 -1
- package/dist/services/secrets.js +432 -10
- package/dist/services/secrets.js.map +1 -1
- package/dist/services/setup-token-session.d.ts +630 -0
- package/dist/services/setup-token-session.d.ts.map +1 -0
- package/dist/services/setup-token-session.js +1005 -0
- package/dist/services/setup-token-session.js.map +1 -0
- package/dist/services/setup-token-session.test.d.ts +2 -0
- package/dist/services/setup-token-session.test.d.ts.map +1 -0
- package/dist/services/setup-token-session.test.js +1143 -0
- package/dist/services/setup-token-session.test.js.map +1 -0
- package/dist/services/setup-token-transport-binding.d.ts +202 -0
- package/dist/services/setup-token-transport-binding.d.ts.map +1 -0
- package/dist/services/setup-token-transport-binding.js +386 -0
- package/dist/services/setup-token-transport-binding.js.map +1 -0
- package/dist/services/setup-token-transport-binding.test.d.ts +2 -0
- package/dist/services/setup-token-transport-binding.test.d.ts.map +1 -0
- package/dist/services/setup-token-transport-binding.test.js +761 -0
- package/dist/services/setup-token-transport-binding.test.js.map +1 -0
- package/dist/services/summary-slots.d.ts.map +1 -1
- package/dist/services/summary-slots.js +40 -6
- package/dist/services/summary-slots.js.map +1 -1
- package/dist/services/task-watchdogs.js +2 -2
- package/dist/services/task-watchdogs.js.map +1 -1
- package/dist/services/tool-access-policy.d.ts +1 -1
- package/dist/services/tool-access.d.ts.map +1 -1
- package/dist/services/tool-access.js +29 -11
- package/dist/services/tool-access.js.map +1 -1
- package/dist/services/tool-gateway.d.ts +1 -1
- package/dist/services/tool-gateway.d.ts.map +1 -1
- package/dist/services/tool-gateway.js +56 -5
- package/dist/services/tool-gateway.js.map +1 -1
- package/dist/services/workspace-file-resources.d.ts +10 -1
- package/dist/services/workspace-file-resources.d.ts.map +1 -1
- package/dist/services/workspace-file-resources.js +258 -6
- package/dist/services/workspace-file-resources.js.map +1 -1
- package/dist/services/workspace-git-operation-scheduler.d.ts +135 -0
- package/dist/services/workspace-git-operation-scheduler.d.ts.map +1 -0
- package/dist/services/workspace-git-operation-scheduler.js +655 -0
- package/dist/services/workspace-git-operation-scheduler.js.map +1 -0
- package/dist/services/workspace-git-operation-scheduler.test.d.ts +2 -0
- package/dist/services/workspace-git-operation-scheduler.test.d.ts.map +1 -0
- package/dist/services/workspace-git-operation-scheduler.test.js +367 -0
- package/dist/services/workspace-git-operation-scheduler.test.js.map +1 -0
- package/dist/services/workspace-operations.d.ts +60 -0
- package/dist/services/workspace-operations.d.ts.map +1 -1
- package/dist/services/workspace-operations.js +384 -19
- package/dist/services/workspace-operations.js.map +1 -1
- package/dist/services/workspace-runtime-exposure-backfill.test.d.ts +2 -0
- package/dist/services/workspace-runtime-exposure-backfill.test.d.ts.map +1 -0
- package/dist/services/workspace-runtime-exposure-backfill.test.js +149 -0
- package/dist/services/workspace-runtime-exposure-backfill.test.js.map +1 -0
- package/dist/services/workspace-runtime-exposure.test.d.ts +2 -0
- package/dist/services/workspace-runtime-exposure.test.d.ts.map +1 -0
- package/dist/services/workspace-runtime-exposure.test.js +517 -0
- package/dist/services/workspace-runtime-exposure.test.js.map +1 -0
- package/dist/services/workspace-runtime-leases.d.ts +90 -0
- package/dist/services/workspace-runtime-leases.d.ts.map +1 -0
- package/dist/services/workspace-runtime-leases.js +252 -0
- package/dist/services/workspace-runtime-leases.js.map +1 -0
- package/dist/services/workspace-runtime-read-model.d.ts +12 -0
- package/dist/services/workspace-runtime-read-model.d.ts.map +1 -1
- package/dist/services/workspace-runtime-read-model.test.js +3 -0
- package/dist/services/workspace-runtime-read-model.test.js.map +1 -1
- package/dist/services/workspace-runtime-ready-comment.test.js +1 -0
- package/dist/services/workspace-runtime-ready-comment.test.js.map +1 -1
- package/dist/services/workspace-runtime.d.ts +160 -2
- package/dist/services/workspace-runtime.d.ts.map +1 -1
- package/dist/services/workspace-runtime.js +1693 -141
- package/dist/services/workspace-runtime.js.map +1 -1
- package/dist/shutdown.d.ts +24 -0
- package/dist/shutdown.d.ts.map +1 -1
- package/dist/shutdown.js +37 -0
- package/dist/shutdown.js.map +1 -1
- package/dist/shutdown.test.js +113 -1
- package/dist/shutdown.test.js.map +1 -1
- package/dist/startup-banner.js +1 -1
- package/dist/startup-banner.js.map +1 -1
- package/dist/url-utils.d.ts +26 -0
- package/dist/url-utils.d.ts.map +1 -0
- package/dist/url-utils.js +59 -0
- package/dist/url-utils.js.map +1 -0
- package/dist/url-utils.test.d.ts +2 -0
- package/dist/url-utils.test.d.ts.map +1 -0
- package/dist/url-utils.test.js +55 -0
- package/dist/url-utils.test.js.map +1 -0
- package/dist/worktree-config.d.ts.map +1 -1
- package/dist/worktree-config.js +3 -17
- package/dist/worktree-config.js.map +1 -1
- package/package.json +20 -20
- package/skills/paperclip/SKILL.md +16 -11
- package/skills/paperclip/references/api-reference.md +8 -5
- package/skills/paperclip-board/SKILL.md +1 -1
- package/ui-dist/assets/{abnfDiagram-VRR7QNED-wvSnvD6-.js → abnfDiagram-N423BO3Z-BvgoV-u5.js} +1 -1
- package/ui-dist/assets/{arc-Bx0V1JsN.js → arc-BInnUBUU.js} +1 -1
- package/ui-dist/assets/architectureDiagram-T3A2C74G-Dg4MziOm.js +36 -0
- package/ui-dist/assets/{blockDiagram-677ZJIJ3-BROcLaUk.js → blockDiagram-VBNYF7ZC-D9kzAkLN.js} +1 -1
- package/ui-dist/assets/{c4Diagram-LMCZKHZV-D-V3kgju.js → c4Diagram-5PPSVZJV-BwzsgV_0.js} +1 -1
- package/ui-dist/assets/channel-qoSYbCn0.js +1 -0
- package/ui-dist/assets/{chunk-32BRIVSS-qillH3H6.js → chunk-2GRJ4B5K-D2EQ9vrl.js} +1 -1
- package/ui-dist/assets/{chunk-2Q5K7J3B-D19cVnii.js → chunk-2Q5K7J3B-DOA_8fSd.js} +1 -1
- package/ui-dist/assets/{chunk-EX3LRPZG-CLUswrZg.js → chunk-5RXB4S5H-CT5ukRiT.js} +1 -1
- package/ui-dist/assets/{chunk-5VM5RSS4-Cwlp02Lx.js → chunk-5VM5RSS4-D2gOlkyB.js} +1 -1
- package/ui-dist/assets/{chunk-MOJQB5TN-yKciyq6E.js → chunk-6Q2QTUOP-DUb6aG8c.js} +1 -1
- package/ui-dist/assets/{chunk-V7JOEXUC-DjJ75g0_.js → chunk-GF5L2VYU-BPabG3Cx.js} +1 -1
- package/ui-dist/assets/{chunk-JWPE2WC7-BjepyzEb.js → chunk-JWPE2WC7-scNfhlZz.js} +1 -1
- package/ui-dist/assets/{chunk-VR4S4FIN-CiEmy8-R.js → chunk-KBJHAD2P-BcRAzRpq.js} +1 -1
- package/ui-dist/assets/{chunk-RYQCIY6F-ed7mQM9j.js → chunk-RYQCIY6F-BiZ0-EGh.js} +1 -1
- package/ui-dist/assets/{chunk-XXDRQBXY-BiI83zoO.js → chunk-XXDRQBXY-lJULxFIE.js} +1 -1
- package/ui-dist/assets/classDiagram-JCYQIIEL-B24u1REs.js +1 -0
- package/ui-dist/assets/classDiagram-v2-OCEON4UE-B24u1REs.js +1 -0
- package/ui-dist/assets/{cose-bilkent-JH36ORCC-DIdQf5ta.js → cose-bilkent-JH36ORCC-Do8pe8JB.js} +1 -1
- package/ui-dist/assets/{cynefin-VYW2F7L2-B2m9xxh8.js → cynefin-VYW2F7L2-B34jJY1x.js} +1 -1
- package/ui-dist/assets/{cynefinDiagram-TSTJHNR4-CGVdB0kX.js → cynefinDiagram-MW4NZA55-DQ8QK8JK.js} +1 -1
- package/ui-dist/assets/cytoscape.esm-DShqj__B.js +321 -0
- package/ui-dist/assets/{dagre-VKFMJZFB-CskKiZwJ.js → dagre-VZM6K2ZE-B7lSKgxH.js} +1 -1
- package/ui-dist/assets/{diagram-OA4YK3LP-D2cn5eIN.js → diagram-7IWD3JNH-B3hLAFGE.js} +1 -1
- package/ui-dist/assets/{diagram-FQU43EPY-oa5Zu0XG.js → diagram-B4RE2ZJO-CH9Ro9e0.js} +1 -1
- package/ui-dist/assets/{diagram-NH7WQ7WH-fymti5Js.js → diagram-LBJQPF4R-DdPVMii7.js} +1 -1
- package/ui-dist/assets/{diagram-G47NLZAW-BWF2ZCsw.js → diagram-Q27KOJAE-f5VXswjc.js} +1 -1
- package/ui-dist/assets/diagram-UB23O5K3-DGqxixsR.js +41 -0
- package/ui-dist/assets/{ebnfDiagram-CCIWWBDH-CJ1d3D5q.js → ebnfDiagram-BXEA7PRR-C-1klg_w.js} +1 -1
- package/ui-dist/assets/{erDiagram-Q63AITRT-BJCPa7kK.js → erDiagram-JOGREHBK-Dt4qK4gv.js} +1 -1
- package/ui-dist/assets/{flowDiagram-23GEKE2U-BWjWuz6r.js → flowDiagram-UKHOOZJN-9ZlrTuET.js} +5 -5
- package/ui-dist/assets/{ganttDiagram-NO4QXBWP-Dv1KKjoM.js → ganttDiagram-PKOTCBZU-CE3lyvFq.js} +1 -1
- package/ui-dist/assets/{gitGraphDiagram-IHSO6WYX-9X7vgL0A.js → gitGraphDiagram-DS77QQ5N-HH2fSgad.js} +1 -1
- package/ui-dist/assets/{index-CpaK48ok.js → index-B61ORkIb.js} +1 -1
- package/ui-dist/assets/index-BHiUiToL.css +1 -0
- package/ui-dist/assets/{index-D2YCrA-D.js → index-BY5ITKmG.js} +1 -1
- package/ui-dist/assets/{index-9JMDmXc5.js → index-BbAhzCq8.js} +1 -1
- package/ui-dist/assets/{index-C5qHatpp.js → index-Bc9g6q63.js} +1 -1
- package/ui-dist/assets/{index-DuI6e5mP.js → index-Bf7tyGxE.js} +1 -1
- package/ui-dist/assets/{index-BahRsj0s.js → index-BvFx-4KW.js} +1 -1
- package/ui-dist/assets/{index-DKlsEDV7.js → index-C1mpWCcn.js} +1 -1
- package/ui-dist/assets/{index-DIXaXZrW.js → index-CHXG4GPL.js} +1 -1
- package/ui-dist/assets/{index-DcfO6NoY.js → index-CLu3Tfpv.js} +1 -1
- package/ui-dist/assets/{index-Cfz6P0Qq.js → index-CQhaM6qd.js} +1 -1
- package/ui-dist/assets/{index-D9d3sl6K.js → index-CVTL3aAL.js} +1 -1
- package/ui-dist/assets/{index-BexA3NAl.js → index-CcRTJ93J.js} +1 -1
- package/ui-dist/assets/{index-CnBrDwGT.js → index-Cm6iEYPt.js} +247 -223
- package/ui-dist/assets/{index-ByGcu-MB.js → index-DQIrrn2L.js} +1 -1
- package/ui-dist/assets/{index-DKEzHO7c.js → index-DU4U1fuv.js} +1 -1
- package/ui-dist/assets/{index-pqJO8FsV.js → index-DV2FOgyD.js} +1 -1
- package/ui-dist/assets/{index-tDuieU3u.js → index-DceuCrr8.js} +1 -1
- package/ui-dist/assets/{index-CjN2lm_w.js → index-DlVBBF8o.js} +1 -1
- package/ui-dist/assets/{index-0yJtUsFh.js → index-DsdVneqe.js} +1 -1
- package/ui-dist/assets/{index-C2a4_IKU.js → index-DvFUXI-Q.js} +1 -1
- package/ui-dist/assets/{index-BTMiDMYv.js → index-FHzrJ7iQ.js} +1 -1
- package/ui-dist/assets/{index-cUQXJtLK.js → index-diFs0CNC.js} +1 -1
- package/ui-dist/assets/{index-C96LHV1A.js → index-hkanj4t6.js} +1 -1
- package/ui-dist/assets/{index-rDI1Q4r9.js → index-yRJnh4vx.js} +1 -1
- package/ui-dist/assets/{infoDiagram-FWYZ7A6U-CHIhutSb.js → infoDiagram-6WML65LV-2jai2fw-.js} +1 -1
- package/ui-dist/assets/{ishikawaDiagram-FXEZZL3T-ChaD7-5_.js → ishikawaDiagram-WSZJBQD7-CUmGhmEb.js} +1 -1
- package/ui-dist/assets/{journeyDiagram-5HDEW3XC-M5T2sMgn.js → journeyDiagram-NVQOT4AX-CGQuPDO1.js} +1 -1
- package/ui-dist/assets/{kanban-definition-HUTT4EX6-B8H6R_Wc.js → kanban-definition-27J2QSJJ-zgEPcbHl.js} +1 -1
- package/ui-dist/assets/{linear-cpPKlVCq.js → linear-BVf5VtLS.js} +1 -1
- package/ui-dist/assets/mermaid.core-D2TFkGfI.js +308 -0
- package/ui-dist/assets/{mindmap-definition-LN4V7U3C-Bf6POzvm.js → mindmap-definition-FAOFIHXS-xoAQ0Lb_.js} +1 -1
- package/ui-dist/assets/{pegDiagram-2B236MQR-BJbGktAV.js → pegDiagram-VL7TDLO6-CBR1PWLr.js} +1 -1
- package/ui-dist/assets/{pieDiagram-ENE6RG2P-BmSN9Bio.js → pieDiagram-7S7Q4E2Y-BMPnzK_e.js} +1 -1
- package/ui-dist/assets/{quadrantDiagram-ABIIQ3AL-JUpqQCx_.js → quadrantDiagram-CIZ2JOQS-CPtVUPkR.js} +1 -1
- package/ui-dist/assets/{railroadDiagram-RFXS5EU6-B8k8T4-2.js → railroadDiagram-AXF67PYL-DC0lsZV1.js} +1 -1
- package/ui-dist/assets/{requirementDiagram-TGXJPOKE-b32heq7Q.js → requirementDiagram-LRYGKXZP-BF6k05SB.js} +1 -1
- package/ui-dist/assets/{sankeyDiagram-HTMAVEWB-DC3s-xgf.js → sankeyDiagram-W5VNT64P-CRN0ibiP.js} +1 -1
- package/ui-dist/assets/{sequenceDiagram-DBY2YBRQ-BsCFyv_E.js → sequenceDiagram-SI44F4Z6-CQtTiPWm.js} +1 -1
- package/ui-dist/assets/{sizeCapture-X5ZJPWSS-S7xeCud3.js → sizeCapture-X5ZJPWSS-BdjuGZd1.js} +1 -1
- package/ui-dist/assets/{stateDiagram-2N3HPSRC-58O3ofoS.js → stateDiagram-OKZ733FA-B99onxyh.js} +1 -1
- package/ui-dist/assets/stateDiagram-v2-UEYNNEHI-bzFlYBZ1.js +1 -0
- package/ui-dist/assets/{swimlanes-5IMT3BWC-DFCVG0iF.js → swimlanes-SLNWSIFB-BIByPbrL.js} +2 -2
- package/ui-dist/assets/swimlanesDiagram-ULZ7WXOC-DgdkBnuK.js +8 -0
- package/ui-dist/assets/{timeline-definition-FHXFAJF6-C0c0SkQu.js → timeline-definition-Z64GVDOM-I1NgOcn9.js} +1 -1
- package/ui-dist/assets/{vennDiagram-L72KCM5P-B1cEx8GQ.js → vennDiagram-T6HMQDX7-D6t2Njcl.js} +1 -1
- package/ui-dist/assets/{wardleyDiagram-EHGQE667-BQhDkOx9.js → wardleyDiagram-T6FBY63Y-BiYKm9gh.js} +1 -1
- package/ui-dist/assets/{xychartDiagram-FW5EYKEG-YSQp1N0i.js → xychartDiagram-ELKLHX3M-BhwXKWIa.js} +4 -4
- package/ui-dist/index.html +3 -3
- package/ui-dist/sw.js +8 -3
- package/ui-dist/assets/architectureDiagram-ZJ3FMSHR-BcJt8Ia1.js +0 -36
- package/ui-dist/assets/channel-ZRpjyNiZ.js +0 -1
- package/ui-dist/assets/classDiagram-OUVF2IWQ-CCzzzCgk.js +0 -1
- package/ui-dist/assets/classDiagram-v2-EOCWNBFH-CCzzzCgk.js +0 -1
- package/ui-dist/assets/cytoscape.esm-D8joxN9f.js +0 -321
- package/ui-dist/assets/diagram-WEI45ONY-M3WOC2tK.js +0 -41
- package/ui-dist/assets/index-BqfqfoQX.css +0 -1
- package/ui-dist/assets/mermaid.core-MCazc_od.js +0 -308
- package/ui-dist/assets/stateDiagram-v2-6OUMAXLB-_VY5dpDG.js +0 -1
- package/ui-dist/assets/swimlanesDiagram-G3AALYLV-BJhUjfns.js +0 -8
|
@@ -3,23 +3,120 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|
6
|
+
import { and, asc, desc, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
|
7
7
|
import { executionWorkspaces, heartbeatRuns, issueWorkProducts, issues, projects, projectWorkspaces, workspaceRuntimeServices, } from "@paperclipai/db";
|
|
8
8
|
import { deriveProjectUrlKey, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT } from "@paperclipai/shared";
|
|
9
9
|
import { conflict, notFound, unprocessable } from "../errors.js";
|
|
10
|
+
import { logger } from "../middleware/logger.js";
|
|
10
11
|
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy, parseIssueExecutionState, } from "./issue-execution-policy.js";
|
|
11
12
|
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
|
12
13
|
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
|
|
13
14
|
import { logActivity } from "./activity-log.js";
|
|
14
15
|
import { createPullRequestMergeDetailsResolver, extractGitHubPullRequestReferences, setBoundedPullRequestCacheEntry, } from "./github-pull-request-merge.js";
|
|
15
16
|
import { visibleIssueCondition } from "./issue-visibility.js";
|
|
17
|
+
import { createGitRemoteAuthProvider } from "./git-credentials.js";
|
|
16
18
|
import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
|
|
19
|
+
import { workspaceGitOperationScheduler } from "./workspace-git-operation-scheduler.js";
|
|
17
20
|
import { listCurrentRuntimeServicesForExecutionWorkspaces, listCurrentRuntimeServicesForProjectWorkspaces, selectConfiguredRuntimeServiceRows, } from "./workspace-runtime-read-model.js";
|
|
18
21
|
const execFileAsync = promisify(execFile);
|
|
19
22
|
const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
|
|
20
23
|
const WORKSPACE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence";
|
|
21
24
|
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
|
|
22
25
|
export const ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON = "issue_terminal";
|
|
26
|
+
// The reopen-failure reason kept on the row when a rebuild does not finish. The
|
|
27
|
+
// value is sanitized: it never contains a repository URL, a host path, or git
|
|
28
|
+
// output.
|
|
29
|
+
export const EXECUTION_WORKSPACE_REOPEN_FAILED_REASON = "reopen_failed";
|
|
30
|
+
// How long the terminal reaper waits before it reclaims a stranded reopen-pending
|
|
31
|
+
// flag. A reopen sets the flag while the source issue is still terminal. The
|
|
32
|
+
// consuming request clears the flag within seconds when the request ends. If the
|
|
33
|
+
// server exits first, or every clear retry fails, the flag stays set and both the
|
|
34
|
+
// reaper and the archive route skip the workspace forever. After this grace the
|
|
35
|
+
// reaper reclaims the flag, but only when no run still owns it. The reaper checks
|
|
36
|
+
// for a live consuming run first, so a request that outruns this grace keeps its
|
|
37
|
+
// fence. A run has a heartbeat row the reaper can see. An HTTP consuming request
|
|
38
|
+
// has none, so the route re-stamps the flag on an interval below this grace, and
|
|
39
|
+
// the fresh timestamp keeps the fence. The grace is a backstop for a flag whose
|
|
40
|
+
// consumer is gone, not a hard deadline on the request.
|
|
41
|
+
export const STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS = 5 * 60 * 1000;
|
|
42
|
+
// The metadata key that holds the workspace lifecycle generation. The generation
|
|
43
|
+
// is a monotonic integer. Every archive and every reopen increases it by one. A
|
|
44
|
+
// destructive cleanup captures the generation it archived at, then re-reads the
|
|
45
|
+
// generation under the lifecycle lock immediately before it deletes the worktree.
|
|
46
|
+
// A reopen that ran in between raises the generation, so the stale cleanup finds
|
|
47
|
+
// a mismatch and does nothing. This fences a queued or in-flight cleanup against
|
|
48
|
+
// a workspace that a reopen already restored.
|
|
49
|
+
export const EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY = "lifecycleGeneration";
|
|
50
|
+
export function readExecutionWorkspaceLifecycleGeneration(metadata) {
|
|
51
|
+
const raw = metadata?.[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY];
|
|
52
|
+
return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : 0;
|
|
53
|
+
}
|
|
54
|
+
// Return a metadata object with the lifecycle generation increased by one. The
|
|
55
|
+
// caller keeps every other metadata key.
|
|
56
|
+
export function bumpExecutionWorkspaceLifecycleGeneration(metadata) {
|
|
57
|
+
return {
|
|
58
|
+
...(metadata ?? {}),
|
|
59
|
+
[EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: readExecutionWorkspaceLifecycleGeneration(metadata) + 1,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function isClosedExecutionWorkspaceStatus(status) {
|
|
63
|
+
return status === "archived" || status === "cleanup_failed";
|
|
64
|
+
}
|
|
65
|
+
// The metadata key that marks a workspace as reopened for a source issue that is
|
|
66
|
+
// still terminal. A reopen sets this flag in the same write that publishes the
|
|
67
|
+
// row as active. The source issue is still terminal at that moment, because the
|
|
68
|
+
// route changes the issue out of the terminal state only after the reopen
|
|
69
|
+
// returns. Both destructive paths (the terminal reaper and the archive route)
|
|
70
|
+
// exclude a flagged workspace, so neither one archives and destroys a worktree
|
|
71
|
+
// that a reopen rebuilt while the caller has not yet consumed it. The reaper
|
|
72
|
+
// clears the flag on a later pass once the source issue leaves the terminal
|
|
73
|
+
// state, so a normal terminal cycle can reap the workspace again.
|
|
74
|
+
export const EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY = "reopenPendingConsumption";
|
|
75
|
+
// The metadata key that holds the time a reopen set the reopen-pending flag. The
|
|
76
|
+
// terminal reaper reads this timestamp to tell a fresh, in-flight reopen from a
|
|
77
|
+
// stranded flag whose consumer never cleared it. A reopen writes this key in the
|
|
78
|
+
// same write that sets the flag, and every clear removes both keys together.
|
|
79
|
+
export const EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY = "reopenPendingConsumptionSince";
|
|
80
|
+
export function metadataHasReopenPendingConsumption(metadata) {
|
|
81
|
+
return metadata?.[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY] === true;
|
|
82
|
+
}
|
|
83
|
+
// Read the time a reopen set the reopen-pending flag. Return null when the flag
|
|
84
|
+
// carries no valid timestamp. The terminal reaper uses this to tell a fresh,
|
|
85
|
+
// in-flight reopen from a stranded flag whose consumer never cleared it.
|
|
86
|
+
export function readMetadataReopenPendingConsumptionSince(metadata) {
|
|
87
|
+
const raw = metadata?.[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY];
|
|
88
|
+
if (typeof raw !== "string")
|
|
89
|
+
return null;
|
|
90
|
+
const parsed = new Date(raw);
|
|
91
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
92
|
+
}
|
|
93
|
+
// Return a metadata object with the reopen-pending flag set. The caller keeps
|
|
94
|
+
// every other metadata key. The `at` timestamp records when the reopen set the
|
|
95
|
+
// flag, so the terminal reaper can reclaim a stranded flag after a grace period.
|
|
96
|
+
export function setMetadataReopenPendingConsumption(metadata, at) {
|
|
97
|
+
return {
|
|
98
|
+
...(metadata ?? {}),
|
|
99
|
+
[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY]: true,
|
|
100
|
+
[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY]: at.toISOString(),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Return a metadata object with the reopen-pending flag removed. The caller
|
|
104
|
+
// keeps every other metadata key. The function removes the flag and its
|
|
105
|
+
// timestamp together, so no orphan timestamp survives a clear.
|
|
106
|
+
export function clearMetadataReopenPendingConsumption(metadata) {
|
|
107
|
+
const next = { ...(metadata ?? {}) };
|
|
108
|
+
delete next[EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY];
|
|
109
|
+
delete next[EXECUTION_WORKSPACE_REOPEN_PENDING_SINCE_METADATA_KEY];
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
// Acquire the per-workspace, transaction-scoped Postgres advisory lock. Postgres
|
|
113
|
+
// releases the lock when the transaction that holds `tx` commits or rolls back.
|
|
114
|
+
// Both the reopen path and the destructive cleanup path acquire the same lock,
|
|
115
|
+
// so they never run against the same workspace at the same time, even on
|
|
116
|
+
// different server processes.
|
|
117
|
+
async function acquireExecutionWorkspaceLifecycleLock(tx, workspaceId) {
|
|
118
|
+
await tx.execute(sql `select pg_advisory_xact_lock(hashtextextended(${`execution_workspace_lifecycle:${workspaceId}`}, 0))`);
|
|
119
|
+
}
|
|
23
120
|
function parseGitHubRepository(repoUrl) {
|
|
24
121
|
if (!repoUrl)
|
|
25
122
|
return null;
|
|
@@ -102,6 +199,15 @@ async function pathExists(value) {
|
|
|
102
199
|
async function runGit(args, cwd) {
|
|
103
200
|
return await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
|
104
201
|
}
|
|
202
|
+
async function runExpensiveGitStatus(input) {
|
|
203
|
+
return workspaceGitOperationScheduler.run({
|
|
204
|
+
workspacePath: input.cwd,
|
|
205
|
+
args: input.args,
|
|
206
|
+
operation: input.operation,
|
|
207
|
+
fairnessKeys: input.fairnessKeys,
|
|
208
|
+
cacheTtlMs: 0,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
105
211
|
async function readGitStdout(args, cwd) {
|
|
106
212
|
const output = await runGit(args, cwd);
|
|
107
213
|
return output.stdout.trim() || null;
|
|
@@ -197,7 +303,15 @@ async function inspectExecutionWorkspaceBranchForReconcile(workspace) {
|
|
|
197
303
|
if (!toBranch) {
|
|
198
304
|
throw unprocessable("Execution workspace is detached; Paperclip cannot reconcile it to a branch name");
|
|
199
305
|
}
|
|
200
|
-
const status = await
|
|
306
|
+
const status = await runExpensiveGitStatus({
|
|
307
|
+
args: ["status", "--porcelain", "--untracked-files=all"],
|
|
308
|
+
cwd: worktreePath,
|
|
309
|
+
operation: "execution_workspaces.branch_reconcile_status",
|
|
310
|
+
fairnessKeys: [
|
|
311
|
+
`workspace:${workspace.id}`,
|
|
312
|
+
...(workspace.sourceIssueId ? [`issue:${workspace.sourceIssueId}`] : []),
|
|
313
|
+
],
|
|
314
|
+
})
|
|
201
315
|
.then((output) => output.stdout)
|
|
202
316
|
.catch(() => null);
|
|
203
317
|
const statusLines = status === null
|
|
@@ -395,11 +509,11 @@ async function inspectGitCloseReadiness(workspace) {
|
|
|
395
509
|
const expectsGitInspection = workspace.providerType === "git_worktree" ||
|
|
396
510
|
Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath);
|
|
397
511
|
if (!expectsGitInspection) {
|
|
398
|
-
return { git: null, warnings };
|
|
512
|
+
return { git: null, warnings, statusInspectionSucceeded: true };
|
|
399
513
|
}
|
|
400
514
|
if (!workspacePath) {
|
|
401
515
|
warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close.");
|
|
402
|
-
return { git: null, warnings };
|
|
516
|
+
return { git: null, warnings, statusInspectionSucceeded: false };
|
|
403
517
|
}
|
|
404
518
|
if (!(await pathExists(workspacePath))) {
|
|
405
519
|
warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`);
|
|
@@ -419,6 +533,7 @@ async function inspectGitCloseReadiness(workspace) {
|
|
|
419
533
|
createdByRuntime,
|
|
420
534
|
},
|
|
421
535
|
warnings,
|
|
536
|
+
statusInspectionSucceeded: true,
|
|
422
537
|
};
|
|
423
538
|
}
|
|
424
539
|
let repoRoot = null;
|
|
@@ -439,9 +554,19 @@ async function inspectGitCloseReadiness(workspace) {
|
|
|
439
554
|
}
|
|
440
555
|
let dirtyEntryCount = 0;
|
|
441
556
|
let untrackedEntryCount = 0;
|
|
557
|
+
let statusInspectionSucceeded = false;
|
|
442
558
|
if (repoRoot) {
|
|
443
559
|
try {
|
|
444
|
-
const statusOutput = (await
|
|
560
|
+
const statusOutput = (await runExpensiveGitStatus({
|
|
561
|
+
args: ["status", "--porcelain=v1", "--untracked-files=all"],
|
|
562
|
+
cwd: workspacePath,
|
|
563
|
+
operation: "execution_workspaces.close_readiness_status",
|
|
564
|
+
fairnessKeys: [
|
|
565
|
+
`company:${workspace.companyId}`,
|
|
566
|
+
`workspace:${workspace.id}`,
|
|
567
|
+
...(workspace.sourceIssueId ? [`issue:${workspace.sourceIssueId}`] : []),
|
|
568
|
+
],
|
|
569
|
+
})).stdout;
|
|
445
570
|
for (const line of statusOutput.split(/\r?\n/)) {
|
|
446
571
|
if (!line)
|
|
447
572
|
continue;
|
|
@@ -451,6 +576,7 @@ async function inspectGitCloseReadiness(workspace) {
|
|
|
451
576
|
}
|
|
452
577
|
dirtyEntryCount += 1;
|
|
453
578
|
}
|
|
579
|
+
statusInspectionSucceeded = true;
|
|
454
580
|
}
|
|
455
581
|
catch (error) {
|
|
456
582
|
warnings.push(`Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -499,6 +625,7 @@ async function inspectGitCloseReadiness(workspace) {
|
|
|
499
625
|
createdByRuntime,
|
|
500
626
|
},
|
|
501
627
|
warnings,
|
|
628
|
+
statusInspectionSucceeded,
|
|
502
629
|
};
|
|
503
630
|
}
|
|
504
631
|
export function readExecutionWorkspaceConfig(metadata) {
|
|
@@ -605,6 +732,7 @@ function toRuntimeService(row) {
|
|
|
605
732
|
stoppedAt: row.stoppedAt ?? null,
|
|
606
733
|
stopPolicy: row.stopPolicy ?? null,
|
|
607
734
|
healthStatus: row.healthStatus,
|
|
735
|
+
exposure: row.exposure ?? null,
|
|
608
736
|
configIndex: row.configIndex ?? null,
|
|
609
737
|
createdAt: row.createdAt,
|
|
610
738
|
updatedAt: row.updatedAt,
|
|
@@ -674,6 +802,7 @@ function toWorkspaceOverviewPrimaryService(service) {
|
|
|
674
802
|
url: service.url,
|
|
675
803
|
port: service.port,
|
|
676
804
|
healthStatus: service.healthStatus,
|
|
805
|
+
exposure: service.exposure ?? null,
|
|
677
806
|
updatedAt: service.updatedAt,
|
|
678
807
|
};
|
|
679
808
|
}
|
|
@@ -744,6 +873,28 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
744
873
|
const now = opts.now ?? (() => new Date());
|
|
745
874
|
const pullRequestStateCache = new Map();
|
|
746
875
|
const pullRequestStateCacheTtlMs = 5 * 60 * 1000;
|
|
876
|
+
// The terminal-workspace reaper scans the candidate set in fixed-size pages.
|
|
877
|
+
// It keeps this keyset cursor between sweeps so each sweep continues after the
|
|
878
|
+
// previous page. A skipped candidate keeps its updatedAt, so a cursor is
|
|
879
|
+
// required: without it the query re-selects the oldest ineligible rows every
|
|
880
|
+
// sweep and starves eligible rows behind them. The cursor resets to the start
|
|
881
|
+
// when a sweep reaches the end of the candidate set.
|
|
882
|
+
let terminalSweepCursor = null;
|
|
883
|
+
// The reaper freezes an upper bound on updatedAt at the start of each
|
|
884
|
+
// rotation. The scan only reads candidates at or below the bound, so a steady
|
|
885
|
+
// stream of newer candidates cannot keep every page full and stop the cursor
|
|
886
|
+
// from ever reaching the end. A frozen set is finite, so the cursor always
|
|
887
|
+
// reaches a short page and resets, and older candidates that became eligible
|
|
888
|
+
// are revisited on the next rotation. The next rotation captures a new bound,
|
|
889
|
+
// so candidates updated after the previous bound enter the scan then.
|
|
890
|
+
let terminalSweepBoundary = null;
|
|
891
|
+
// The scheduler starts a sweep on each tick and does not wait for the previous
|
|
892
|
+
// sweep to finish. A sweep that outlasts the tick interval overlaps the next
|
|
893
|
+
// sweep. Both sweeps share the cursor and the boundary above. Interleaved
|
|
894
|
+
// reads and writes can leave a non-null cursor with a null boundary, which
|
|
895
|
+
// removes the upper bound and makes the scan chase newer churn again. This
|
|
896
|
+
// flag lets only one sweep run at a time, so one sweep owns the shared state.
|
|
897
|
+
let terminalSweepInProgress = false;
|
|
747
898
|
async function listWorkspaceIssueTree(workspace) {
|
|
748
899
|
if (!workspace.sourceIssueId)
|
|
749
900
|
return [];
|
|
@@ -865,6 +1016,9 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
865
1016
|
readGitStdout(["rev-parse", "HEAD"], workspacePath).catch(() => null),
|
|
866
1017
|
readGitStdout(["symbolic-ref", "--quiet", "--short", "HEAD"], workspacePath).catch(() => null),
|
|
867
1018
|
]);
|
|
1019
|
+
if (!current.statusInspectionSucceeded) {
|
|
1020
|
+
throw new Error("Refusing terminal workspace cleanup because the git status could not be verified");
|
|
1021
|
+
}
|
|
868
1022
|
if (!current.git?.repoRoot
|
|
869
1023
|
|| current.git.hasDirtyTrackedFiles
|
|
870
1024
|
|| current.git.hasUntrackedFiles
|
|
@@ -900,7 +1054,141 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
900
1054
|
.limit(1);
|
|
901
1055
|
return active.length > 0;
|
|
902
1056
|
}
|
|
903
|
-
|
|
1057
|
+
// The single generation-fenced gateway for a terminal-workspace write. It owns
|
|
1058
|
+
// the whole guarded step. It acquires the per-workspace lifecycle lock, it
|
|
1059
|
+
// re-reads the fresh row, and it compares the current lifecycle generation to
|
|
1060
|
+
// the generation the caller captured. It runs the caller's write body only
|
|
1061
|
+
// when the current generation still equals `expectedGeneration` and the fresh
|
|
1062
|
+
// row still passes the caller's `isWriteTarget` guard. Otherwise it emits the
|
|
1063
|
+
// optional skip log and returns the caller's skip value. Every fenced
|
|
1064
|
+
// terminal-workspace write routes through this function, so no other function
|
|
1065
|
+
// re-derives the lock, the fresh read, or the generation compare.
|
|
1066
|
+
async function fenceLifecycleGenerationWrite(input) {
|
|
1067
|
+
return db.transaction(async (tx) => {
|
|
1068
|
+
await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId);
|
|
1069
|
+
const row = await tx
|
|
1070
|
+
.select({ status: executionWorkspaces.status, metadata: executionWorkspaces.metadata })
|
|
1071
|
+
.from(executionWorkspaces)
|
|
1072
|
+
.where(eq(executionWorkspaces.id, input.workspaceId))
|
|
1073
|
+
.then((rows) => rows[0] ?? null);
|
|
1074
|
+
const fresh = row
|
|
1075
|
+
? { status: row.status, metadata: row.metadata }
|
|
1076
|
+
: null;
|
|
1077
|
+
const currentGeneration = fresh ? readExecutionWorkspaceLifecycleGeneration(fresh.metadata) : null;
|
|
1078
|
+
if (!fresh
|
|
1079
|
+
|| currentGeneration !== input.expectedGeneration
|
|
1080
|
+
|| !input.isWriteTarget(fresh)) {
|
|
1081
|
+
if (input.skipLog) {
|
|
1082
|
+
logger.info({
|
|
1083
|
+
event: input.skipLog.event,
|
|
1084
|
+
reason: "reopened",
|
|
1085
|
+
executionWorkspaceId: input.workspaceId,
|
|
1086
|
+
capturedGeneration: input.expectedGeneration,
|
|
1087
|
+
currentGeneration,
|
|
1088
|
+
currentStatus: fresh?.status ?? null,
|
|
1089
|
+
}, input.skipLog.message);
|
|
1090
|
+
}
|
|
1091
|
+
return input.onSkip();
|
|
1092
|
+
}
|
|
1093
|
+
return input.write({ tx, fresh });
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
// Clear the reopen-pending flag through the lifecycle gateway. Every caller
|
|
1097
|
+
// presents the lifecycle generation that owns the fence it wants to clear. The
|
|
1098
|
+
// gateway removes the flag only when the current generation still equals
|
|
1099
|
+
// `expectedGeneration`. A newer reopen raises the generation and re-sets the
|
|
1100
|
+
// flag, so its fence has a different owner. This check stops a stale actor (a
|
|
1101
|
+
// delayed response cleanup, a retry, or an aged reaper snapshot) from clearing
|
|
1102
|
+
// a newer reopen's live fence.
|
|
1103
|
+
//
|
|
1104
|
+
// A caller that reclaims a stranded flag passes `requireStaleSinceBefore`. The
|
|
1105
|
+
// write-target guard then re-reads the flag timestamp from the fresh row and
|
|
1106
|
+
// clears the flag only when that timestamp is still older than the cutoff. This
|
|
1107
|
+
// re-confirms the strand decision against the live row instead of an aged
|
|
1108
|
+
// snapshot.
|
|
1109
|
+
async function clearReopenPendingConsumptionUnderLock(workspaceId, options) {
|
|
1110
|
+
return fenceLifecycleGenerationWrite({
|
|
1111
|
+
workspaceId,
|
|
1112
|
+
expectedGeneration: options.expectedGeneration,
|
|
1113
|
+
isWriteTarget: (fresh) => {
|
|
1114
|
+
if (!metadataHasReopenPendingConsumption(fresh.metadata))
|
|
1115
|
+
return false;
|
|
1116
|
+
if (options.requireStaleSinceBefore) {
|
|
1117
|
+
const freshSince = readMetadataReopenPendingConsumptionSince(fresh.metadata);
|
|
1118
|
+
const stillStale = freshSince === null
|
|
1119
|
+
|| freshSince.getTime() <= options.requireStaleSinceBefore.getTime();
|
|
1120
|
+
// The live row shows a fresh flag, so the consumer is still in flight.
|
|
1121
|
+
if (!stillStale)
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
return true;
|
|
1125
|
+
},
|
|
1126
|
+
onSkip: () => false,
|
|
1127
|
+
write: async ({ tx, fresh }) => {
|
|
1128
|
+
await tx
|
|
1129
|
+
.update(executionWorkspaces)
|
|
1130
|
+
.set({
|
|
1131
|
+
metadata: clearMetadataReopenPendingConsumption(fresh.metadata),
|
|
1132
|
+
updatedAt: new Date(),
|
|
1133
|
+
})
|
|
1134
|
+
.where(eq(executionWorkspaces.id, workspaceId));
|
|
1135
|
+
return true;
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
// Re-stamp the reopen-pending timestamp for a workspace whose consuming request
|
|
1140
|
+
// is still in flight. The request that reopens and consumes the worktree is an
|
|
1141
|
+
// HTTP request, not a heartbeat run, so `workspaceHasActiveRun` cannot see it.
|
|
1142
|
+
// Without a refresh, a request that runs longer than the stale grace period lets
|
|
1143
|
+
// the terminal reaper clear the live fence, and a later sweep archives and
|
|
1144
|
+
// destroys the worktree under the request. The consuming route calls this on an
|
|
1145
|
+
// interval shorter than the grace period, so the flag never looks stale while the
|
|
1146
|
+
// request lives. The refresh runs under the lifecycle lock and re-stamps the
|
|
1147
|
+
// timestamp only while the flag is still set and the current generation still
|
|
1148
|
+
// equals `expectedGeneration`, so it never revives a cleared flag and never
|
|
1149
|
+
// refreshes a newer reopen's fence. It returns true when it re-stamped the flag.
|
|
1150
|
+
async function refreshReopenPendingConsumptionUnderLock(workspaceId, options) {
|
|
1151
|
+
return fenceLifecycleGenerationWrite({
|
|
1152
|
+
workspaceId,
|
|
1153
|
+
expectedGeneration: options.expectedGeneration,
|
|
1154
|
+
// A newer reopen or an archive raised the generation. The gateway then
|
|
1155
|
+
// skips, so this refresh never re-stamps another owner's fence.
|
|
1156
|
+
isWriteTarget: (fresh) => metadataHasReopenPendingConsumption(fresh.metadata),
|
|
1157
|
+
onSkip: () => false,
|
|
1158
|
+
write: async ({ tx, fresh }) => {
|
|
1159
|
+
await tx
|
|
1160
|
+
.update(executionWorkspaces)
|
|
1161
|
+
.set({
|
|
1162
|
+
metadata: setMetadataReopenPendingConsumption(fresh.metadata, now()),
|
|
1163
|
+
updatedAt: new Date(),
|
|
1164
|
+
})
|
|
1165
|
+
.where(eq(executionWorkspaces.id, workspaceId));
|
|
1166
|
+
return true;
|
|
1167
|
+
},
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
async function cleanupTerminalWorkspace(workspace, expectedHeadSha, capturedGeneration) {
|
|
1171
|
+
// The gateway holds the per-workspace lifecycle lock across the destructive
|
|
1172
|
+
// actions. A reopen takes the same lock, so a reopen cannot rebuild the
|
|
1173
|
+
// worktree while this cleanup runs, and this cleanup cannot delete a worktree
|
|
1174
|
+
// that a reopen already restored. The advisory lock (not a row FOR UPDATE)
|
|
1175
|
+
// gives the exclusion, so the cleanup body can still update the same row on
|
|
1176
|
+
// the pooled connection without a self-block. A reopen restored this
|
|
1177
|
+
// workspace after it was archived when the guard fails, so the cleanup skips
|
|
1178
|
+
// and does not destroy the rebuilt worktree.
|
|
1179
|
+
return fenceLifecycleGenerationWrite({
|
|
1180
|
+
workspaceId: workspace.id,
|
|
1181
|
+
expectedGeneration: capturedGeneration,
|
|
1182
|
+
isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
|
|
1183
|
+
skipLog: {
|
|
1184
|
+
event: "execution_workspace.cleanup_skipped",
|
|
1185
|
+
message: "execution workspace cleanup skipped because it was reopened",
|
|
1186
|
+
},
|
|
1187
|
+
onSkip: () => ({ cleaned: false, warnings: [], skippedReopened: true }),
|
|
1188
|
+
write: () => runTerminalWorkspaceCleanup(workspace, expectedHeadSha),
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
async function runTerminalWorkspaceCleanup(workspace, expectedHeadSha) {
|
|
904
1192
|
const [{ acquireGitWorktreeCleanupLock, cleanupExecutionWorkspaceArtifacts, stopRuntimeServicesForExecutionWorkspace, }, { workspaceOperationService },] = await Promise.all([
|
|
905
1193
|
import("./workspace-runtime.js"),
|
|
906
1194
|
import("./workspace-operations.js"),
|
|
@@ -974,6 +1262,41 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
974
1262
|
await cleanupLock?.release();
|
|
975
1263
|
}
|
|
976
1264
|
}
|
|
1265
|
+
// Write the cleanup-failed status under the per-workspace lifecycle lock, but
|
|
1266
|
+
// only while the row is still closed at the generation the reaper captured. The
|
|
1267
|
+
// reaper calls this from its catch handler after a cleanup threw. The cleanup
|
|
1268
|
+
// transaction already rolled back and released the lock, so a reopen can run in
|
|
1269
|
+
// the window before this write. A reopen raises the generation and restores the
|
|
1270
|
+
// row to active. A later archive lowers the row back to closed but keeps the
|
|
1271
|
+
// higher generation. The generation compare then skips this write, so stale
|
|
1272
|
+
// cleanup-failure state never lands on a newer archive lifecycle. The function
|
|
1273
|
+
// returns true when it wrote the status, or false when the fence skipped it.
|
|
1274
|
+
async function markTerminalCleanupFailedFenced(input) {
|
|
1275
|
+
// A reopen restored the row after the cleanup threw when the guard fails. The
|
|
1276
|
+
// gateway then skips, so the stale cleanup-failure status never overwrites the
|
|
1277
|
+
// newer lifecycle state.
|
|
1278
|
+
return fenceLifecycleGenerationWrite({
|
|
1279
|
+
workspaceId: input.workspaceId,
|
|
1280
|
+
expectedGeneration: input.capturedGeneration,
|
|
1281
|
+
isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
|
|
1282
|
+
skipLog: {
|
|
1283
|
+
event: "execution_workspace.cleanup_failed_write_skipped",
|
|
1284
|
+
message: "execution workspace cleanup-failure write skipped because it was reopened",
|
|
1285
|
+
},
|
|
1286
|
+
onSkip: () => false,
|
|
1287
|
+
write: async ({ tx }) => {
|
|
1288
|
+
await tx
|
|
1289
|
+
.update(executionWorkspaces)
|
|
1290
|
+
.set({
|
|
1291
|
+
status: "cleanup_failed",
|
|
1292
|
+
cleanupReason: input.cleanupReason,
|
|
1293
|
+
updatedAt: now(),
|
|
1294
|
+
})
|
|
1295
|
+
.where(eq(executionWorkspaces.id, input.workspaceId));
|
|
1296
|
+
return true;
|
|
1297
|
+
},
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
977
1300
|
function buildListConditions(companyId, filters) {
|
|
978
1301
|
const conditions = [eq(executionWorkspaces.companyId, companyId)];
|
|
979
1302
|
if (filters?.projectId)
|
|
@@ -1352,10 +1675,13 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
1352
1675
|
: null;
|
|
1353
1676
|
const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices);
|
|
1354
1677
|
const config = readExecutionWorkspaceConfig(workspace.metadata ?? null);
|
|
1355
|
-
const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace);
|
|
1678
|
+
const { git, warnings: gitWarnings, statusInspectionSucceeded, } = await inspectGitCloseReadiness(executionWorkspace);
|
|
1356
1679
|
const { deliveryState } = await assessDelivery(workspace, git);
|
|
1357
1680
|
const warnings = [...gitWarnings];
|
|
1358
1681
|
const blockingReasons = [];
|
|
1682
|
+
if (!statusInspectionSucceeded) {
|
|
1683
|
+
blockingReasons.push("Paperclip could not verify the workspace git status. Retry before destructive cleanup.");
|
|
1684
|
+
}
|
|
1359
1685
|
const isSharedWorkspace = executionWorkspace.mode === "shared_workspace";
|
|
1360
1686
|
const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd);
|
|
1361
1687
|
const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null;
|
|
@@ -1514,137 +1840,275 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
1514
1840
|
};
|
|
1515
1841
|
},
|
|
1516
1842
|
sweepTerminalWorkspaces: async (limit = 50) => {
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1843
|
+
// Skip this sweep while another sweep runs. A concurrent sweep would share
|
|
1844
|
+
// the cursor and the boundary and could corrupt the rotation state. A
|
|
1845
|
+
// skipped tick is safe: the next tick runs the sweep with intact state.
|
|
1846
|
+
if (terminalSweepInProgress) {
|
|
1847
|
+
return {
|
|
1848
|
+
checked: 0,
|
|
1849
|
+
eligible: 0,
|
|
1850
|
+
archived: 0,
|
|
1851
|
+
cleanupFailed: 0,
|
|
1852
|
+
skippedActiveRun: 0,
|
|
1853
|
+
skippedNonTerminalTree: 0,
|
|
1854
|
+
skippedUndelivered: 0,
|
|
1855
|
+
skippedRace: 0,
|
|
1856
|
+
skippedReopened: 0,
|
|
1857
|
+
clearedStaleReopenPending: 0,
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
terminalSweepInProgress = true;
|
|
1861
|
+
try {
|
|
1862
|
+
const baseCandidateFilter = and(inArray(executionWorkspaces.status, ["active", "idle", "in_review"]), isNull(executionWorkspaces.closedAt), sql `${executionWorkspaces.sourceIssueId} IS NOT NULL`);
|
|
1863
|
+
// Continue the scan after the previous sweep's last row. The keyset
|
|
1864
|
+
// predicate uses the same (updatedAt, id) order as the query, so each
|
|
1865
|
+
// sweep advances past the rows it already inspected instead of re-reading
|
|
1866
|
+
// the oldest ineligible candidates.
|
|
1867
|
+
const cursor = terminalSweepCursor;
|
|
1868
|
+
// Freeze an upper bound on updatedAt at the start of each rotation. Without
|
|
1869
|
+
// a bound, a steady stream of newer candidates keeps every page full, so
|
|
1870
|
+
// the cursor never reaches the end and never resets, and older candidates
|
|
1871
|
+
// that became eligible are never revisited. The frozen bound makes the
|
|
1872
|
+
// rotation cover a finite set, so the cursor always reaches a short page.
|
|
1873
|
+
if (!cursor) {
|
|
1874
|
+
terminalSweepBoundary = now();
|
|
1544
1875
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1876
|
+
const boundary = terminalSweepBoundary;
|
|
1877
|
+
const boundaryFilter = boundary
|
|
1878
|
+
? lte(executionWorkspaces.updatedAt, boundary)
|
|
1879
|
+
: undefined;
|
|
1880
|
+
const cursorFilter = cursor
|
|
1881
|
+
? or(gt(executionWorkspaces.updatedAt, cursor.updatedAt), and(eq(executionWorkspaces.updatedAt, cursor.updatedAt), gt(executionWorkspaces.id, cursor.id)))
|
|
1882
|
+
: undefined;
|
|
1883
|
+
const scanFilter = and(baseCandidateFilter, boundaryFilter, cursorFilter);
|
|
1884
|
+
const candidates = await db
|
|
1885
|
+
.select()
|
|
1886
|
+
.from(executionWorkspaces)
|
|
1887
|
+
.where(scanFilter)
|
|
1888
|
+
.orderBy(asc(executionWorkspaces.updatedAt), asc(executionWorkspaces.id))
|
|
1889
|
+
.limit(limit);
|
|
1890
|
+
// Advance the cursor to this page's last row. A short page means the scan
|
|
1891
|
+
// reached the end of the bounded candidate set, so reset the cursor and
|
|
1892
|
+
// the bound to start a new rotation on the next sweep.
|
|
1893
|
+
if (candidates.length < limit) {
|
|
1894
|
+
terminalSweepCursor = null;
|
|
1895
|
+
terminalSweepBoundary = null;
|
|
1549
1896
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1897
|
+
else {
|
|
1898
|
+
const lastCandidate = candidates[candidates.length - 1];
|
|
1899
|
+
terminalSweepCursor = { updatedAt: lastCandidate.updatedAt, id: lastCandidate.id };
|
|
1553
1900
|
}
|
|
1554
|
-
result
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1901
|
+
const result = {
|
|
1902
|
+
checked: candidates.length,
|
|
1903
|
+
eligible: 0,
|
|
1904
|
+
archived: 0,
|
|
1905
|
+
cleanupFailed: 0,
|
|
1906
|
+
skippedActiveRun: 0,
|
|
1907
|
+
skippedNonTerminalTree: 0,
|
|
1908
|
+
skippedUndelivered: 0,
|
|
1909
|
+
skippedRace: 0,
|
|
1910
|
+
skippedReopened: 0,
|
|
1911
|
+
clearedStaleReopenPending: 0,
|
|
1912
|
+
};
|
|
1913
|
+
for (const workspace of candidates) {
|
|
1914
|
+
const executionWorkspace = toExecutionWorkspace(workspace);
|
|
1915
|
+
const { git, statusInspectionSucceeded } = await inspectGitCloseReadiness(executionWorkspace);
|
|
1916
|
+
if (!statusInspectionSucceeded) {
|
|
1917
|
+
result.skippedUndelivered += 1;
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
const assessment = await assessDelivery(workspace, git);
|
|
1921
|
+
const reopenPending = metadataHasReopenPendingConsumption(workspace.metadata);
|
|
1922
|
+
if (!assessment.sourceIssueTerminal || !assessment.subtreeTerminal) {
|
|
1923
|
+
if (reopenPending) {
|
|
1924
|
+
// The source issue left the terminal state, so the reopen transition
|
|
1925
|
+
// committed. Clear the reopen-pending flag under the lifecycle lock so
|
|
1926
|
+
// a later terminal cycle can archive the workspace again. Pass the
|
|
1927
|
+
// generation from this snapshot, so the clear skips a newer reopen that
|
|
1928
|
+
// raised the generation and installed its own fence after this read.
|
|
1929
|
+
await clearReopenPendingConsumptionUnderLock(workspace.id, {
|
|
1930
|
+
expectedGeneration: readExecutionWorkspaceLifecycleGeneration(workspace.metadata),
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
result.skippedNonTerminalTree += 1;
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1936
|
+
if (assessment.workspaceDirty) {
|
|
1937
|
+
result.skippedUndelivered += 1;
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
if (assessment.deliveryState !== "merged_via_pr"
|
|
1941
|
+
&& assessment.deliveryState !== "merged_by_ancestry") {
|
|
1942
|
+
result.skippedUndelivered += 1;
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
if (reopenPending) {
|
|
1946
|
+
const pendingSince = readMetadataReopenPendingConsumptionSince(workspace.metadata);
|
|
1947
|
+
const staleBefore = new Date(now().getTime() - STALE_REOPEN_PENDING_CONSUMPTION_GRACE_MS);
|
|
1948
|
+
const stranded = pendingSince === null
|
|
1949
|
+
|| pendingSince.getTime() <= staleBefore.getTime();
|
|
1950
|
+
if (!stranded) {
|
|
1951
|
+
// A reopen published this workspace as active while the source issue
|
|
1952
|
+
// is still terminal, and the consuming request is still in flight. Do
|
|
1953
|
+
// not archive it now. The authoritative check is the NOT reopenPending
|
|
1954
|
+
// predicate in the archive statement below; this early skip avoids the
|
|
1955
|
+
// work when the snapshot already shows the flag.
|
|
1956
|
+
result.skippedReopened += 1;
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
// The flag is older than the grace period, but age alone does not prove
|
|
1960
|
+
// the consuming request ended. An authorized request can run longer than
|
|
1961
|
+
// the grace period. A live consuming run still owns the fence, so keep it
|
|
1962
|
+
// and skip. This stops the sweep from clearing the fence of a slow request
|
|
1963
|
+
// that a later sweep would then archive and destroy under the request.
|
|
1964
|
+
if (await workspaceHasActiveRun(workspace)) {
|
|
1965
|
+
result.skippedReopened += 1;
|
|
1966
|
+
continue;
|
|
1967
|
+
}
|
|
1968
|
+
// The reopen-pending flag outlived its consumption window and no run owns
|
|
1969
|
+
// it. The consuming request ended without moving the issue out of the
|
|
1970
|
+
// terminal state, or the server exited before it cleared the flag. Clear
|
|
1971
|
+
// the stranded flag under the lifecycle lock so a later sweep can reclaim
|
|
1972
|
+
// the workspace. Keep the row active, so a retried resume can still reuse
|
|
1973
|
+
// the rebuilt worktree. Pass this snapshot's generation and re-confirm
|
|
1974
|
+
// staleness against the fresh row under the lock, so a newer reopen that
|
|
1975
|
+
// raised the generation or refreshed the timestamp keeps its live fence.
|
|
1976
|
+
const cleared = await clearReopenPendingConsumptionUnderLock(workspace.id, {
|
|
1977
|
+
expectedGeneration: readExecutionWorkspaceLifecycleGeneration(workspace.metadata),
|
|
1978
|
+
requireStaleSinceBefore: staleBefore,
|
|
1979
|
+
});
|
|
1980
|
+
if (cleared) {
|
|
1981
|
+
result.clearedStaleReopenPending += 1;
|
|
1982
|
+
logger.info({
|
|
1983
|
+
event: "execution_workspace.reopen",
|
|
1984
|
+
outcome: "stale_reopen_pending_cleared",
|
|
1985
|
+
executionWorkspaceId: workspace.id,
|
|
1986
|
+
sourceIssueId: workspace.sourceIssueId,
|
|
1987
|
+
pendingSince: pendingSince?.toISOString() ?? null,
|
|
1988
|
+
}, "cleared a stranded reopen-pending flag on a terminal workspace");
|
|
1989
|
+
}
|
|
1990
|
+
continue;
|
|
1991
|
+
}
|
|
1992
|
+
if (await workspaceHasActiveRun(workspace)) {
|
|
1993
|
+
result.skippedActiveRun += 1;
|
|
1994
|
+
continue;
|
|
1995
|
+
}
|
|
1996
|
+
result.eligible += 1;
|
|
1997
|
+
const closedAt = now();
|
|
1998
|
+
// Raise the lifecycle generation on archive. The cleanup below captures
|
|
1999
|
+
// this generation and re-checks it before it deletes the worktree, so a
|
|
2000
|
+
// reopen that runs in between fences the cleanup off.
|
|
2001
|
+
const archivedMetadata = bumpExecutionWorkspaceLifecycleGeneration(workspace.metadata);
|
|
2002
|
+
// Take the per-workspace lifecycle lock before the archive decision, so a
|
|
2003
|
+
// concurrent reopen cannot publish an active row between the predicate
|
|
2004
|
+
// checks and the archive write. The archive statement re-checks the
|
|
2005
|
+
// status, the terminal predicates, and the reopen-pending flag under the
|
|
2006
|
+
// lock, so it never archives a workspace that a reopen just restored.
|
|
2007
|
+
const archived = await db.transaction(async (tx) => {
|
|
2008
|
+
await acquireExecutionWorkspaceLifecycleLock(tx, workspace.id);
|
|
2009
|
+
return tx
|
|
2010
|
+
.update(executionWorkspaces)
|
|
2011
|
+
.set({
|
|
2012
|
+
status: "archived",
|
|
2013
|
+
closedAt,
|
|
2014
|
+
cleanupEligibleAt: workspace.cleanupEligibleAt ?? closedAt,
|
|
2015
|
+
cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
|
|
2016
|
+
metadata: archivedMetadata,
|
|
2017
|
+
updatedAt: closedAt,
|
|
2018
|
+
})
|
|
2019
|
+
.where(and(eq(executionWorkspaces.id, workspace.id), eq(executionWorkspaces.companyId, workspace.companyId), inArray(executionWorkspaces.status, ["active", "idle", "in_review"]), isNull(executionWorkspaces.closedAt), sql `(${executionWorkspaces.metadata} ->> ${EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY}) IS DISTINCT FROM 'true'`, sql `EXISTS (
|
|
2020
|
+
SELECT 1
|
|
2021
|
+
FROM ${issues} source_issue
|
|
2022
|
+
WHERE source_issue.company_id = ${workspace.companyId}
|
|
2023
|
+
AND source_issue.id = ${workspace.sourceIssueId}
|
|
2024
|
+
AND source_issue.status IN ('done', 'cancelled')
|
|
2025
|
+
)`, sql `NOT EXISTS (
|
|
2026
|
+
SELECT 1
|
|
2027
|
+
FROM ${issues} linked_issue
|
|
2028
|
+
JOIN ${heartbeatRuns} live_run
|
|
2029
|
+
ON live_run.id = linked_issue.checkout_run_id
|
|
2030
|
+
OR live_run.id = linked_issue.execution_run_id
|
|
2031
|
+
WHERE linked_issue.company_id = ${workspace.companyId}
|
|
2032
|
+
AND (
|
|
2033
|
+
linked_issue.execution_workspace_id = ${workspace.id}
|
|
2034
|
+
OR linked_issue.id = ${workspace.sourceIssueId}
|
|
2035
|
+
)
|
|
2036
|
+
AND live_run.company_id = ${workspace.companyId}
|
|
2037
|
+
AND live_run.status IN ('queued', 'running')
|
|
2038
|
+
)`, sql `NOT EXISTS (
|
|
2039
|
+
WITH RECURSIVE issue_tree(id, status) AS (
|
|
2040
|
+
SELECT root.id, root.status
|
|
2041
|
+
FROM ${issues} root
|
|
2042
|
+
WHERE root.company_id = ${workspace.companyId}
|
|
2043
|
+
AND root.id = ${workspace.sourceIssueId}
|
|
2044
|
+
UNION ALL
|
|
2045
|
+
SELECT child.id, child.status
|
|
2046
|
+
FROM ${issues} child
|
|
2047
|
+
JOIN issue_tree parent ON child.parent_id = parent.id
|
|
2048
|
+
WHERE child.company_id = ${workspace.companyId}
|
|
1581
2049
|
)
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
SELECT child.id, child.status
|
|
1592
|
-
FROM ${issues} child
|
|
1593
|
-
JOIN issue_tree parent ON child.parent_id = parent.id
|
|
1594
|
-
WHERE child.company_id = ${workspace.companyId}
|
|
1595
|
-
)
|
|
1596
|
-
SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled')
|
|
1597
|
-
)`))
|
|
1598
|
-
.returning()
|
|
1599
|
-
.then((rows) => rows[0] ?? null);
|
|
1600
|
-
if (!archived) {
|
|
1601
|
-
result.skippedRace += 1;
|
|
1602
|
-
continue;
|
|
1603
|
-
}
|
|
1604
|
-
await logActivity(db, {
|
|
1605
|
-
companyId: archived.companyId,
|
|
1606
|
-
actorType: "system",
|
|
1607
|
-
actorId: "workspace_terminality_reaper",
|
|
1608
|
-
action: "execution_workspace.issue_terminal_archived",
|
|
1609
|
-
entityType: "execution_workspace",
|
|
1610
|
-
entityId: archived.id,
|
|
1611
|
-
details: {
|
|
1612
|
-
sourceIssueId: archived.sourceIssueId,
|
|
1613
|
-
deliveryState: assessment.deliveryState,
|
|
1614
|
-
cleanupEligibleAt: archived.cleanupEligibleAt?.toISOString() ?? null,
|
|
1615
|
-
cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
|
|
1616
|
-
},
|
|
1617
|
-
});
|
|
1618
|
-
try {
|
|
1619
|
-
const cleanup = await cleanupTerminalWorkspace(archived, assessment.workspaceHeadSha);
|
|
1620
|
-
if (!cleanup.cleaned)
|
|
1621
|
-
result.cleanupFailed += 1;
|
|
1622
|
-
else
|
|
1623
|
-
result.archived += 1;
|
|
1624
|
-
}
|
|
1625
|
-
catch (error) {
|
|
1626
|
-
result.cleanupFailed += 1;
|
|
1627
|
-
const failure = error instanceof Error ? error.message : String(error);
|
|
1628
|
-
await db
|
|
1629
|
-
.update(executionWorkspaces)
|
|
1630
|
-
.set({
|
|
1631
|
-
status: "cleanup_failed",
|
|
1632
|
-
cleanupReason: `${ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON} | ${failure}`,
|
|
1633
|
-
updatedAt: now(),
|
|
1634
|
-
})
|
|
1635
|
-
.where(eq(executionWorkspaces.id, archived.id));
|
|
2050
|
+
SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled')
|
|
2051
|
+
)`))
|
|
2052
|
+
.returning()
|
|
2053
|
+
.then((rows) => rows[0] ?? null);
|
|
2054
|
+
});
|
|
2055
|
+
if (!archived) {
|
|
2056
|
+
result.skippedRace += 1;
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
1636
2059
|
await logActivity(db, {
|
|
1637
2060
|
companyId: archived.companyId,
|
|
1638
2061
|
actorType: "system",
|
|
1639
2062
|
actorId: "workspace_terminality_reaper",
|
|
1640
|
-
action: "execution_workspace.
|
|
2063
|
+
action: "execution_workspace.issue_terminal_archived",
|
|
1641
2064
|
entityType: "execution_workspace",
|
|
1642
2065
|
entityId: archived.id,
|
|
1643
|
-
details: {
|
|
2066
|
+
details: {
|
|
2067
|
+
sourceIssueId: archived.sourceIssueId,
|
|
2068
|
+
deliveryState: assessment.deliveryState,
|
|
2069
|
+
cleanupEligibleAt: archived.cleanupEligibleAt?.toISOString() ?? null,
|
|
2070
|
+
cleanupReason: ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON,
|
|
2071
|
+
},
|
|
1644
2072
|
});
|
|
2073
|
+
const capturedGeneration = readExecutionWorkspaceLifecycleGeneration(archived.metadata);
|
|
2074
|
+
try {
|
|
2075
|
+
const cleanup = await cleanupTerminalWorkspace(archived, assessment.workspaceHeadSha, capturedGeneration);
|
|
2076
|
+
if (cleanup.skippedReopened)
|
|
2077
|
+
result.skippedReopened += 1;
|
|
2078
|
+
else if (!cleanup.cleaned)
|
|
2079
|
+
result.cleanupFailed += 1;
|
|
2080
|
+
else
|
|
2081
|
+
result.archived += 1;
|
|
2082
|
+
}
|
|
2083
|
+
catch (error) {
|
|
2084
|
+
result.cleanupFailed += 1;
|
|
2085
|
+
const failure = error instanceof Error ? error.message : String(error);
|
|
2086
|
+
// Mark cleanup_failed only while the row is still closed at the
|
|
2087
|
+
// generation this sweep captured. A reopen that raced after the failure
|
|
2088
|
+
// raises the generation and restores the row; a later archive keeps the
|
|
2089
|
+
// higher generation. The fenced write then skips, so stale
|
|
2090
|
+
// cleanup-failure state never lands on a newer archive lifecycle.
|
|
2091
|
+
await markTerminalCleanupFailedFenced({
|
|
2092
|
+
workspaceId: archived.id,
|
|
2093
|
+
capturedGeneration,
|
|
2094
|
+
cleanupReason: `${ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON} | ${failure}`,
|
|
2095
|
+
});
|
|
2096
|
+
await logActivity(db, {
|
|
2097
|
+
companyId: archived.companyId,
|
|
2098
|
+
actorType: "system",
|
|
2099
|
+
actorId: "workspace_terminality_reaper",
|
|
2100
|
+
action: "execution_workspace.issue_terminal_cleanup_failed",
|
|
2101
|
+
entityType: "execution_workspace",
|
|
2102
|
+
entityId: archived.id,
|
|
2103
|
+
details: { sourceIssueId: archived.sourceIssueId, failure },
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
1645
2106
|
}
|
|
2107
|
+
return result;
|
|
2108
|
+
}
|
|
2109
|
+
finally {
|
|
2110
|
+
terminalSweepInProgress = false;
|
|
1646
2111
|
}
|
|
1647
|
-
return result;
|
|
1648
2112
|
},
|
|
1649
2113
|
create: async (data) => {
|
|
1650
2114
|
const row = await db
|
|
@@ -1663,6 +2127,414 @@ export function executionWorkspaceService(db, opts = {}) {
|
|
|
1663
2127
|
.then((rows) => rows[0] ?? null);
|
|
1664
2128
|
return row ? toExecutionWorkspace(row) : null;
|
|
1665
2129
|
},
|
|
2130
|
+
// Reopen one closed isolated execution workspace so an authorized issue can
|
|
2131
|
+
// use it again. The caller must first authorize the request on the issue.
|
|
2132
|
+
// The whole operation runs under the per-workspace lifecycle lock: it reads
|
|
2133
|
+
// the row in the issue scope, rebuilds the worktree, and only then publishes
|
|
2134
|
+
// the row as "active". A rebuild failure keeps the row closed and returns an
|
|
2135
|
+
// error, so the caller never dispatches a run against a broken workspace.
|
|
2136
|
+
reopenClosedIsolatedExecutionWorkspaceForIssue: async (input) => {
|
|
2137
|
+
const { issue, actor } = input;
|
|
2138
|
+
// Bind the workspace to the issue company and project. A null project on
|
|
2139
|
+
// the issue must match a null project on the row (IS NOT DISTINCT FROM).
|
|
2140
|
+
const projectIdCondition = issue.projectId == null
|
|
2141
|
+
? sql `${executionWorkspaces.projectId} IS NULL`
|
|
2142
|
+
: eq(executionWorkspaces.projectId, issue.projectId);
|
|
2143
|
+
return db.transaction(async (tx) => {
|
|
2144
|
+
await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId);
|
|
2145
|
+
const row = await tx
|
|
2146
|
+
.select()
|
|
2147
|
+
.from(executionWorkspaces)
|
|
2148
|
+
.where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, issue.companyId), projectIdCondition, eq(executionWorkspaces.mode, "isolated_workspace")))
|
|
2149
|
+
.then((rows) => rows[0] ?? null);
|
|
2150
|
+
if (!row) {
|
|
2151
|
+
// Wrong company, wrong project, wrong mode, or missing. Fail closed and
|
|
2152
|
+
// disclose no workspace detail.
|
|
2153
|
+
return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" };
|
|
2154
|
+
}
|
|
2155
|
+
if (!isClosedExecutionWorkspaceStatus(row.status)) {
|
|
2156
|
+
// A concurrent reopen already restored the row. Report success without a
|
|
2157
|
+
// second rebuild so the caller continues normally. The other request
|
|
2158
|
+
// owns the reopen-pending flag, so return its generation and let the
|
|
2159
|
+
// caller skip the consumption guard.
|
|
2160
|
+
return {
|
|
2161
|
+
ok: true,
|
|
2162
|
+
reopened: false,
|
|
2163
|
+
workspace: toExecutionWorkspace(row),
|
|
2164
|
+
generation: readExecutionWorkspaceLifecycleGeneration(row.metadata),
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
const [{ ensurePersistedExecutionWorkspaceAvailable }, { workspaceOperationService }, { ensureManagedProjectWorkspace },] = await Promise.all([
|
|
2168
|
+
import("./workspace-runtime.js"),
|
|
2169
|
+
import("./workspace-operations.js"),
|
|
2170
|
+
// heartbeat.js imports this module, so a static import creates a
|
|
2171
|
+
// cycle. Load ensureManagedProjectWorkspace dynamically instead.
|
|
2172
|
+
import("./heartbeat.js"),
|
|
2173
|
+
]);
|
|
2174
|
+
const [projectWorkspace, projectPolicy] = await Promise.all([
|
|
2175
|
+
row.projectWorkspaceId
|
|
2176
|
+
? db
|
|
2177
|
+
.select({ cwd: projectWorkspaces.cwd })
|
|
2178
|
+
.from(projectWorkspaces)
|
|
2179
|
+
.where(and(eq(projectWorkspaces.companyId, row.companyId), eq(projectWorkspaces.id, row.projectWorkspaceId)))
|
|
2180
|
+
.then((rows) => rows[0] ?? null)
|
|
2181
|
+
: null,
|
|
2182
|
+
db
|
|
2183
|
+
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
|
2184
|
+
.from(projects)
|
|
2185
|
+
.where(and(eq(projects.companyId, row.companyId), eq(projects.id, row.projectId)))
|
|
2186
|
+
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)),
|
|
2187
|
+
]);
|
|
2188
|
+
// Resolve the base checkout that the rebuild spawns git in. A
|
|
2189
|
+
// local-folder project stores its base path in projectWorkspaces.cwd.
|
|
2190
|
+
// A managed_checkout project stores null there, so resolve its live
|
|
2191
|
+
// managed checkout instead. Never use row.cwd for a git_worktree
|
|
2192
|
+
// rebuild: row.cwd is the archived worktree path, which the reaper
|
|
2193
|
+
// already removed from disk. A spawn in that missing directory fails
|
|
2194
|
+
// with "spawn git ENOENT" and hides the real cause.
|
|
2195
|
+
let resolvedBaseCwd = projectWorkspace?.cwd ?? null;
|
|
2196
|
+
if (resolvedBaseCwd == null && row.strategyType === "git_worktree" && row.projectId) {
|
|
2197
|
+
const managedWorkspace = await ensureManagedProjectWorkspace({
|
|
2198
|
+
companyId: row.companyId,
|
|
2199
|
+
projectId: row.projectId,
|
|
2200
|
+
repoUrl: row.repoUrl,
|
|
2201
|
+
resolveGitAuth: createGitRemoteAuthProvider(db, row.companyId, {
|
|
2202
|
+
issueId: row.sourceIssueId ?? issue.id,
|
|
2203
|
+
}),
|
|
2204
|
+
});
|
|
2205
|
+
resolvedBaseCwd = managedWorkspace.cwd;
|
|
2206
|
+
}
|
|
2207
|
+
const config = readExecutionWorkspaceConfig(row.metadata);
|
|
2208
|
+
const nextGeneration = readExecutionWorkspaceLifecycleGeneration(row.metadata) + 1;
|
|
2209
|
+
const nextMetadata = bumpExecutionWorkspaceLifecycleGeneration(row.metadata);
|
|
2210
|
+
const recorder = workspaceOperationService(db).createRecorder({
|
|
2211
|
+
companyId: row.companyId,
|
|
2212
|
+
executionWorkspaceId: row.id,
|
|
2213
|
+
});
|
|
2214
|
+
let rebuildError = null;
|
|
2215
|
+
try {
|
|
2216
|
+
const realized = await ensurePersistedExecutionWorkspaceAvailable({
|
|
2217
|
+
db: tx,
|
|
2218
|
+
base: {
|
|
2219
|
+
baseCwd: resolvedBaseCwd ?? row.cwd ?? "",
|
|
2220
|
+
source: "task_session",
|
|
2221
|
+
projectId: row.projectId,
|
|
2222
|
+
workspaceId: row.projectWorkspaceId,
|
|
2223
|
+
repoUrl: row.repoUrl,
|
|
2224
|
+
repoRef: row.baseRef,
|
|
2225
|
+
},
|
|
2226
|
+
workspace: {
|
|
2227
|
+
id: row.id,
|
|
2228
|
+
mode: row.mode,
|
|
2229
|
+
strategyType: row.strategyType,
|
|
2230
|
+
cwd: row.cwd,
|
|
2231
|
+
providerRef: row.providerRef,
|
|
2232
|
+
projectId: row.projectId,
|
|
2233
|
+
projectWorkspaceId: row.projectWorkspaceId,
|
|
2234
|
+
repoUrl: row.repoUrl,
|
|
2235
|
+
baseRef: row.baseRef,
|
|
2236
|
+
branchName: row.branchName,
|
|
2237
|
+
metadata: row.metadata,
|
|
2238
|
+
config: {
|
|
2239
|
+
...config,
|
|
2240
|
+
provisionCommand: config?.provisionCommand
|
|
2241
|
+
?? projectPolicy?.workspaceStrategy?.provisionCommand
|
|
2242
|
+
?? null,
|
|
2243
|
+
},
|
|
2244
|
+
},
|
|
2245
|
+
issue: row.sourceIssueId
|
|
2246
|
+
? { id: row.sourceIssueId, identifier: null, title: row.name }
|
|
2247
|
+
: null,
|
|
2248
|
+
agent: {
|
|
2249
|
+
id: actor.agentId ?? null,
|
|
2250
|
+
name: actor.actorType === "user" ? "Board" : "Agent",
|
|
2251
|
+
companyId: row.companyId,
|
|
2252
|
+
},
|
|
2253
|
+
recorder,
|
|
2254
|
+
});
|
|
2255
|
+
if (!realized) {
|
|
2256
|
+
rebuildError = "Execution workspace could not be rebuilt";
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
catch (error) {
|
|
2260
|
+
rebuildError = error instanceof Error ? error.message : String(error);
|
|
2261
|
+
}
|
|
2262
|
+
if (rebuildError) {
|
|
2263
|
+
// The rebuild failed. Keep the row closed and retryable. Raise the
|
|
2264
|
+
// generation so a queued cleanup that captured the old generation does
|
|
2265
|
+
// nothing. Clear cleanupEligibleAt so the reaper does not destroy the
|
|
2266
|
+
// half-built worktree while a later reopen retries.
|
|
2267
|
+
await tx
|
|
2268
|
+
.update(executionWorkspaces)
|
|
2269
|
+
.set({
|
|
2270
|
+
cleanupReason: EXECUTION_WORKSPACE_REOPEN_FAILED_REASON,
|
|
2271
|
+
cleanupEligibleAt: null,
|
|
2272
|
+
metadata: nextMetadata,
|
|
2273
|
+
updatedAt: new Date(),
|
|
2274
|
+
})
|
|
2275
|
+
.where(eq(executionWorkspaces.id, row.id));
|
|
2276
|
+
// The server log carries the underlying cause for diagnosis. The audit
|
|
2277
|
+
// event and the returned message stay free of repo URLs, host paths,
|
|
2278
|
+
// and git output.
|
|
2279
|
+
logger.warn({
|
|
2280
|
+
event: "execution_workspace.reopen",
|
|
2281
|
+
outcome: "rebuild_failed",
|
|
2282
|
+
executionWorkspaceId: row.id,
|
|
2283
|
+
issueId: issue.id,
|
|
2284
|
+
companyId: row.companyId,
|
|
2285
|
+
actorType: actor.actorType,
|
|
2286
|
+
actorAgentId: actor.agentId ?? null,
|
|
2287
|
+
generation: nextGeneration,
|
|
2288
|
+
error: rebuildError,
|
|
2289
|
+
}, "execution workspace reopen rebuild failed");
|
|
2290
|
+
await logActivity(tx, {
|
|
2291
|
+
companyId: row.companyId,
|
|
2292
|
+
actorType: actor.actorType === "user" ? "user" : "agent",
|
|
2293
|
+
actorId: actor.agentId ?? "system",
|
|
2294
|
+
agentId: actor.actorType === "user" ? null : actor.agentId,
|
|
2295
|
+
action: "execution_workspace.reopen_failed",
|
|
2296
|
+
entityType: "execution_workspace",
|
|
2297
|
+
entityId: row.id,
|
|
2298
|
+
details: {
|
|
2299
|
+
issueId: issue.id,
|
|
2300
|
+
outcome: "rebuild_failed",
|
|
2301
|
+
generation: nextGeneration,
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
|
|
2305
|
+
}
|
|
2306
|
+
// The rebuild succeeded. Publish the row as active in one write, and clear
|
|
2307
|
+
// the closed markers. Set the reopen-pending flag in the same write. The
|
|
2308
|
+
// source issue is still terminal at this point, because the route changes
|
|
2309
|
+
// the issue out of the terminal state only after this reopen returns. The
|
|
2310
|
+
// flag stops the terminal reaper and the archive route from archiving and
|
|
2311
|
+
// destroying the rebuilt worktree in that window.
|
|
2312
|
+
const activeMetadata = setMetadataReopenPendingConsumption(nextMetadata, now());
|
|
2313
|
+
const activeRow = await tx
|
|
2314
|
+
.update(executionWorkspaces)
|
|
2315
|
+
.set({
|
|
2316
|
+
status: "active",
|
|
2317
|
+
closedAt: null,
|
|
2318
|
+
cleanupReason: null,
|
|
2319
|
+
cleanupEligibleAt: null,
|
|
2320
|
+
metadata: activeMetadata,
|
|
2321
|
+
lastUsedAt: new Date(),
|
|
2322
|
+
updatedAt: new Date(),
|
|
2323
|
+
})
|
|
2324
|
+
.where(eq(executionWorkspaces.id, row.id))
|
|
2325
|
+
.returning()
|
|
2326
|
+
.then((rows) => rows[0] ?? null);
|
|
2327
|
+
if (!activeRow) {
|
|
2328
|
+
return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
|
|
2329
|
+
}
|
|
2330
|
+
logger.info({
|
|
2331
|
+
event: "execution_workspace.reopen",
|
|
2332
|
+
outcome: "reopened",
|
|
2333
|
+
executionWorkspaceId: row.id,
|
|
2334
|
+
issueId: issue.id,
|
|
2335
|
+
companyId: row.companyId,
|
|
2336
|
+
actorType: actor.actorType,
|
|
2337
|
+
actorAgentId: actor.agentId ?? null,
|
|
2338
|
+
generation: nextGeneration,
|
|
2339
|
+
}, "execution workspace reopened");
|
|
2340
|
+
await logActivity(tx, {
|
|
2341
|
+
companyId: row.companyId,
|
|
2342
|
+
actorType: actor.actorType === "user" ? "user" : "agent",
|
|
2343
|
+
actorId: actor.agentId ?? "system",
|
|
2344
|
+
agentId: actor.actorType === "user" ? null : actor.agentId,
|
|
2345
|
+
action: "execution_workspace.reopened",
|
|
2346
|
+
entityType: "execution_workspace",
|
|
2347
|
+
entityId: row.id,
|
|
2348
|
+
details: {
|
|
2349
|
+
issueId: issue.id,
|
|
2350
|
+
outcome: "reopened",
|
|
2351
|
+
generation: nextGeneration,
|
|
2352
|
+
},
|
|
2353
|
+
});
|
|
2354
|
+
return { ok: true, reopened: true, workspace: toExecutionWorkspace(activeRow), generation: nextGeneration };
|
|
2355
|
+
});
|
|
2356
|
+
},
|
|
2357
|
+
// Clear the reopen-pending flag after a caller failed to consume a reopened
|
|
2358
|
+
// workspace. A reopen publishes the rebuilt worktree as active and sets the
|
|
2359
|
+
// flag while the source issue is still terminal. The route then moves the
|
|
2360
|
+
// issue out of the terminal state, and the terminal reaper clears the flag
|
|
2361
|
+
// once it observes the non-terminal issue. If that move never lands (the
|
|
2362
|
+
// route mutation returns null, throws, or leaves the issue terminal), the
|
|
2363
|
+
// issue stays terminal and the flag stays set. The terminal reaper and the
|
|
2364
|
+
// archive route both skip a reopen-pending row, so the rebuilt worktree leaks
|
|
2365
|
+
// and no path can reclaim it. This method clears the flag under the lifecycle
|
|
2366
|
+
// lock, so the reaper can archive and reclaim the worktree. It keeps the row
|
|
2367
|
+
// active, so a retried resume can still reuse the rebuilt worktree. The
|
|
2368
|
+
// method is idempotent: it does nothing when the flag is already clear.
|
|
2369
|
+
// Re-stamp the reopen-pending timestamp while a consuming request is still in
|
|
2370
|
+
// flight. The consuming route calls this on an interval shorter than the stale
|
|
2371
|
+
// grace period, so the terminal reaper never treats the live fence as stranded.
|
|
2372
|
+
// The refresh runs only while the flag is still set and the generation still
|
|
2373
|
+
// matches `expectedGeneration`, so it never revives a cleared flag and never
|
|
2374
|
+
// refreshes a newer reopen's fence. It returns { refreshed } so the caller can
|
|
2375
|
+
// stop the interval once the fence is no longer its own.
|
|
2376
|
+
refreshReopenPendingConsumption: async (input) => {
|
|
2377
|
+
const refreshed = await refreshReopenPendingConsumptionUnderLock(input.workspaceId, {
|
|
2378
|
+
expectedGeneration: input.expectedGeneration,
|
|
2379
|
+
});
|
|
2380
|
+
return { refreshed };
|
|
2381
|
+
},
|
|
2382
|
+
clearReopenPendingConsumptionForUnconsumedReopen: async (input) => {
|
|
2383
|
+
const cleared = await clearReopenPendingConsumptionUnderLock(input.workspaceId, {
|
|
2384
|
+
expectedGeneration: input.expectedGeneration,
|
|
2385
|
+
});
|
|
2386
|
+
if (cleared) {
|
|
2387
|
+
logger.info({
|
|
2388
|
+
event: "execution_workspace.reopen",
|
|
2389
|
+
outcome: "unconsumed_reopen_cleared",
|
|
2390
|
+
executionWorkspaceId: input.workspaceId,
|
|
2391
|
+
issueId: input.issue.id,
|
|
2392
|
+
companyId: input.issue.companyId,
|
|
2393
|
+
actorType: input.actor.actorType,
|
|
2394
|
+
actorAgentId: input.actor.agentId ?? null,
|
|
2395
|
+
}, "execution workspace reopen-pending flag cleared after an unconsumed reopen");
|
|
2396
|
+
await logActivity(db, {
|
|
2397
|
+
companyId: input.issue.companyId,
|
|
2398
|
+
actorType: input.actor.actorType === "user" ? "user" : "agent",
|
|
2399
|
+
actorId: input.actor.agentId ?? "system",
|
|
2400
|
+
agentId: input.actor.actorType === "user" ? null : input.actor.agentId,
|
|
2401
|
+
action: "execution_workspace.reopen_unconsumed",
|
|
2402
|
+
entityType: "execution_workspace",
|
|
2403
|
+
entityId: input.workspaceId,
|
|
2404
|
+
details: {
|
|
2405
|
+
issueId: input.issue.id,
|
|
2406
|
+
outcome: "unconsumed_reopen_cleared",
|
|
2407
|
+
},
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
return { cleared };
|
|
2411
|
+
},
|
|
2412
|
+
// Archive one workspace under the per-workspace lifecycle lock. The archive
|
|
2413
|
+
// route calls this so the transition to archived and the destruction fence
|
|
2414
|
+
// both run under the same lock as a reopen. The lock stops a concurrent
|
|
2415
|
+
// reopen from publishing an active row between the status re-check and the
|
|
2416
|
+
// archive write. The archive runs only while the row is still open (not
|
|
2417
|
+
// already archived by a race) and clears the reopen-pending flag.
|
|
2418
|
+
//
|
|
2419
|
+
// The method refuses to archive a row that carries the reopen-pending flag.
|
|
2420
|
+
// A reopen sets that flag when it publishes a rebuilt worktree as active
|
|
2421
|
+
// while the source issue is still terminal. The method returns a distinct
|
|
2422
|
+
// "reopen_pending" outcome for that row. It does not clear the flag and does
|
|
2423
|
+
// not archive. The route maps that outcome to HTTP 409 and returns before any
|
|
2424
|
+
// destructive cleanup, so the archive control never removes a rebuilt
|
|
2425
|
+
// worktree during the reopen consumption window.
|
|
2426
|
+
archiveWorkspaceUnderLifecycleLock: async (input) => {
|
|
2427
|
+
return db.transaction(async (tx) => {
|
|
2428
|
+
await acquireExecutionWorkspaceLifecycleLock(tx, input.id);
|
|
2429
|
+
const fresh = await tx
|
|
2430
|
+
.select()
|
|
2431
|
+
.from(executionWorkspaces)
|
|
2432
|
+
.where(eq(executionWorkspaces.id, input.id))
|
|
2433
|
+
.then((rows) => rows[0] ?? null);
|
|
2434
|
+
if (!fresh || isClosedExecutionWorkspaceStatus(fresh.status)) {
|
|
2435
|
+
// The row is missing or already closed by a concurrent path. Do not
|
|
2436
|
+
// archive again.
|
|
2437
|
+
return null;
|
|
2438
|
+
}
|
|
2439
|
+
if (metadataHasReopenPendingConsumption(fresh.metadata)) {
|
|
2440
|
+
// A reopen published this row as active while its source issue is still
|
|
2441
|
+
// terminal. A caller will consume the rebuilt worktree. Refuse the
|
|
2442
|
+
// archive and keep the flag, so the destructive path never removes the
|
|
2443
|
+
// rebuilt worktree. The route maps this to HTTP 409.
|
|
2444
|
+
return { outcome: "reopen_pending" };
|
|
2445
|
+
}
|
|
2446
|
+
const baseMetadata = input.patch.metadata
|
|
2447
|
+
?? fresh.metadata;
|
|
2448
|
+
const archiveMetadata = clearMetadataReopenPendingConsumption(bumpExecutionWorkspaceLifecycleGeneration(baseMetadata));
|
|
2449
|
+
const archived = await tx
|
|
2450
|
+
.update(executionWorkspaces)
|
|
2451
|
+
.set({
|
|
2452
|
+
...input.patch,
|
|
2453
|
+
status: "archived",
|
|
2454
|
+
closedAt: input.closedAt,
|
|
2455
|
+
cleanupReason: null,
|
|
2456
|
+
metadata: archiveMetadata,
|
|
2457
|
+
updatedAt: new Date(),
|
|
2458
|
+
})
|
|
2459
|
+
.where(and(eq(executionWorkspaces.id, input.id),
|
|
2460
|
+
// Defense in depth: never archive a reopen-pending row even if the
|
|
2461
|
+
// flag appears between the read above and this write. The lifecycle
|
|
2462
|
+
// lock already serializes reopen and archive, so this predicate only
|
|
2463
|
+
// adds a second, authoritative guard at the write.
|
|
2464
|
+
sql `(${executionWorkspaces.metadata} ->> ${EXECUTION_WORKSPACE_REOPEN_PENDING_METADATA_KEY}) IS DISTINCT FROM 'true'`))
|
|
2465
|
+
.returning()
|
|
2466
|
+
.then((rows) => rows[0] ?? null);
|
|
2467
|
+
if (!archived)
|
|
2468
|
+
return null;
|
|
2469
|
+
return {
|
|
2470
|
+
outcome: "archived",
|
|
2471
|
+
workspace: toExecutionWorkspace(archived),
|
|
2472
|
+
capturedGeneration: readExecutionWorkspaceLifecycleGeneration(archiveMetadata),
|
|
2473
|
+
};
|
|
2474
|
+
});
|
|
2475
|
+
},
|
|
2476
|
+
// Apply the terminal cleanup outcome to a workspace row through the lifecycle
|
|
2477
|
+
// gateway. The archive route calls this after the destruction fence ran, to
|
|
2478
|
+
// record cleanup warnings and, when the destroy failed, the cleanup_failed
|
|
2479
|
+
// status. A reopen that raced after the fence returned restores the row to an
|
|
2480
|
+
// open status and raises the generation. The gateway re-reads the fresh row
|
|
2481
|
+
// under the lock and writes only while the row is still closed at
|
|
2482
|
+
// `capturedGeneration`. So a stale cleanup patch never overwrites the closedAt,
|
|
2483
|
+
// the cleanup reason, or the status of a freshly rebuilt worktree. The method
|
|
2484
|
+
// returns the updated row, or null when the guard skipped the write.
|
|
2485
|
+
applyClosedWorkspaceCleanupOutcome: async (input) => {
|
|
2486
|
+
// A reopen restored the row after the destruction fence returned when the
|
|
2487
|
+
// guard fails. The gateway then skips, so the write never overwrites the
|
|
2488
|
+
// newly active lifecycle state.
|
|
2489
|
+
return fenceLifecycleGenerationWrite({
|
|
2490
|
+
workspaceId: input.id,
|
|
2491
|
+
expectedGeneration: input.capturedGeneration,
|
|
2492
|
+
isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
|
|
2493
|
+
onSkip: () => null,
|
|
2494
|
+
write: async ({ tx }) => {
|
|
2495
|
+
const row = await tx
|
|
2496
|
+
.update(executionWorkspaces)
|
|
2497
|
+
.set({
|
|
2498
|
+
closedAt: input.closedAt,
|
|
2499
|
+
cleanupReason: input.cleanupReason,
|
|
2500
|
+
...(input.markCleanupFailed ? { status: "cleanup_failed" } : {}),
|
|
2501
|
+
updatedAt: new Date(),
|
|
2502
|
+
})
|
|
2503
|
+
.where(eq(executionWorkspaces.id, input.id))
|
|
2504
|
+
.returning()
|
|
2505
|
+
.then((rows) => rows[0] ?? null);
|
|
2506
|
+
return row ? toExecutionWorkspace(row) : null;
|
|
2507
|
+
},
|
|
2508
|
+
});
|
|
2509
|
+
},
|
|
2510
|
+
// Read the lifecycle generation of a workspace row. The archive route captures
|
|
2511
|
+
// this before it destroys, so it can hand the value to the destruction fence.
|
|
2512
|
+
readLifecycleGeneration: async (id) => {
|
|
2513
|
+
const row = await db
|
|
2514
|
+
.select({ metadata: executionWorkspaces.metadata })
|
|
2515
|
+
.from(executionWorkspaces)
|
|
2516
|
+
.where(eq(executionWorkspaces.id, id))
|
|
2517
|
+
.then((rows) => rows[0] ?? null);
|
|
2518
|
+
return row ? readExecutionWorkspaceLifecycleGeneration(row.metadata) : null;
|
|
2519
|
+
},
|
|
2520
|
+
// Run a destructive workspace cleanup through the lifecycle gateway. The
|
|
2521
|
+
// caller passes the generation it captured at archive time. If a reopen raised
|
|
2522
|
+
// the generation or restored the row to an open status, the gateway skips the
|
|
2523
|
+
// destroy callback, so a cleanup never deletes a worktree that a reopen
|
|
2524
|
+
// rebuilt.
|
|
2525
|
+
fenceClosedWorkspaceDestruction: async (input) => {
|
|
2526
|
+
return fenceLifecycleGenerationWrite({
|
|
2527
|
+
workspaceId: input.workspaceId,
|
|
2528
|
+
expectedGeneration: input.capturedGeneration,
|
|
2529
|
+
isWriteTarget: (fresh) => isClosedExecutionWorkspaceStatus(fresh.status),
|
|
2530
|
+
skipLog: {
|
|
2531
|
+
event: "execution_workspace.cleanup_skipped",
|
|
2532
|
+
message: "execution workspace cleanup skipped because it was reopened",
|
|
2533
|
+
},
|
|
2534
|
+
onSkip: () => ({ skippedReopened: true }),
|
|
2535
|
+
write: async () => ({ skippedReopened: false, result: await input.destroy() }),
|
|
2536
|
+
});
|
|
2537
|
+
},
|
|
1666
2538
|
reconcileExecutionWorkspaceBranch: async (id, input) => {
|
|
1667
2539
|
const existingRow = await db
|
|
1668
2540
|
.select()
|