@straiffi/archon 1.7.3 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/client/assets/ActivityView-CT9iCPyc.js +1 -0
  2. package/dist/client/assets/GitHubActionsDialog-BA6xyQeX.js +1 -0
  3. package/dist/client/assets/TestsDialog-D2qrW5Ug.js +4 -0
  4. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BzzGotac.js → architectureDiagram-3BPJPVTR-R6v7w0Nl.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-GPEHLZMM-DIcqLHKg.js → blockDiagram-GPEHLZMM-CHlBlnJq.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-AAUBKEIU-Hc1Sqmhs.js → c4Diagram-AAUBKEIU-BvZKqUc6.js} +1 -1
  7. package/dist/client/assets/channel-Ckv8cPSf.js +1 -0
  8. package/dist/client/assets/{chunk-2J33WTMH-CWSwjsgk.js → chunk-2J33WTMH-2gsIkBeO.js} +1 -1
  9. package/dist/client/assets/{chunk-3OPIFGDE-BnX3DljN.js → chunk-3OPIFGDE-CcqPBjqx.js} +1 -1
  10. package/dist/client/assets/{chunk-5ZQYHXKU-EyyJlI-d.js → chunk-5ZQYHXKU-DX_wK5Rp.js} +1 -1
  11. package/dist/client/assets/{chunk-727SXJPM-Ci6CfxWj.js → chunk-727SXJPM-B9WUxzJL.js} +1 -1
  12. package/dist/client/assets/{chunk-AQP2D5EJ-RKZNsJTm.js → chunk-AQP2D5EJ-BG0s75_H.js} +1 -1
  13. package/dist/client/assets/{chunk-CSCIHK7Q-DBf0Euoj.js → chunk-CSCIHK7Q-CyNcvvqW.js} +1 -1
  14. package/dist/client/assets/{chunk-KSCS5N6A-BmLQI_2S.js → chunk-KSCS5N6A-CWVy_Wi0.js} +1 -1
  15. package/dist/client/assets/{chunk-L5ZTLDWV-DKwVAb8G.js → chunk-L5ZTLDWV-3dsXRB0X.js} +1 -1
  16. package/dist/client/assets/{chunk-LZXEDZCA-axotKkbz.js → chunk-LZXEDZCA-B09QSB9z.js} +2 -2
  17. package/dist/client/assets/{chunk-ND2GUHAM-BBlJ4nXC.js → chunk-ND2GUHAM-BxzAdxTN.js} +1 -1
  18. package/dist/client/assets/{chunk-NZK2D7GU-BKJGoZZt.js → chunk-NZK2D7GU-D4ZfFwbB.js} +1 -1
  19. package/dist/client/assets/{chunk-O5CBEL6O-C-Ghigo1.js → chunk-O5CBEL6O-BxQULuKS.js} +1 -1
  20. package/dist/client/assets/{chunk-WU5MYG2G-CCzVWYcd.js → chunk-WU5MYG2G-CEJwp0Ry.js} +1 -1
  21. package/dist/client/assets/classDiagram-4FO5ZUOK-B-N-arsi.js +1 -0
  22. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-PTX7YrlL.js +1 -0
  23. package/dist/client/assets/{dagre-BM42HDAG-rwIdPcjI.js → dagre-BM42HDAG-Dx_V7OMy.js} +1 -1
  24. package/dist/client/assets/{diagram-2AECGRRQ-DJWxOQsg.js → diagram-2AECGRRQ-Cc3VTg9P.js} +1 -1
  25. package/dist/client/assets/{diagram-5GNKFQAL-C-mQeFtd.js → diagram-5GNKFQAL-mJ1xhs2G.js} +1 -1
  26. package/dist/client/assets/{diagram-KO2AKTUF-BAu6u4sR.js → diagram-KO2AKTUF-CLyT9ac8.js} +1 -1
  27. package/dist/client/assets/{diagram-LMA3HP47-U6CbvBn0.js → diagram-LMA3HP47-CVr47yij.js} +1 -1
  28. package/dist/client/assets/{diagram-OG6HWLK6-DNTwFcs1.js → diagram-OG6HWLK6-Dp5TJ8lM.js} +1 -1
  29. package/dist/client/assets/{erDiagram-TEJ5UH35-DaHX6Xw5.js → erDiagram-TEJ5UH35-BBxwyu0r.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-I6XJVG4X-B9hKqL2l.js → flowDiagram-I6XJVG4X-CuFYzVQ-.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-6RSMTGT7-BQ1HPHVO.js → ganttDiagram-6RSMTGT7-aRQarcQY.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-BBRcsmMK.js → gitGraphDiagram-PVQCEYII-B3mHSkBv.js} +1 -1
  33. package/dist/client/assets/index-BJGHMaMz.css +2 -0
  34. package/dist/client/assets/index-Oz0S7bcT.js +273 -0
  35. package/dist/client/assets/{infoDiagram-5YYISTIA-DUoNiSGC.js → infoDiagram-5YYISTIA-BSyryQJ9.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-DJN1y2w8.js → ishikawaDiagram-YF4QCWOH-BAGbrtSG.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-JHISSGLW-BxLLMT70.js → journeyDiagram-JHISSGLW-CDTht6k6.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-UN3LZRKU-BdCPistJ.js → kanban-definition-UN3LZRKU-BDtuMp0H.js} +1 -1
  39. package/dist/client/assets/{line-4mIgXsZd.js → line-n0aNicmA.js} +1 -1
  40. package/dist/client/assets/{mermaid-parser.core-Pu1QGWJ-.js → mermaid-parser.core-CdE4vZ4k.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-DnH9DrgZ.js → mermaid.core-C546CQj1.js} +3 -3
  42. package/dist/client/assets/{mindmap-definition-RKZ34NQL-DqE4ba3C.js → mindmap-definition-RKZ34NQL-BwO779am.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-4H26LBE5-Crjy9ejf.js → pieDiagram-4H26LBE5-DU1k71kn.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-BUSZ2VB7.js → quadrantDiagram-W4KKPZXB-CvM5lyQg.js} +1 -1
  45. package/dist/client/assets/{requirementDiagram-4Y6WPE33-CBhFKEoV.js → requirementDiagram-4Y6WPE33-DDBdSq0B.js} +1 -1
  46. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-CgdbY41v.js → sankeyDiagram-5OEKKPKP-ELM2mu81.js} +1 -1
  47. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-hkp_H7AE.js → sequenceDiagram-3UESZ5HK-D7uF-nRR.js} +1 -1
  48. package/dist/client/assets/{stateDiagram-AJRCARHV-C6x45RWp.js → stateDiagram-AJRCARHV-IhaGRCbG.js} +1 -1
  49. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-ceHFQMIT.js +1 -0
  50. package/dist/client/assets/{timeline-definition-PNZ67QCA-CjnZWttQ.js → timeline-definition-PNZ67QCA-zHJGFvZ8.js} +1 -1
  51. package/dist/client/assets/{vennDiagram-CIIHVFJN-DU5ZyYju.js → vennDiagram-CIIHVFJN-EG1HFfD7.js} +1 -1
  52. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-CaCEnCB0.js → wardleyDiagram-YWT4CUSO-DdQevxXg.js} +1 -1
  53. package/dist/client/assets/{xychartDiagram-2RQKCTM6-Ce3xa37U.js → xychartDiagram-2RQKCTM6-BjihNG0E.js} +1 -1
  54. package/dist/client/index.html +2 -2
  55. package/dist/server/db.js +283 -0
  56. package/dist/server/db.js.map +1 -1
  57. package/dist/server/index.js +390 -117
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/lib/attachments.js +40 -0
  60. package/dist/server/lib/attachments.js.map +1 -1
  61. package/dist/server/lib/buildOpenQuestionOperations.js +17 -2
  62. package/dist/server/lib/buildOpenQuestionOperations.js.map +1 -1
  63. package/dist/server/lib/buildSequences.js +25 -0
  64. package/dist/server/lib/buildSequences.js.map +1 -1
  65. package/dist/server/lib/bundleReviewOperations.js +9 -0
  66. package/dist/server/lib/bundleReviewOperations.js.map +1 -1
  67. package/dist/server/lib/chatOperations.js +40 -16
  68. package/dist/server/lib/chatOperations.js.map +1 -1
  69. package/dist/server/lib/executionArtifactCleanupState.js +12 -0
  70. package/dist/server/lib/executionArtifactCleanupState.js.map +1 -0
  71. package/dist/server/lib/executionArtifactRetention.js +405 -0
  72. package/dist/server/lib/executionArtifactRetention.js.map +1 -0
  73. package/dist/server/lib/executionArtifacts.js +705 -0
  74. package/dist/server/lib/executionArtifacts.js.map +1 -0
  75. package/dist/server/lib/executionRetry.js +702 -0
  76. package/dist/server/lib/executionRetry.js.map +1 -0
  77. package/dist/server/lib/executionRetryContext.js +510 -0
  78. package/dist/server/lib/executionRetryContext.js.map +1 -0
  79. package/dist/server/lib/executionRetryPredicates.js +74 -0
  80. package/dist/server/lib/executionRetryPredicates.js.map +1 -0
  81. package/dist/server/lib/executionRuns.js +203 -40
  82. package/dist/server/lib/executionRuns.js.map +1 -1
  83. package/dist/server/lib/executionTimeline.js +73 -0
  84. package/dist/server/lib/executionTimeline.js.map +1 -0
  85. package/dist/server/lib/lightweightPrompt.js +2 -2
  86. package/dist/server/lib/lightweightPrompt.js.map +1 -1
  87. package/dist/server/lib/projects.js +16 -3
  88. package/dist/server/lib/projects.js.map +1 -1
  89. package/dist/server/lib/pullRequestContent.js +148 -25
  90. package/dist/server/lib/pullRequestContent.js.map +1 -1
  91. package/dist/server/lib/queue.js +1 -0
  92. package/dist/server/lib/queue.js.map +1 -1
  93. package/dist/server/lib/runtimePaths.js +7 -0
  94. package/dist/server/lib/runtimePaths.js.map +1 -1
  95. package/dist/server/lib/specOperations.js +51 -16
  96. package/dist/server/lib/specOperations.js.map +1 -1
  97. package/dist/server/lib/specReviewContext.js +10 -4
  98. package/dist/server/lib/specReviewContext.js.map +1 -1
  99. package/dist/server/lib/specReviewOperations.js +33 -5
  100. package/dist/server/lib/specReviewOperations.js.map +1 -1
  101. package/dist/server/lib/testSessions.js +2 -5
  102. package/dist/server/lib/testSessions.js.map +1 -1
  103. package/dist/server/lib/ticketAutomationCommit.js +11 -4
  104. package/dist/server/lib/ticketAutomationCommit.js.map +1 -1
  105. package/dist/server/lib/ticketFollowUpOperations.js +74 -27
  106. package/dist/server/lib/ticketFollowUpOperations.js.map +1 -1
  107. package/dist/server/lib/ticketMessages.js +16 -0
  108. package/dist/server/lib/ticketMessages.js.map +1 -1
  109. package/dist/server/lib/types.js +12 -0
  110. package/dist/server/lib/types.js.map +1 -1
  111. package/dist/server/workers/build.js +1 -1
  112. package/dist/server/workers/build.js.map +1 -1
  113. package/dist/server/workers/chat.js +26 -20
  114. package/dist/server/workers/chat.js.map +1 -1
  115. package/dist/server/workers/followUp.js +118 -15
  116. package/dist/server/workers/followUp.js.map +1 -1
  117. package/dist/server/workers/plan.js +23 -4
  118. package/dist/server/workers/plan.js.map +1 -1
  119. package/dist/server/workers/planFollowUp.js +12 -9
  120. package/dist/server/workers/planFollowUp.js.map +1 -1
  121. package/dist/server/workers/review.js +2 -2
  122. package/dist/server/workers/review.js.map +1 -1
  123. package/dist/server/workers/specReview.js +2 -2
  124. package/dist/server/workers/specReview.js.map +1 -1
  125. package/dist/server/workers/specSession.js +29 -22
  126. package/dist/server/workers/specSession.js.map +1 -1
  127. package/package.json +1 -1
  128. package/dist/client/assets/ActivityView-DTEenQas.js +0 -1
  129. package/dist/client/assets/GitHubActionsDialog-BKWHonUS.js +0 -1
  130. package/dist/client/assets/TestsDialog-cBaoffCs.js +0 -4
  131. package/dist/client/assets/channel-Cym17waa.js +0 -1
  132. package/dist/client/assets/classDiagram-4FO5ZUOK-BcigvvIC.js +0 -1
  133. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-3AdSuE0N.js +0 -1
  134. package/dist/client/assets/index-CEMaJCPS.js +0 -273
  135. package/dist/client/assets/index-lWftq65U.css +0 -2
  136. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-DVzPjRPN.js +0 -1
@@ -10,7 +10,7 @@ import { adjectives, animals, uniqueNamesGenerator } from 'unique-names-generato
10
10
  import db from './db.js';
11
11
  import { deleteChatSession, getChatSession, listChatSessions, updateChatSessionMode } from './lib/chats.js';
12
12
  import { deleteSpecSession, getCreatedTicketIdsForSnapshot, getSpecSession, getSpecSnapshot, listSpecSessions } from './lib/specs.js';
13
- import { createChatAttachment, createSpecAttachment, getAttachmentById, deleteChatAttachment, deleteSpecSessionAttachment, getChatAttachmentForSession, getChatAttachmentLinkedMessageId, getSpecAttachmentForSession, getSpecAttachmentLinkedMessageId, MAX_CHAT_ATTACHMENTS_PER_TURN, MAX_CHAT_ATTACHMENT_SIZE_BYTES, MAX_SPEC_ATTACHMENTS_PER_TURN, MAX_SPEC_ATTACHMENT_SIZE_BYTES, readChatAttachmentContent, readSpecAttachmentContent, validateImageAttachment, } from './lib/attachments.js';
13
+ import { createChatAttachment, createSpecAttachment, getAttachmentById, deleteChatAttachment, deleteProjectAttachments, deleteSpecSessionAttachment, getChatAttachmentForSession, getChatAttachmentLinkedMessageId, getSpecAttachmentForSession, getSpecAttachmentLinkedMessageId, MAX_CHAT_ATTACHMENTS_PER_TURN, MAX_CHAT_ATTACHMENT_SIZE_BYTES, MAX_SPEC_ATTACHMENTS_PER_TURN, MAX_SPEC_ATTACHMENT_SIZE_BYTES, readChatAttachmentContent, readSpecAttachmentContent, validateImageAttachment, } from './lib/attachments.js';
14
14
  import { createProjectChatSession, resolveChatMode, resolveChatTool, stopProjectChatSessionResponse, submitProjectChatSessionMessage, } from './lib/chatOperations.js';
15
15
  import { createProjectSpecSession, deleteProjectSpecSession, stopProjectSpecSessionResponse, submitProjectSpecSessionMessage, updateProjectSpecSessionMode, } from './lib/specOperations.js';
16
16
  import { buildProjectSpecReviewRevisionDraft, getProjectSpecReviewFindings, startProjectSpecReview, stopProjectSpecReview, } from './lib/specReviewOperations.js';
@@ -54,8 +54,12 @@ import { clearAutomationPause, countBlockingFindings, depthAtLeast, getBundleAut
54
54
  import { runAutomationCommitChain } from './lib/ticketAutomationCommit.js';
55
55
  import { getDiscoveredSkills, normalizeSkillNames } from './lib/skills.js';
56
56
  import { clearAllPreviewProxies } from './lib/previewProxy.js';
57
- import { createExecutionRun, addExecutionRunLink, addExecutionRunLinks, getExecutionRun, hasActiveBuildExecutionRunForTicket, getGitHubActionsExecutionDetails, hasDirectRetryChild, getTestExecutionDetails, GitHubActionsDispatchConflictError, listExecutionRunLinks, listExecutionRuns, listProjectLifecycleEvents, toCanonicalLifecycleEvent, transitionExecutionRun, withExecutionRunTransaction, } from './lib/executionRuns.js';
57
+ import { createExecutionRun, addExecutionRunLink, addExecutionRunLinks, getExecutionRun, hasActiveBuildExecutionRunForTicket, getGitHubActionsExecutionDetails, getTestExecutionDetails, GitHubActionsDispatchConflictError, listExecutionRunLinks, listExecutionRuns, listLifecycleEvents, listProjectLifecycleEvents, toCanonicalLifecycleEvent, transitionExecutionRun, withExecutionRunTransaction, } from './lib/executionRuns.js';
58
+ import { dispatchExecutionRetry, getExecutionRetryEligibility, resolveExecutionRetry } from './lib/executionRetry.js';
59
+ import { listExecutionTimeline } from './lib/executionTimeline.js';
60
+ import { loadExecutionArtifactSummariesForRuns, openExecutionArtifactForRetrieval, } from './lib/executionArtifacts.js';
58
61
  import { cleanupAllExecutionEvidence, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS, resolveExecutionEvidenceRetentionDays, } from './lib/executionRunRetention.js';
62
+ import { ExecutionArtifactCleanupError, reconcileExecutionArtifactRegistrationIntents, reconcileFinalizedExecutionArtifacts, reconcileProjectExecutionArtifacts, resolveArtifactCleanupIntervalMs, } from './lib/executionArtifactRetention.js';
59
63
  import { shutdownRealtimeServer } from './lib/shutdown.js';
60
64
  import { resolveClientBuildPaths, shouldServeClientApp, STATIC_CLIENT_DIRECTORY_OPTIONS, STATIC_CLIENT_INDEX_OPTIONS, } from './lib/staticClient.js';
61
65
  import { deleteIntegrationConnection, getGitHubConnectionConfig, getJiraConnectionConfig, listIntegrationConnections, upsertIntegrationConnection, } from './lib/integrations/index.js';
@@ -70,9 +74,9 @@ import { PlanningReferenceError, resolveExternalPlanningDescription } from './li
70
74
  import { refreshIfNeeded as refreshAssignedIssues, clearCachedAssignedIssues } from './lib/integrations/assignedIssuesCache.js';
71
75
  import { buildCommitMessagePrompt, normalizeGeneratedCommitMessage } from './lib/commitMessage.js';
72
76
  import { BUNDLE_PULL_REQUEST_SYNC_TTL_MS, getBundlePullRequestRecord, hasOpenBundlePullRequest, serializeBundlePullRequest, shouldSyncBundlePullRequest, syncTrackedBundlePullRequestIfNeeded, upsertBundlePullRequest, upsertBundlePullRequestFromGitHub, } from './lib/bundlePullRequests.js';
73
- import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listProjects, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, } from './lib/projects.js';
77
+ import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listInterruptedProjectDeletions, listProjects, releaseInterruptedProjectDeletionClaims, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, } from './lib/projects.js';
74
78
  import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopTicket } from './lib/run.js';
75
- import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExist, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
79
+ import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
76
80
  import { getBundleBuildBlockerResponse, shouldStartBundleBuildNow, transitionTicketState, } from './lib/ticketWorkflowOperations.js';
77
81
  import { submitPlanTicketFollowUp, submitTicketFollowUp, } from './lib/ticketFollowUpOperations.js';
78
82
  import { canUseBuildControls, formatActiveRunConflict, getTicketRunReadinessError, resumeBuildTicketRun, runProjectTarget, runTicketRun, stopBuildTicketRun, stopReviewTicketRun, stopProjectTargetRun, stopTicketRun, } from './lib/ticketRunOperations.js';
@@ -92,7 +96,30 @@ import { exportSpecToDocs, checkSpecExportCollision } from './lib/specExport.js'
92
96
  import { createTicketDependency, deleteTicketDependenciesForTicket, deleteTicketDependency, getTicketDependency, isTicketRunSetupReady, listTicketDependencies, validateCreateTicketDependency, } from './lib/ticketDependencies.js';
93
97
  import { startBuild } from './workers/build.js';
94
98
  import { expandPlanningTicketOutline, generatePlanningTicketOutline, generatePlanText, prepareLinkedPlanningResearch, startPlan, stopAllPlanAgents, } from './workers/plan.js';
95
- import { EXECUTION_RUN_KINDS, validateWorkflowContext } from './lib/types.js';
99
+ import { EXECUTION_RUN_KINDS, MAX_ARTIFACTS_PER_RUN, validateWorkflowContext } from './lib/types.js';
100
+ class ProjectDeletionError extends Error {
101
+ status;
102
+ constructor(status, message) {
103
+ super(message);
104
+ this.name = 'ProjectDeletionError';
105
+ this.status = status;
106
+ }
107
+ }
108
+ let executionArtifactMaintenanceTail = Promise.resolve();
109
+ const runExclusiveExecutionArtifactMaintenance = async (task) => {
110
+ const previous = executionArtifactMaintenanceTail;
111
+ let release = () => undefined;
112
+ executionArtifactMaintenanceTail = new Promise(resolve => {
113
+ release = resolve;
114
+ });
115
+ await previous;
116
+ try {
117
+ return await task();
118
+ }
119
+ finally {
120
+ release();
121
+ }
122
+ };
96
123
  const getErrorMessage = (error) => {
97
124
  return error instanceof Error ? error.message : String(error);
98
125
  };
@@ -110,6 +137,9 @@ const requireExistingProject = (projectId) => {
110
137
  if (!project) {
111
138
  return { status: 404, error: 'Not found' };
112
139
  }
140
+ if (project.deletion_started_at) {
141
+ return { status: 409, error: 'Project deletion is in progress' };
142
+ }
113
143
  return { project };
114
144
  };
115
145
  const requireProjectMemoryProject = (projectId) => {
@@ -167,6 +197,44 @@ const parseLifecycleListQuery = (query) => {
167
197
  limit,
168
198
  };
169
199
  };
200
+ const parseExecutionTimelineScope = (projectId, query) => {
201
+ const hasScopeType = query.scope_type != null && query.scope_type !== '';
202
+ const hasScopeId = query.scope_id != null && query.scope_id !== '';
203
+ if (hasScopeType !== hasScopeId) {
204
+ return { error: 'scope_type and scope_id must be provided together' };
205
+ }
206
+ if (!hasScopeType) {
207
+ return { scopeEntity: undefined };
208
+ }
209
+ if (typeof query.scope_type !== 'string' || typeof query.scope_id !== 'string') {
210
+ return { error: 'scope_type and scope_id must be strings' };
211
+ }
212
+ const scopeType = query.scope_type;
213
+ const scopeId = query.scope_id.trim();
214
+ if (!['ticket', 'bundle', 'chat_session', 'spec_session'].includes(scopeType)) {
215
+ return { error: 'scope_type must be ticket, bundle, chat_session, or spec_session' };
216
+ }
217
+ if (!scopeId) {
218
+ return { error: 'scope_id must not be empty' };
219
+ }
220
+ const table = scopeType === 'ticket'
221
+ ? 'tickets'
222
+ : scopeType === 'bundle'
223
+ ? 'worktree_bundles'
224
+ : scopeType === 'chat_session'
225
+ ? 'chat_sessions'
226
+ : 'spec_sessions';
227
+ const exists = db.prepare(`SELECT 1 FROM ${table} WHERE id = ? AND project_id = ?`).get(scopeId, projectId);
228
+ if (!exists) {
229
+ return { error: 'Scope not found', status: 404 };
230
+ }
231
+ return {
232
+ scopeEntity: {
233
+ type: scopeType,
234
+ id: scopeId,
235
+ },
236
+ };
237
+ };
170
238
  const serializeTestExecutionDetail = (details, retryEligibility) => {
171
239
  if (!details) {
172
240
  return { kind: 'test', available: false, retry_eligibility: retryEligibility };
@@ -221,6 +289,26 @@ const serializeGitHubActionsExecutionDetail = (details) => {
221
289
  jobs_count: details.jobs_count,
222
290
  };
223
291
  };
292
+ const buildExecutionRunDetailResponse = (project, run) => {
293
+ const retryEligibility = getExecutionRetryEligibility(project.id, run.id);
294
+ const details = run.kind === 'test'
295
+ ? serializeTestExecutionDetail(getTestExecutionDetails(project.id, run.id), retryEligibility)
296
+ : run.kind === 'github_actions'
297
+ ? { ...serializeGitHubActionsExecutionDetail(getGitHubActionsExecutionDetails(project.id, run.id)), retry_eligibility: retryEligibility }
298
+ : ['automation_commit', 'automation_push', 'automation_pull_request'].includes(run.kind)
299
+ ? { kind: run.kind, available: true, automation_stage: getAutomationStageDetails(project.id, run.id), retry_eligibility: retryEligibility }
300
+ : { kind: run.kind, available: true, retry_eligibility: retryEligibility };
301
+ const artifacts = loadExecutionArtifactSummariesForRuns(project.id, [run.id], MAX_ARTIFACTS_PER_RUN).get(run.id)
302
+ ?? { artifacts: [], artifact_count: 0 };
303
+ return {
304
+ run,
305
+ links: listExecutionRunLinks(project.id, run.id),
306
+ events: listLifecycleEvents(project.id, run.id).map(toCanonicalLifecycleEvent),
307
+ details,
308
+ agent_runs: listAgentRunsForExecutionRun(run.id),
309
+ ...artifacts,
310
+ };
311
+ };
224
312
  const formatWorktreeDeleteError = (target, branch, error) => {
225
313
  return `Unable to delete the ${target} worktree for branch "${branch}": ${getErrorMessage(error)}`;
226
314
  };
@@ -947,63 +1035,6 @@ const parseTestTargetSelection = (value) => {
947
1035
  }
948
1036
  return null;
949
1037
  };
950
- const unavailableTestRetry = (reason, status, message) => ({
951
- eligibility: { eligible: false, reason },
952
- error: { status, message },
953
- });
954
- const resolveTestRetry = (project, executionRun, details) => {
955
- if (!executionRun || executionRun.kind !== 'test' || !details) {
956
- return unavailableTestRetry('details_unavailable', 404, 'Test execution run not found');
957
- }
958
- if (!['failed', 'cancelled', 'interrupted'].includes(executionRun.status)) {
959
- return unavailableTestRetry('run_not_terminal', 409, 'Only failed, cancelled, or interrupted test runs can be retried.');
960
- }
961
- if (hasDirectRetryChild(executionRun.id)) {
962
- return unavailableTestRetry('retry_already_created', 409, 'A retry has already been created for this test run.');
963
- }
964
- if (!details.command_id) {
965
- return unavailableTestRetry('command_unidentifiable', 409, 'The original test command is no longer identifiable.');
966
- }
967
- const command = findProjectTestCommand(project, details.command_id);
968
- if (!command) {
969
- return unavailableTestRetry('command_missing', 404, 'Test command not found');
970
- }
971
- const selection = parseTestTargetSelection(details.selection);
972
- if (!selection) {
973
- return unavailableTestRetry('selection_invalid', 409, 'The original test selection is no longer valid.');
974
- }
975
- let workspace;
976
- if (details.ticket_id) {
977
- const ticket = getTicket(details.ticket_id);
978
- if (!ticket || ticket.project_id !== project.id) {
979
- return unavailableTestRetry('target_missing', 404, 'Ticket not found');
980
- }
981
- workspace = resolveTicketTestWorkspace(ticket);
982
- }
983
- else if (details.target_kind === 'repo_root' || details.target_kind === 'bundle') {
984
- workspace = resolveExplicitProjectTestWorkspace(project, details.target_kind, details.target_bundle_id);
985
- }
986
- else {
987
- return unavailableTestRetry('target_invalid', 409, 'The original test target is no longer valid.');
988
- }
989
- if ('error' in workspace) {
990
- return unavailableTestRetry('target_unavailable', workspace.status, workspace.error);
991
- }
992
- if (!isTestSelectionSupported(command, selection, workspace.cwd)) {
993
- return unavailableTestRetry('selection_unsupported', 409, 'The selected test command only supports running the full suite.');
994
- }
995
- if (selection.kind !== 'all' && !doesTestFileExist(workspace.cwd, selection.path, command)) {
996
- return unavailableTestRetry('test_file_missing', 404, 'Selected test file no longer exists in this target workspace.');
997
- }
998
- return {
999
- eligibility: { eligible: true, reason: null },
1000
- details,
1001
- executionRun,
1002
- command,
1003
- selection,
1004
- workspace,
1005
- };
1006
- };
1007
1038
  const resolveProjectFileSuggestionWorkspace = (project, options = {}) => {
1008
1039
  const ticketId = options.ticketId?.trim() || null;
1009
1040
  if (ticketId) {
@@ -2417,6 +2448,17 @@ app.post('/projects/:id/chat-sessions/:sessionId/attachments', (req, res) => {
2417
2448
  if (error) {
2418
2449
  return res.status(400).json({ error: getChatAttachmentUploadErrorMessage(error) });
2419
2450
  }
2451
+ const currentProject = getProjectById(req.params.id);
2452
+ if (!currentProject) {
2453
+ return res.status(404).json({ error: 'Not found' });
2454
+ }
2455
+ const currentChatSession = getProjectChatSession(currentProject.id, req.params.sessionId);
2456
+ if (!currentChatSession) {
2457
+ return res.status(404).json({ error: 'Not found' });
2458
+ }
2459
+ if (currentChatSession.agent_status === 'running') {
2460
+ return res.status(409).json({ error: 'Wait for the current chat response to finish before uploading images.' });
2461
+ }
2420
2462
  const files = (req.files ?? []);
2421
2463
  if (files.length === 0) {
2422
2464
  return res.status(400).json({ error: 'At least one image is required.' });
@@ -2434,11 +2476,11 @@ app.post('/projects/:id/chat-sessions/:sessionId/attachments', (req, res) => {
2434
2476
  try {
2435
2477
  const attachments = files.map(file => {
2436
2478
  const attachment = createChatAttachment({
2437
- chatSessionId: chatSession.id,
2479
+ chatSessionId: currentChatSession.id,
2438
2480
  originalName: file.originalname,
2439
2481
  mimeType: file.mimetype,
2440
2482
  buffer: file.buffer,
2441
- projectId: project.id,
2483
+ projectId: currentProject.id,
2442
2484
  });
2443
2485
  createdAttachmentIds.push(attachment.id);
2444
2486
  return attachment;
@@ -2709,6 +2751,17 @@ app.post('/projects/:id/spec-sessions/:sessionId/attachments', (req, res) => {
2709
2751
  if (error) {
2710
2752
  return res.status(400).json({ error: getSpecAttachmentUploadErrorMessage(error) });
2711
2753
  }
2754
+ const currentProject = getProjectById(req.params.id);
2755
+ if (!currentProject) {
2756
+ return res.status(404).json({ error: 'Not found' });
2757
+ }
2758
+ const currentSpecSession = getProjectSpecSession(currentProject.id, req.params.sessionId);
2759
+ if (!currentSpecSession) {
2760
+ return res.status(404).json({ error: 'Not found' });
2761
+ }
2762
+ if (currentSpecSession.agent_status === 'running') {
2763
+ return res.status(409).json({ error: 'Wait for the current spec response to finish before uploading images.' });
2764
+ }
2712
2765
  const files = (req.files ?? []);
2713
2766
  if (files.length === 0) {
2714
2767
  return res.status(400).json({ error: 'At least one image is required.' });
@@ -2726,11 +2779,11 @@ app.post('/projects/:id/spec-sessions/:sessionId/attachments', (req, res) => {
2726
2779
  try {
2727
2780
  const attachments = files.map(file => {
2728
2781
  const attachment = createSpecAttachment({
2729
- specSessionId: specSession.id,
2782
+ specSessionId: currentSpecSession.id,
2730
2783
  originalName: file.originalname,
2731
2784
  mimeType: file.mimetype,
2732
2785
  buffer: file.buffer,
2733
- projectId: project.id,
2786
+ projectId: currentProject.id,
2734
2787
  });
2735
2788
  createdAttachmentIds.push(attachment.id);
2736
2789
  return attachment;
@@ -6676,32 +6729,114 @@ app.patch('/projects/:id', (req, res) => {
6676
6729
  const updated = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
6677
6730
  res.json(serializeProject(updated ?? null));
6678
6731
  });
6679
- app.delete('/projects/:id', (req, res) => {
6680
- const project = getProjectById(req.params.id);
6732
+ const assertProjectDeletionEligible = (projectId, claimId) => {
6733
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
6681
6734
  if (!project) {
6682
- return res.status(404).json({ error: 'Not found' });
6735
+ throw new ProjectDeletionError(404, 'Not found');
6683
6736
  }
6684
- const projectCount = Number(db.prepare('SELECT COUNT(*) AS count FROM projects').get()?.count ?? 0);
6737
+ if (claimId ? project.deletion_claim_id !== claimId : Boolean(project.deletion_started_at)) {
6738
+ throw new ProjectDeletionError(409, 'Project deletion is already in progress');
6739
+ }
6740
+ const projectCount = Number(db.prepare(`
6741
+ SELECT COUNT(*) AS count FROM projects
6742
+ WHERE deletion_started_at IS NULL OR id = ?
6743
+ `).get(projectId)?.count ?? 0);
6685
6744
  if (projectCount <= 1) {
6686
- return res.status(409).json({ error: 'Cannot delete the last remaining project' });
6745
+ throw new ProjectDeletionError(409, 'Cannot delete the last remaining project');
6687
6746
  }
6688
- if (countInboundProjectLinks(project.id) > 0) {
6689
- return res.status(409).json({ error: 'Cannot delete a project that is linked from another project' });
6747
+ if (countInboundProjectLinks(projectId) > 0) {
6748
+ throw new ProjectDeletionError(409, 'Cannot delete a project that is linked from another project');
6690
6749
  }
6691
- const stats = getProjectTicketStats(project.id);
6750
+ const stats = getProjectTicketStats(projectId);
6692
6751
  if (stats.ticketCount > 0 || stats.worktreeCount > 0 || stats.bundleCount > 0) {
6693
- return res.status(409).json({
6694
- error: 'Cannot delete a project with existing tickets, bundles, or worktrees',
6695
- });
6752
+ throw new ProjectDeletionError(409, 'Cannot delete a project with existing tickets, bundles, or worktrees');
6696
6753
  }
6697
- db.transaction(() => {
6754
+ };
6755
+ const completeClaimedProjectDeletion = async (projectId, claimId) => {
6756
+ await runExclusiveExecutionArtifactMaintenance(() => reconcileProjectExecutionArtifacts(projectId, {
6757
+ emitter: io,
6758
+ beginCleanup: () => {
6759
+ const marked = db.prepare(`
6760
+ UPDATE projects
6761
+ SET deletion_cleanup_started_at = COALESCE(deletion_cleanup_started_at, CURRENT_TIMESTAMP)
6762
+ WHERE id = ? AND deletion_claim_id = ?
6763
+ `).run(projectId, claimId);
6764
+ if (marked.changes !== 1) {
6765
+ throw new ProjectDeletionError(409, 'Project deletion ownership was lost');
6766
+ }
6767
+ },
6768
+ finalize: () => {
6769
+ db.transaction(() => {
6770
+ assertProjectDeletionEligible(projectId, claimId);
6771
+ if (db.prepare('SELECT 1 FROM execution_artifact_registration_intents WHERE project_id = ? LIMIT 1').get(projectId)) {
6772
+ throw new ExecutionArtifactCleanupError('active_registration', 'Cannot delete project while an artifact registration is active');
6773
+ }
6774
+ if (db.prepare("SELECT 1 FROM execution_artifacts WHERE project_id = ? AND availability <> 'deleted' LIMIT 1").get(projectId)) {
6775
+ throw new ExecutionArtifactCleanupError('cleanup_failed', 'Cannot delete project because artifact file cleanup failed');
6776
+ }
6777
+ db.prepare("DELETE FROM execution_artifacts WHERE project_id = ? AND availability = 'deleted'").run(projectId);
6778
+ deleteProjectAttachments(projectId);
6779
+ db.prepare(`
6780
+ UPDATE execution_runs
6781
+ SET parent_run_id = NULL, retry_of_run_id = NULL
6782
+ WHERE project_id = ?
6783
+ `).run(projectId);
6784
+ const deleted = db.prepare('DELETE FROM projects WHERE id = ? AND deletion_claim_id = ?').run(projectId, claimId);
6785
+ if (deleted.changes !== 1) {
6786
+ throw new ProjectDeletionError(409, 'Project deletion ownership was lost');
6787
+ }
6788
+ })();
6789
+ },
6790
+ }));
6791
+ };
6792
+ export const resumeInterruptedProjectDeletions = async () => {
6793
+ for (const project of listInterruptedProjectDeletions()) {
6794
+ try {
6795
+ await completeClaimedProjectDeletion(project.id, project.deletion_claim_id);
6796
+ }
6797
+ catch (error) {
6798
+ console.error(`Failed to resume deletion for project ${project.id}`, error);
6799
+ }
6800
+ }
6801
+ };
6802
+ app.delete('/projects/:id', async (req, res) => {
6803
+ const claimId = randomUUID();
6804
+ try {
6805
+ db.transaction(() => {
6806
+ assertProjectDeletionEligible(req.params.id);
6807
+ const claimed = db.prepare(`
6808
+ UPDATE projects
6809
+ SET deletion_started_at = CURRENT_TIMESTAMP, deletion_claim_id = ?
6810
+ WHERE id = ? AND deletion_started_at IS NULL
6811
+ `).run(claimId, req.params.id);
6812
+ if (claimed.changes !== 1) {
6813
+ throw new ProjectDeletionError(409, 'Project deletion is already in progress');
6814
+ }
6815
+ })();
6816
+ }
6817
+ catch (error) {
6818
+ if (error instanceof ProjectDeletionError) {
6819
+ return res.status(error.status).json({ error: error.message });
6820
+ }
6821
+ throw error;
6822
+ }
6823
+ try {
6824
+ await completeClaimedProjectDeletion(req.params.id, claimId);
6825
+ }
6826
+ catch (error) {
6698
6827
  db.prepare(`
6699
- UPDATE execution_runs
6700
- SET parent_run_id = NULL, retry_of_run_id = NULL
6701
- WHERE project_id = ?
6702
- `).run(req.params.id);
6703
- db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
6704
- })();
6828
+ UPDATE projects
6829
+ SET deletion_started_at = NULL, deletion_claim_id = NULL, deletion_cleanup_started_at = NULL
6830
+ WHERE id = ? AND deletion_claim_id = ? AND deletion_cleanup_started_at IS NULL
6831
+ `).run(req.params.id, claimId);
6832
+ if (error instanceof ExecutionArtifactCleanupError) {
6833
+ return res.status(409).json({ error: error.message });
6834
+ }
6835
+ if (error instanceof ProjectDeletionError) {
6836
+ return res.status(error.status).json({ error: error.message });
6837
+ }
6838
+ throw error;
6839
+ }
6705
6840
  res.status(204).end();
6706
6841
  });
6707
6842
  app.post('/tickets', (req, res) => {
@@ -7582,56 +7717,139 @@ app.get('/projects/:id/lifecycle-events', (req, res) => {
7582
7717
  throw error;
7583
7718
  }
7584
7719
  });
7585
- app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7720
+ app.get('/projects/:id/execution-timeline', (req, res) => {
7586
7721
  const project = getProjectById(req.params.id);
7587
7722
  if (!project) {
7588
7723
  return res.status(404).json({ error: 'Not found' });
7589
7724
  }
7725
+ const query = parseLifecycleListQuery(req.query);
7726
+ if ('error' in query) {
7727
+ return res.status(400).json({ error: query.error });
7728
+ }
7729
+ const scope = parseExecutionTimelineScope(project.id, req.query);
7730
+ if ('error' in scope) {
7731
+ return res.status(scope.status ?? 400).json({ error: scope.error });
7732
+ }
7733
+ try {
7734
+ return res.json(listExecutionTimeline({
7735
+ project,
7736
+ kind: query.kind,
7737
+ statuses: query.statuses,
7738
+ cursor: query.cursor,
7739
+ limit: query.limit,
7740
+ scope: scope.scopeEntity,
7741
+ }));
7742
+ }
7743
+ catch (error) {
7744
+ if (error instanceof Error && error.message === 'Invalid execution run cursor') {
7745
+ return res.status(400).json({ error: 'cursor must be a valid execution run cursor' });
7746
+ }
7747
+ throw error;
7748
+ }
7749
+ });
7750
+ app.get('/projects/:id/execution-timeline/:runId', (req, res) => {
7751
+ const project = getProjectById(req.params.id);
7752
+ if (!project) {
7753
+ return res.status(404).json({ error: 'Not found' });
7754
+ }
7755
+ const scope = parseExecutionTimelineScope(project.id, req.query);
7756
+ if ('error' in scope) {
7757
+ return res.status(scope.status ?? 400).json({ error: scope.error });
7758
+ }
7590
7759
  const run = getExecutionRun(project.id, req.params.runId);
7591
7760
  if (!run) {
7592
7761
  return res.status(404).json({ error: 'Execution run not found' });
7593
7762
  }
7594
- const testDetails = run.kind === 'test' ? getTestExecutionDetails(project.id, run.id) : null;
7595
- const details = run.kind === 'test'
7596
- ? serializeTestExecutionDetail(testDetails, resolveTestRetry(project, run, testDetails).eligibility)
7597
- : run.kind === 'github_actions'
7598
- ? serializeGitHubActionsExecutionDetail(getGitHubActionsExecutionDetails(project.id, run.id))
7599
- : { kind: run.kind, available: true };
7600
7763
  const links = listExecutionRunLinks(project.id, run.id);
7601
- const agentRuns = listAgentRunsForExecutionRun(run.id);
7602
- return res.json({ run, links, details, agent_runs: agentRuns });
7764
+ if (scope.scopeEntity && !links.some(link => link.entity_type === scope.scopeEntity?.type && link.entity_id === scope.scopeEntity.id)) {
7765
+ return res.status(404).json({ error: 'Execution run not found' });
7766
+ }
7767
+ return res.json(buildExecutionRunDetailResponse(project, run));
7603
7768
  });
7604
- app.post('/projects/:id/execution-runs/:runId/retry', async (req, res) => {
7769
+ app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7605
7770
  const project = getProjectById(req.params.id);
7606
7771
  if (!project) {
7607
7772
  return res.status(404).json({ error: 'Not found' });
7608
7773
  }
7609
- const executionRun = getExecutionRun(project.id, req.params.runId);
7610
- const details = getTestExecutionDetails(project.id, req.params.runId);
7611
- const retry = resolveTestRetry(project, executionRun, details);
7612
- if ('error' in retry) {
7613
- return res.status(retry.error.status).json({ error: retry.error.message });
7774
+ const run = getExecutionRun(project.id, req.params.runId);
7775
+ if (!run) {
7776
+ return res.status(404).json({ error: 'Execution run not found' });
7614
7777
  }
7615
- const session = createTestSession({
7616
- projectId: project.id,
7617
- ticketId: retry.details.ticket_id,
7618
- targetKind: retry.workspace.target_kind,
7619
- targetBundleId: retry.workspace.target_bundle_id,
7620
- cwd: retry.workspace.cwd,
7621
- targetLabel: retry.workspace.target_label,
7778
+ return res.json(buildExecutionRunDetailResponse(project, run));
7779
+ });
7780
+ app.get('/projects/:id/execution-runs/:runId/artifacts/:artifactId', async (req, res) => {
7781
+ const project = getProjectById(req.params.id);
7782
+ if (!project || !getExecutionRun(project.id, req.params.runId)) {
7783
+ return res.status(404).json({ error: 'Not found' });
7784
+ }
7785
+ const retrieval = await openExecutionArtifactForRetrieval(project.id, req.params.runId, req.params.artifactId, io);
7786
+ if (retrieval.kind === 'not_found') {
7787
+ return res.status(404).json({ error: 'Not found' });
7788
+ }
7789
+ if (retrieval.kind === 'unavailable') {
7790
+ return res.status(410).json({ error: 'Artifact unavailable' });
7791
+ }
7792
+ if (retrieval.kind === 'external') {
7793
+ res.setHeader('Referrer-Policy', 'no-referrer');
7794
+ res.setHeader('Location', retrieval.url);
7795
+ return res.status(303).end();
7796
+ }
7797
+ res.setHeader('Content-Type', retrieval.mediaType);
7798
+ res.setHeader('Content-Length', retrieval.byteSize);
7799
+ res.setHeader('Content-Disposition', `attachment; filename="${retrieval.filename}"`);
7800
+ res.setHeader('X-Content-Type-Options', 'nosniff');
7801
+ const stream = retrieval.handle.createReadStream({ autoClose: true });
7802
+ stream.on('error', () => {
7803
+ console.error('Execution artifact stream failed', {
7804
+ project_id: project.id,
7805
+ execution_run_id: req.params.runId,
7806
+ artifact_id: req.params.artifactId,
7807
+ });
7808
+ res.destroy();
7622
7809
  });
7623
- try {
7624
- return res.status(201).json(await startTestSessionRun(session.id, project, {
7625
- command: retry.command,
7626
- selection: retry.selection,
7627
- retryOfRunId: retry.executionRun.id,
7628
- deleteOnComplete: true,
7629
- }, io));
7810
+ res.on('close', () => {
7811
+ if (!stream.destroyed) {
7812
+ stream.destroy();
7813
+ }
7814
+ });
7815
+ return stream.pipe(res);
7816
+ });
7817
+ app.post('/projects/:id/execution-runs/:runId/retry', async (req, res) => {
7818
+ const project = getProjectById(req.params.id);
7819
+ if (!project) {
7820
+ return res.status(404).json({ error: 'Not found' });
7630
7821
  }
7631
- catch (error) {
7632
- await deleteTestSession(session.id, io).catch(() => undefined);
7633
- return res.status(409).json({ error: getErrorMessage(error) });
7822
+ const resolution = resolveExecutionRetry(project.id, req.params.runId);
7823
+ const result = await dispatchExecutionRetry(resolution, io, async (retry) => {
7824
+ const session = createTestSession({
7825
+ projectId: project.id,
7826
+ ticketId: retry.ticketId,
7827
+ targetKind: retry.workspace.target_kind,
7828
+ targetBundleId: retry.workspace.target_bundle_id,
7829
+ cwd: retry.workspace.cwd,
7830
+ targetLabel: retry.workspace.target_label,
7831
+ });
7832
+ try {
7833
+ const started = await startTestSessionRun(session.id, project, {
7834
+ command: retry.command,
7835
+ selection: retry.selection,
7836
+ retryOfRunId: req.params.runId,
7837
+ deleteOnComplete: true,
7838
+ }, io);
7839
+ if (!started.run_id) {
7840
+ throw new Error('Test retry did not create a replacement execution');
7841
+ }
7842
+ return started.run_id;
7843
+ }
7844
+ catch (error) {
7845
+ await deleteTestSession(session.id, io).catch(() => undefined);
7846
+ throw error;
7847
+ }
7848
+ });
7849
+ if (!result.ok) {
7850
+ return res.status(result.status).json({ error: result.error, reason: result.reason });
7634
7851
  }
7852
+ return res.status(201).json(result.value);
7635
7853
  });
7636
7854
  app.post('/tickets/:id/test-sessions', (req, res) => {
7637
7855
  const ticket = getTicket(req.params.id);
@@ -7735,7 +7953,7 @@ app.post('/test-sessions/:id/stop', async (req, res) => {
7735
7953
  }
7736
7954
  });
7737
7955
  app.delete('/test-sessions/:id', async (req, res) => {
7738
- const deleted = await deleteTestSession(req.params.id, io, { purgeExecutionRun: true });
7956
+ const deleted = await deleteTestSession(req.params.id, io);
7739
7957
  if (!deleted) {
7740
7958
  return res.status(404).json({ error: 'Not found' });
7741
7959
  }
@@ -7890,6 +8108,7 @@ app.post('/tickets/:id/plan', (req, res) => {
7890
8108
  }
7891
8109
  const scheduling = startPlan(ticket, io, {
7892
8110
  branch,
8111
+ targetBundleId,
7893
8112
  });
7894
8113
  if (!scheduling.accepted) {
7895
8114
  const status = scheduling.reason === 'queue_failed' ? 503 : 409;
@@ -7934,12 +8153,21 @@ if (clientBuild) {
7934
8153
  });
7935
8154
  });
7936
8155
  }
8156
+ app.use((error, _req, res, next) => {
8157
+ if (error instanceof Error && error.message.includes('Project is being deleted')) {
8158
+ res.status(409).json({ error: 'Project deletion is in progress' });
8159
+ return;
8160
+ }
8161
+ next(error);
8162
+ });
7937
8163
  io.on('connection', (socket) => {
7938
8164
  console.log('client connected:', socket.id);
7939
8165
  registerTerminalSocketHandlers(io, socket);
7940
8166
  });
7941
8167
  const DEFAULT_PORT = 3001;
7942
8168
  let executionEvidenceCleanupTimer = null;
8169
+ let executionArtifactCleanupTimer = null;
8170
+ let executionArtifactCleanupPromise = null;
7943
8171
  let startServerPromise = null;
7944
8172
  let shutdownServerPromise = null;
7945
8173
  let shutdownRequested = false;
@@ -7966,6 +8194,41 @@ const startExecutionEvidenceCleanupTimer = () => {
7966
8194
  executionEvidenceCleanupTimer = setInterval(runExecutionEvidenceCleanup, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS);
7967
8195
  executionEvidenceCleanupTimer.unref();
7968
8196
  };
8197
+ export const runExecutionArtifactCleanup = (mode) => {
8198
+ if (executionArtifactCleanupPromise) {
8199
+ return executionArtifactCleanupPromise;
8200
+ }
8201
+ executionArtifactCleanupPromise = (async () => {
8202
+ if (mode === 'periodic') {
8203
+ await resumeInterruptedProjectDeletions();
8204
+ }
8205
+ await runExclusiveExecutionArtifactMaintenance(async () => {
8206
+ const intents = await reconcileExecutionArtifactRegistrationIntents({ mode });
8207
+ const artifacts = await reconcileFinalizedExecutionArtifacts({ emitter: io });
8208
+ const failures = intents.failed.length + artifacts.failed.length;
8209
+ if (failures > 0) {
8210
+ console.error(`Artifact reconciliation retained ${failures} item(s) for retry`);
8211
+ }
8212
+ if (intents.deleted + artifacts.deleted > 0) {
8213
+ console.log(`Reconciled ${intents.deleted} artifact registration intent(s) and expired ${artifacts.deleted} artifact(s)`);
8214
+ }
8215
+ });
8216
+ })().catch(error => {
8217
+ console.error('Failed to reconcile execution artifacts', error);
8218
+ }).finally(() => {
8219
+ executionArtifactCleanupPromise = null;
8220
+ });
8221
+ return executionArtifactCleanupPromise;
8222
+ };
8223
+ const startExecutionArtifactCleanupTimer = () => {
8224
+ if (executionArtifactCleanupTimer) {
8225
+ return;
8226
+ }
8227
+ executionArtifactCleanupTimer = setInterval(() => {
8228
+ void runExecutionArtifactCleanup('periodic');
8229
+ }, resolveArtifactCleanupIntervalMs());
8230
+ executionArtifactCleanupTimer.unref();
8231
+ };
7969
8232
  const resolveStartResult = (host) => {
7970
8233
  const address = httpServer.address();
7971
8234
  const { displayHost } = resolveDesktopServerHost(host);
@@ -7993,8 +8256,12 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
7993
8256
  return startServerPromise;
7994
8257
  }
7995
8258
  startServerPromise = (async () => {
8259
+ releaseInterruptedProjectDeletionClaims();
8260
+ await resumeInterruptedProjectDeletions();
7996
8261
  await reconcileStartupExecutions({ getGitHubConfig: getGitHubConnectionConfig });
7997
8262
  throwIfShutdownRequested();
8263
+ await runExecutionArtifactCleanup('startup');
8264
+ throwIfShutdownRequested();
7998
8265
  startGitHubActionsPolling(io);
7999
8266
  try {
8000
8267
  throwIfShutdownRequested();
@@ -8020,6 +8287,7 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
8020
8287
  runExecutionEvidenceCleanup();
8021
8288
  throwIfShutdownRequested();
8022
8289
  startExecutionEvidenceCleanupTimer();
8290
+ startExecutionArtifactCleanupTimer();
8023
8291
  return resolveStartResult(host);
8024
8292
  })().finally(() => {
8025
8293
  startServerPromise = null;
@@ -8041,6 +8309,11 @@ export const shutdownServer = async (signal) => {
8041
8309
  clearInterval(executionEvidenceCleanupTimer);
8042
8310
  executionEvidenceCleanupTimer = null;
8043
8311
  }
8312
+ if (executionArtifactCleanupTimer) {
8313
+ clearInterval(executionArtifactCleanupTimer);
8314
+ executionArtifactCleanupTimer = null;
8315
+ }
8316
+ await executionArtifactCleanupPromise;
8044
8317
  await stopMobileAccess();
8045
8318
  await clearAllTestSessions(io);
8046
8319
  await clearAllGitHubActionsSessions();