@straiffi/archon 1.7.4 → 1.7.6

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 (108) hide show
  1. package/dist/client/assets/{ActivityView-BgMz3NNT.js → ActivityView-BrZY8vYf.js} +1 -1
  2. package/dist/client/assets/{GitHubActionsDialog-CNqWDS3E.js → GitHubActionsDialog-CUfdVw97.js} +1 -1
  3. package/dist/client/assets/{TestsDialog-Cll7xJe0.js → TestsDialog-FXcnLOyf.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BL5dYiul.js → architectureDiagram-3BPJPVTR-D0PXGvSL.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-GPEHLZMM-Bi5x2v80.js → blockDiagram-GPEHLZMM-BCLAxnp5.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-AAUBKEIU-esEm54Ay.js → c4Diagram-AAUBKEIU-DVZ5yZWV.js} +1 -1
  7. package/dist/client/assets/channel-BCPCrThZ.js +1 -0
  8. package/dist/client/assets/{chunk-2J33WTMH-DACfY8xM.js → chunk-2J33WTMH-BGJYj1Lu.js} +1 -1
  9. package/dist/client/assets/{chunk-3OPIFGDE-DZNUiaUs.js → chunk-3OPIFGDE-DI5-yK3m.js} +1 -1
  10. package/dist/client/assets/{chunk-5ZQYHXKU-ClDfkMAp.js → chunk-5ZQYHXKU-Dnr5DDsb.js} +1 -1
  11. package/dist/client/assets/{chunk-727SXJPM-CCwA3GWT.js → chunk-727SXJPM-8HdFwn-P.js} +1 -1
  12. package/dist/client/assets/{chunk-AQP2D5EJ-Byl4DRZ9.js → chunk-AQP2D5EJ-C7x9t2uJ.js} +1 -1
  13. package/dist/client/assets/{chunk-CSCIHK7Q-CXymZQ7A.js → chunk-CSCIHK7Q-CrhwRPh5.js} +1 -1
  14. package/dist/client/assets/{chunk-KSCS5N6A-VV3D8g6a.js → chunk-KSCS5N6A-CdD62r5U.js} +1 -1
  15. package/dist/client/assets/{chunk-L5ZTLDWV-BL9N5GRI.js → chunk-L5ZTLDWV-CqQVvwc_.js} +1 -1
  16. package/dist/client/assets/{chunk-LZXEDZCA-DKNN-zno.js → chunk-LZXEDZCA-DLHrqoJ4.js} +2 -2
  17. package/dist/client/assets/{chunk-ND2GUHAM-CHCpQ1yr.js → chunk-ND2GUHAM-B0yOdiPF.js} +1 -1
  18. package/dist/client/assets/{chunk-NZK2D7GU-B0Dm74mt.js → chunk-NZK2D7GU-boM-MocY.js} +1 -1
  19. package/dist/client/assets/{chunk-O5CBEL6O-DAa5z2xG.js → chunk-O5CBEL6O-B0jgJn0t.js} +1 -1
  20. package/dist/client/assets/{chunk-WU5MYG2G-DrVJWKVR.js → chunk-WU5MYG2G-C_S_v-m2.js} +1 -1
  21. package/dist/client/assets/classDiagram-4FO5ZUOK-BdJlc_1i.js +1 -0
  22. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-Bidw5Zc1.js +1 -0
  23. package/dist/client/assets/{dagre-BM42HDAG-B0fsl8mE.js → dagre-BM42HDAG-DKl6Y8ya.js} +1 -1
  24. package/dist/client/assets/{diagram-2AECGRRQ-Cx_VEbSS.js → diagram-2AECGRRQ-6Lyn9DBp.js} +1 -1
  25. package/dist/client/assets/{diagram-5GNKFQAL-Cv7VVn5r.js → diagram-5GNKFQAL-wdS1vEkN.js} +1 -1
  26. package/dist/client/assets/{diagram-KO2AKTUF-C8Sl5czV.js → diagram-KO2AKTUF-CPih29jF.js} +1 -1
  27. package/dist/client/assets/{diagram-LMA3HP47-C3dZaLz2.js → diagram-LMA3HP47-Cr5Px74d.js} +1 -1
  28. package/dist/client/assets/{diagram-OG6HWLK6-BAU9DtTr.js → diagram-OG6HWLK6-i0PjSScd.js} +1 -1
  29. package/dist/client/assets/{erDiagram-TEJ5UH35-DpNc-eBD.js → erDiagram-TEJ5UH35-Dt4yOC9J.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-I6XJVG4X-DGwTAT1S.js → flowDiagram-I6XJVG4X-Bf6kvajC.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-6RSMTGT7-MPZhEgzJ.js → ganttDiagram-6RSMTGT7-DgxePMk_.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-CY0tHL5N.js → gitGraphDiagram-PVQCEYII-BZq2cmFS.js} +1 -1
  33. package/dist/client/assets/index-BJGHMaMz.css +2 -0
  34. package/dist/client/assets/{index-CJRgmWwY.js → index-CjRMYO1U.js} +106 -106
  35. package/dist/client/assets/{infoDiagram-5YYISTIA-BBZeKjf4.js → infoDiagram-5YYISTIA-Chij69KN.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CM_iFrES.js → ishikawaDiagram-YF4QCWOH-3qHCLFgD.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-JHISSGLW-Do_lY94X.js → journeyDiagram-JHISSGLW-DvRW8tbA.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-UN3LZRKU-brxg3j37.js → kanban-definition-UN3LZRKU-DjR2T5I1.js} +1 -1
  39. package/dist/client/assets/{line-PHHbm9rj.js → line-B9OzrQ1Y.js} +1 -1
  40. package/dist/client/assets/{mermaid-parser.core-CLxAkW69.js → mermaid-parser.core-DYcDor4d.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-CebvSmx9.js → mermaid.core-DG8QYo2u.js} +3 -3
  42. package/dist/client/assets/{mindmap-definition-RKZ34NQL-CX_-ZaJF.js → mindmap-definition-RKZ34NQL-f4QPF6b3.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-4H26LBE5-BJuHvql_.js → pieDiagram-4H26LBE5-CMkl2jBu.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-DiJll7l9.js → quadrantDiagram-W4KKPZXB-BXaUHzJi.js} +1 -1
  45. package/dist/client/assets/{requirementDiagram-4Y6WPE33-Bg-tXgcj.js → requirementDiagram-4Y6WPE33-B-Inuj_6.js} +1 -1
  46. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BoK2egfZ.js → sankeyDiagram-5OEKKPKP-BjsmZFYI.js} +1 -1
  47. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-DcrDWNzv.js → sequenceDiagram-3UESZ5HK-B1PAm6sX.js} +1 -1
  48. package/dist/client/assets/{stateDiagram-AJRCARHV-B995qt1S.js → stateDiagram-AJRCARHV-pLONNEN8.js} +1 -1
  49. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-kWPDF3wr.js +1 -0
  50. package/dist/client/assets/{timeline-definition-PNZ67QCA-DqSVAhqL.js → timeline-definition-PNZ67QCA-BLMkSXo_.js} +1 -1
  51. package/dist/client/assets/{vennDiagram-CIIHVFJN-D9wVraVY.js → vennDiagram-CIIHVFJN-CEwGRegG.js} +1 -1
  52. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-CLff9zVd.js → wardleyDiagram-YWT4CUSO-_md2IBXC.js} +1 -1
  53. package/dist/client/assets/{xychartDiagram-2RQKCTM6-BuHUMMH2.js → xychartDiagram-2RQKCTM6-DVzwfXHc.js} +1 -1
  54. package/dist/client/index.html +2 -2
  55. package/dist/server/db.js +282 -0
  56. package/dist/server/db.js.map +1 -1
  57. package/dist/server/index.js +332 -130
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/lib/attachments.js +16 -0
  60. package/dist/server/lib/attachments.js.map +1 -1
  61. package/dist/server/lib/automationResumeDecision.js +141 -0
  62. package/dist/server/lib/automationResumeDecision.js.map +1 -0
  63. package/dist/server/lib/buildSequences.js +52 -0
  64. package/dist/server/lib/buildSequences.js.map +1 -1
  65. package/dist/server/lib/executionArtifactCleanupState.js +12 -0
  66. package/dist/server/lib/executionArtifactCleanupState.js.map +1 -0
  67. package/dist/server/lib/executionArtifactRetention.js +405 -0
  68. package/dist/server/lib/executionArtifactRetention.js.map +1 -0
  69. package/dist/server/lib/executionArtifacts.js +705 -0
  70. package/dist/server/lib/executionArtifacts.js.map +1 -0
  71. package/dist/server/lib/executionRetry.js +9 -1
  72. package/dist/server/lib/executionRetry.js.map +1 -1
  73. package/dist/server/lib/executionRetryContext.js +21 -0
  74. package/dist/server/lib/executionRetryContext.js.map +1 -1
  75. package/dist/server/lib/executionRuns.js +62 -2
  76. package/dist/server/lib/executionRuns.js.map +1 -1
  77. package/dist/server/lib/executionTimeline.js +38 -10
  78. package/dist/server/lib/executionTimeline.js.map +1 -1
  79. package/dist/server/lib/lightweightPrompt.js +2 -2
  80. package/dist/server/lib/lightweightPrompt.js.map +1 -1
  81. package/dist/server/lib/projects.js +16 -3
  82. package/dist/server/lib/projects.js.map +1 -1
  83. package/dist/server/lib/pullRequestContent.js +148 -25
  84. package/dist/server/lib/pullRequestContent.js.map +1 -1
  85. package/dist/server/lib/queue.js +1 -0
  86. package/dist/server/lib/queue.js.map +1 -1
  87. package/dist/server/lib/runtimePaths.js +7 -0
  88. package/dist/server/lib/runtimePaths.js.map +1 -1
  89. package/dist/server/lib/ticketAutomationCommit.js +11 -4
  90. package/dist/server/lib/ticketAutomationCommit.js.map +1 -1
  91. package/dist/server/lib/ticketFollowUpOperations.js +19 -15
  92. package/dist/server/lib/ticketFollowUpOperations.js.map +1 -1
  93. package/dist/server/lib/ticketUndo.js +83 -31
  94. package/dist/server/lib/ticketUndo.js.map +1 -1
  95. package/dist/server/lib/tickets.js +67 -6
  96. package/dist/server/lib/tickets.js.map +1 -1
  97. package/dist/server/lib/types.js +11 -0
  98. package/dist/server/lib/types.js.map +1 -1
  99. package/dist/server/workers/followUp.js +94 -6
  100. package/dist/server/workers/followUp.js.map +1 -1
  101. package/dist/server/workers/review.js +1 -0
  102. package/dist/server/workers/review.js.map +1 -1
  103. package/package.json +1 -1
  104. package/dist/client/assets/channel-CqNHF3FD.js +0 -1
  105. package/dist/client/assets/classDiagram-4FO5ZUOK-BjEgQwKg.js +0 -1
  106. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-gGomH-F_.js +0 -1
  107. package/dist/client/assets/index-mRkJWu70.css +0 -2
  108. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Dn8P7Sa1.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';
@@ -49,15 +49,18 @@ import { formatBuildOpenQuestionAnswersPrompt, getLatestBuildOpenQuestionBatchSt
49
49
  import { acceptBuildOpenQuestionResolution } from './lib/buildOpenQuestionOperations.js';
50
50
  import { completeAutomationProgression, createAutomationReviewFix, finishAutomationReviewFix, getAutomationProgression, recoverLegacyAutomationProgression, recoverStoppedSideEffectProgression, runAutomationProgression, stopAutomationProgression, waitAutomationProgression } from './lib/automationProgression.js';
51
51
  import { reconcileAutomationStage } from './lib/automationReconciliation.js';
52
+ import { pausedRunBelongsToProgression, resolveAutomationResumeDecision } from './lib/automationResumeDecision.js';
52
53
  import { getAutomationStageDetails, } from './lib/automationStageDetails.js';
53
- import { clearAutomationPause, countBlockingFindings, depthAtLeast, getBundleAutomationState, isAutomationDepth, recordAutomationPause, resolveAutomationConfig, upsertBundleAutomationState, } from './lib/ticketAutomation.js';
54
+ import { buildAutomationFixInstruction, clearAutomationPause, countBlockingFindings, depthAtLeast, getBundleAutomationState, isAutomationDepth, recordAutomationPause, resolveAutomationConfig, upsertBundleAutomationState, } from './lib/ticketAutomation.js';
54
55
  import { runAutomationCommitChain } from './lib/ticketAutomationCommit.js';
55
56
  import { getDiscoveredSkills, normalizeSkillNames } from './lib/skills.js';
56
57
  import { clearAllPreviewProxies } from './lib/previewProxy.js';
57
58
  import { createExecutionRun, addExecutionRunLink, addExecutionRunLinks, getExecutionRun, hasActiveBuildExecutionRunForTicket, getGitHubActionsExecutionDetails, getTestExecutionDetails, GitHubActionsDispatchConflictError, listExecutionRunLinks, listExecutionRuns, listLifecycleEvents, listProjectLifecycleEvents, toCanonicalLifecycleEvent, transitionExecutionRun, withExecutionRunTransaction, } from './lib/executionRuns.js';
58
59
  import { dispatchExecutionRetry, getExecutionRetryEligibility, resolveExecutionRetry } from './lib/executionRetry.js';
59
60
  import { listExecutionTimeline } from './lib/executionTimeline.js';
61
+ import { loadExecutionArtifactSummariesForRuns, openExecutionArtifactForRetrieval, } from './lib/executionArtifacts.js';
60
62
  import { cleanupAllExecutionEvidence, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS, resolveExecutionEvidenceRetentionDays, } from './lib/executionRunRetention.js';
63
+ import { ExecutionArtifactCleanupError, reconcileExecutionArtifactRegistrationIntents, reconcileFinalizedExecutionArtifacts, reconcileProjectExecutionArtifacts, resolveArtifactCleanupIntervalMs, } from './lib/executionArtifactRetention.js';
61
64
  import { shutdownRealtimeServer } from './lib/shutdown.js';
62
65
  import { resolveClientBuildPaths, shouldServeClientApp, STATIC_CLIENT_DIRECTORY_OPTIONS, STATIC_CLIENT_INDEX_OPTIONS, } from './lib/staticClient.js';
63
66
  import { deleteIntegrationConnection, getGitHubConnectionConfig, getJiraConnectionConfig, listIntegrationConnections, upsertIntegrationConnection, } from './lib/integrations/index.js';
@@ -72,7 +75,7 @@ import { PlanningReferenceError, resolveExternalPlanningDescription } from './li
72
75
  import { refreshIfNeeded as refreshAssignedIssues, clearCachedAssignedIssues } from './lib/integrations/assignedIssuesCache.js';
73
76
  import { buildCommitMessagePrompt, normalizeGeneratedCommitMessage } from './lib/commitMessage.js';
74
77
  import { BUNDLE_PULL_REQUEST_SYNC_TTL_MS, getBundlePullRequestRecord, hasOpenBundlePullRequest, serializeBundlePullRequest, shouldSyncBundlePullRequest, syncTrackedBundlePullRequestIfNeeded, upsertBundlePullRequest, upsertBundlePullRequestFromGitHub, } from './lib/bundlePullRequests.js';
75
- import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listProjects, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, } from './lib/projects.js';
78
+ import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listInterruptedProjectDeletions, listProjects, releaseInterruptedProjectDeletionClaims, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, } from './lib/projects.js';
76
79
  import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopTicket } from './lib/run.js';
77
80
  import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
78
81
  import { getBundleBuildBlockerResponse, shouldStartBundleBuildNow, transitionTicketState, } from './lib/ticketWorkflowOperations.js';
@@ -94,7 +97,30 @@ import { exportSpecToDocs, checkSpecExportCollision } from './lib/specExport.js'
94
97
  import { createTicketDependency, deleteTicketDependenciesForTicket, deleteTicketDependency, getTicketDependency, isTicketRunSetupReady, listTicketDependencies, validateCreateTicketDependency, } from './lib/ticketDependencies.js';
95
98
  import { startBuild } from './workers/build.js';
96
99
  import { expandPlanningTicketOutline, generatePlanningTicketOutline, generatePlanText, prepareLinkedPlanningResearch, startPlan, stopAllPlanAgents, } from './workers/plan.js';
97
- import { EXECUTION_RUN_KINDS, validateWorkflowContext } from './lib/types.js';
100
+ import { EXECUTION_RUN_KINDS, MAX_ARTIFACTS_PER_RUN, validateWorkflowContext } from './lib/types.js';
101
+ class ProjectDeletionError extends Error {
102
+ status;
103
+ constructor(status, message) {
104
+ super(message);
105
+ this.name = 'ProjectDeletionError';
106
+ this.status = status;
107
+ }
108
+ }
109
+ let executionArtifactMaintenanceTail = Promise.resolve();
110
+ const runExclusiveExecutionArtifactMaintenance = async (task) => {
111
+ const previous = executionArtifactMaintenanceTail;
112
+ let release = () => undefined;
113
+ executionArtifactMaintenanceTail = new Promise(resolve => {
114
+ release = resolve;
115
+ });
116
+ await previous;
117
+ try {
118
+ return await task();
119
+ }
120
+ finally {
121
+ release();
122
+ }
123
+ };
98
124
  const getErrorMessage = (error) => {
99
125
  return error instanceof Error ? error.message : String(error);
100
126
  };
@@ -112,6 +138,9 @@ const requireExistingProject = (projectId) => {
112
138
  if (!project) {
113
139
  return { status: 404, error: 'Not found' };
114
140
  }
141
+ if (project.deletion_started_at) {
142
+ return { status: 409, error: 'Project deletion is in progress' };
143
+ }
115
144
  return { project };
116
145
  };
117
146
  const requireProjectMemoryProject = (projectId) => {
@@ -261,6 +290,26 @@ const serializeGitHubActionsExecutionDetail = (details) => {
261
290
  jobs_count: details.jobs_count,
262
291
  };
263
292
  };
293
+ const buildExecutionRunDetailResponse = (project, run) => {
294
+ const retryEligibility = getExecutionRetryEligibility(project.id, run.id);
295
+ const details = run.kind === 'test'
296
+ ? serializeTestExecutionDetail(getTestExecutionDetails(project.id, run.id), retryEligibility)
297
+ : run.kind === 'github_actions'
298
+ ? { ...serializeGitHubActionsExecutionDetail(getGitHubActionsExecutionDetails(project.id, run.id)), retry_eligibility: retryEligibility }
299
+ : ['automation_commit', 'automation_push', 'automation_pull_request'].includes(run.kind)
300
+ ? { kind: run.kind, available: true, automation_stage: getAutomationStageDetails(project.id, run.id), retry_eligibility: retryEligibility }
301
+ : { kind: run.kind, available: true, retry_eligibility: retryEligibility };
302
+ const artifacts = loadExecutionArtifactSummariesForRuns(project.id, [run.id], MAX_ARTIFACTS_PER_RUN).get(run.id)
303
+ ?? { artifacts: [], artifact_count: 0 };
304
+ return {
305
+ run,
306
+ links: listExecutionRunLinks(project.id, run.id),
307
+ events: listLifecycleEvents(project.id, run.id).map(toCanonicalLifecycleEvent),
308
+ details,
309
+ agent_runs: listAgentRunsForExecutionRun(run.id),
310
+ ...artifacts,
311
+ };
312
+ };
264
313
  const formatWorktreeDeleteError = (target, branch, error) => {
265
314
  return `Unable to delete the ${target} worktree for branch "${branch}": ${getErrorMessage(error)}`;
266
315
  };
@@ -2400,6 +2449,17 @@ app.post('/projects/:id/chat-sessions/:sessionId/attachments', (req, res) => {
2400
2449
  if (error) {
2401
2450
  return res.status(400).json({ error: getChatAttachmentUploadErrorMessage(error) });
2402
2451
  }
2452
+ const currentProject = getProjectById(req.params.id);
2453
+ if (!currentProject) {
2454
+ return res.status(404).json({ error: 'Not found' });
2455
+ }
2456
+ const currentChatSession = getProjectChatSession(currentProject.id, req.params.sessionId);
2457
+ if (!currentChatSession) {
2458
+ return res.status(404).json({ error: 'Not found' });
2459
+ }
2460
+ if (currentChatSession.agent_status === 'running') {
2461
+ return res.status(409).json({ error: 'Wait for the current chat response to finish before uploading images.' });
2462
+ }
2403
2463
  const files = (req.files ?? []);
2404
2464
  if (files.length === 0) {
2405
2465
  return res.status(400).json({ error: 'At least one image is required.' });
@@ -2417,11 +2477,11 @@ app.post('/projects/:id/chat-sessions/:sessionId/attachments', (req, res) => {
2417
2477
  try {
2418
2478
  const attachments = files.map(file => {
2419
2479
  const attachment = createChatAttachment({
2420
- chatSessionId: chatSession.id,
2480
+ chatSessionId: currentChatSession.id,
2421
2481
  originalName: file.originalname,
2422
2482
  mimeType: file.mimetype,
2423
2483
  buffer: file.buffer,
2424
- projectId: project.id,
2484
+ projectId: currentProject.id,
2425
2485
  });
2426
2486
  createdAttachmentIds.push(attachment.id);
2427
2487
  return attachment;
@@ -2692,6 +2752,17 @@ app.post('/projects/:id/spec-sessions/:sessionId/attachments', (req, res) => {
2692
2752
  if (error) {
2693
2753
  return res.status(400).json({ error: getSpecAttachmentUploadErrorMessage(error) });
2694
2754
  }
2755
+ const currentProject = getProjectById(req.params.id);
2756
+ if (!currentProject) {
2757
+ return res.status(404).json({ error: 'Not found' });
2758
+ }
2759
+ const currentSpecSession = getProjectSpecSession(currentProject.id, req.params.sessionId);
2760
+ if (!currentSpecSession) {
2761
+ return res.status(404).json({ error: 'Not found' });
2762
+ }
2763
+ if (currentSpecSession.agent_status === 'running') {
2764
+ return res.status(409).json({ error: 'Wait for the current spec response to finish before uploading images.' });
2765
+ }
2695
2766
  const files = (req.files ?? []);
2696
2767
  if (files.length === 0) {
2697
2768
  return res.status(400).json({ error: 'At least one image is required.' });
@@ -2709,11 +2780,11 @@ app.post('/projects/:id/spec-sessions/:sessionId/attachments', (req, res) => {
2709
2780
  try {
2710
2781
  const attachments = files.map(file => {
2711
2782
  const attachment = createSpecAttachment({
2712
- specSessionId: specSession.id,
2783
+ specSessionId: currentSpecSession.id,
2713
2784
  originalName: file.originalname,
2714
2785
  mimeType: file.mimetype,
2715
2786
  buffer: file.buffer,
2716
- projectId: project.id,
2787
+ projectId: currentProject.id,
2717
2788
  });
2718
2789
  createdAttachmentIds.push(attachment.id);
2719
2790
  return attachment;
@@ -3884,25 +3955,6 @@ app.post('/bundles/:id/follow-up', (req, res) => {
3884
3955
  });
3885
3956
  const AUTOMATION_SIDE_EFFECT_KINDS = new Set(['automation_commit', 'automation_push', 'automation_pull_request']);
3886
3957
  const AUTOMATION_WORKER_PAUSE_KINDS = new Set(['build', 'review', 'build_follow_up', 'automation_review_fix']);
3887
- const pausedRunBelongsToProgression = (projectId, bundleId, progressionRunId, pausedRunId) => {
3888
- const pausedRun = getExecutionRun(projectId, pausedRunId);
3889
- const progression = getExecutionRun(projectId, progressionRunId);
3890
- if (!pausedRun || !progression || progression.kind !== 'automation_progression') {
3891
- return false;
3892
- }
3893
- const ownsBundle = listExecutionRunLinks(projectId, pausedRun.id).some(link => (link.entity_type === 'bundle'
3894
- && link.entity_id === bundleId
3895
- && (link.relationship === 'primary' || link.relationship === 'context')));
3896
- if (!ownsBundle) {
3897
- return false;
3898
- }
3899
- if (pausedRun.parent_run_id === progressionRunId) {
3900
- return true;
3901
- }
3902
- const parent = pausedRun.parent_run_id ? getExecutionRun(projectId, pausedRun.parent_run_id) : null;
3903
- return parent?.kind === 'automation_review_fix' && parent.parent_run_id === progressionRunId
3904
- || progression.parent_run_id === pausedRun.id;
3905
- };
3906
3958
  app.post('/bundles/:id/resume-automation', async (req, res) => {
3907
3959
  const result = getRequiredProject(req);
3908
3960
  if ('error' in result) {
@@ -3916,15 +3968,38 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
3916
3968
  if (!bundleState || !bundleState.automation_paused_reason) {
3917
3969
  return res.status(409).json({ error: 'Bundle automation is not paused' });
3918
3970
  }
3919
- const liveAutomationConfig = resolveAutomationConfig(bundle, result.project);
3920
- if (hasActiveBuildOpenQuestionBatch(result.project.id, bundle.id)) {
3921
- return res.status(409).json({ error: 'Build open question resolution is still in progress' });
3922
- }
3923
- if (bundleState.automation_paused_code === 'open_questions'
3924
- && liveAutomationConfig.pauseOnOpenQuestions
3925
- && listBuildOpenQuestionsForBundle(result.project.id, bundle.id).length > 0) {
3926
- return res.status(409).json({ error: 'Resolve or dismiss all Build open questions before resuming automation' });
3927
- }
3971
+ const resolveCurrentDecision = () => {
3972
+ const currentState = getBundleAutomationState(result.project.id, bundle.id);
3973
+ if (!currentState?.automation_paused_reason) {
3974
+ return null;
3975
+ }
3976
+ return resolveAutomationResumeDecision({
3977
+ projectId: result.project.id,
3978
+ bundleId: bundle.id,
3979
+ state: currentState,
3980
+ config: resolveAutomationConfig(getBundle(bundle.id, result.project.id), getProjectById(result.project.id)),
3981
+ tickets: listDetailedBundleTickets(result.project.id, bundle.id),
3982
+ openQuestionCount: listBuildOpenQuestionsForBundle(result.project.id, bundle.id).length,
3983
+ questionResolutionActive: hasActiveBuildOpenQuestionBatch(result.project.id, bundle.id),
3984
+ });
3985
+ };
3986
+ const decision = resolveCurrentDecision();
3987
+ if (!decision || decision.status !== 'enabled') {
3988
+ const error = decision?.status === 'hidden' && decision.reason === 'question_resolution_active'
3989
+ ? 'Build open question resolution is still in progress'
3990
+ : decision?.status === 'hidden' && decision.reason === 'resolve_open_questions'
3991
+ ? 'Resolve or dismiss all Build open questions before resuming automation'
3992
+ : decision?.status === 'disabled' && decision.reason === 'invalid_stage_lineage'
3993
+ ? 'The paused automation stage does not belong to the active progression'
3994
+ : 'Bundle automation cannot be resumed in its current state';
3995
+ return res.status(409).json({ error, automation_resume_decision: decision });
3996
+ }
3997
+ const revalidateDecision = () => {
3998
+ const current = resolveCurrentDecision();
3999
+ return current?.status === 'enabled'
4000
+ && current.outcome === decision.outcome
4001
+ && current.owner_ticket_id === decision.owner_ticket_id;
4002
+ };
3928
4003
  let progressionRunId = bundleState.automation_progression_run_id;
3929
4004
  if (!progressionRunId) {
3930
4005
  try {
@@ -3952,12 +4027,13 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
3952
4027
  catch (error) {
3953
4028
  return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
3954
4029
  }
4030
+ if (!revalidateDecision()) {
4031
+ return res.status(409).json({ error: 'Bundle automation state changed before Resume could start' });
4032
+ }
3955
4033
  let sideEffectResume = null;
3956
- let pausedRunKind = null;
3957
4034
  if (bundleState.automation_paused_stage_run_id) {
3958
4035
  const stageRunId = bundleState.automation_paused_stage_run_id;
3959
4036
  const stage = getExecutionRun(result.project.id, stageRunId);
3960
- pausedRunKind = stage?.kind ?? null;
3961
4037
  if (!stage
3962
4038
  || (!AUTOMATION_SIDE_EFFECT_KINDS.has(stage.kind) && !AUTOMATION_WORKER_PAUSE_KINDS.has(stage.kind))
3963
4039
  || !pausedRunBelongsToProgression(result.project.id, bundle.id, progressionRunId, stageRunId)) {
@@ -4021,33 +4097,7 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4021
4097
  }
4022
4098
  }
4023
4099
  }
4024
- if (liveAutomationConfig.depth === 'manual') {
4025
- completeAutomationProgression({
4026
- projectId: result.project.id,
4027
- bundleId: bundle.id,
4028
- progressionRunId,
4029
- domainStatus: 'manual_boundary_reached',
4030
- broadcaster: io,
4031
- });
4032
- for (const ticket of listDetailedBundleTickets(result.project.id, bundle.id)) {
4033
- io.emit('ticket:updated', ticket);
4034
- }
4035
- return res.json(serializeBundleDetails(result.project, bundle));
4036
- }
4037
- const reviewOwnerSource = bundleState.automation_paused_code === 'open_questions'
4038
- ? listBundleSliceTickets(result.project.id, bundle.id, 'build')
4039
- .find(ticket => ticket.agent_status === 'done') ?? null
4040
- : resolveBundleReviewOwner(result.project.id, bundle.id)
4041
- ?? listBundleSliceTickets(result.project.id, bundle.id, 'review')[0]
4042
- ?? (bundleState.automation_paused_code === 'review' && (pausedRunKind === 'build' || pausedRunKind === 'build_follow_up')
4043
- ? listBundleSliceTickets(result.project.id, bundle.id, 'build')
4044
- .find(ticket => ticket.agent_status === 'done') ?? null
4045
- : null)
4046
- ?? null;
4047
- if (!reviewOwnerSource) {
4048
- return res.status(409).json({ error: 'No review ticket to resume from' });
4049
- }
4050
- const owner = getTicket(reviewOwnerSource.id);
4100
+ const owner = getTicket(decision.owner_ticket_id);
4051
4101
  if (!owner) {
4052
4102
  return res.status(409).json({ error: 'No review ticket to resume from' });
4053
4103
  }
@@ -4081,6 +4131,9 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4081
4131
  };
4082
4132
  };
4083
4133
  if (sideEffectResume) {
4134
+ if (!decision.outcome.startsWith('reconcile_') || !revalidateDecision()) {
4135
+ return res.status(409).json({ error: 'Bundle automation state changed before Resume could start' });
4136
+ }
4084
4137
  try {
4085
4138
  runAutomationProgression({
4086
4139
  projectId: result.project.id,
@@ -4099,27 +4152,17 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4099
4152
  }
4100
4153
  return res.json(serializeBundleDetails(result.project, bundle));
4101
4154
  }
4102
- if (owner.state === 'review' && owner.agent_status === 'running') {
4103
- return res.status(409).json({ error: 'Review already running' });
4104
- }
4105
4155
  if (owner.state === 'review' && owner.agent_status !== 'running') {
4106
- const latestRun = getLatestReviewRunForContext({
4107
- ticketId: owner.id,
4108
- projectId: owner.project_id,
4109
- worktreeBundleId: owner.worktree_bundle_id,
4110
- });
4111
- const findings = latestRun ? listReviewFindings(latestRun.id) : [];
4112
- const blockingCount = countBlockingFindings(findings, automationConfig);
4113
4156
  const iteration = bundleState.review_iteration_count ?? 0;
4114
- if (owner.can_re_review
4115
- && automationConfig.reviewFixEnabled
4116
- && blockingCount > 0
4117
- && iteration < automationConfig.reviewMaxIterations) {
4157
+ if (decision.outcome === 'start_review_fix') {
4118
4158
  let reviewFixRunId = null;
4119
4159
  let followUpResult;
4120
4160
  const previousIteration = bundleState.review_iteration_count ?? 0;
4121
4161
  const previousFixLoopActive = bundleState.fix_loop_active;
4122
4162
  try {
4163
+ if (!revalidateDecision()) {
4164
+ return res.status(409).json({ error: 'Bundle automation state changed before Resume could start' });
4165
+ }
4123
4166
  const progression = getAutomationProgression(result.project.id, bundle.id);
4124
4167
  if (!progression || progression.id !== progressionRunId) {
4125
4168
  return res.status(409).json({ error: 'Automation progression ownership is invalid' });
@@ -4149,10 +4192,11 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4149
4192
  ticketId: owner.id,
4150
4193
  requestedProjectId: result.project.id,
4151
4194
  payload: {
4152
- instruction: `Fix the code review findings at or above "${automationConfig.reviewSeverity}" severity. Address each blocking finding directly and minimally. Do not introduce unrelated changes, reformatting, or refactors. Do not commit.`,
4195
+ instruction: buildAutomationFixInstruction(automationConfig.reviewSeverity),
4153
4196
  mode: 'build',
4154
4197
  },
4155
4198
  broadcaster: io,
4199
+ persistTriggerMessage: false,
4156
4200
  initiation: {
4157
4201
  initiator: 'automation',
4158
4202
  causalParentRunId: reviewFix.runId,
@@ -4206,8 +4250,11 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4206
4250
  return res.status(followUpResult.status).json({ error: followUpResult.error });
4207
4251
  }
4208
4252
  }
4209
- else if (depthAtLeast(automationConfig.depth, 'auto_commit')) {
4253
+ else if (decision.outcome === 'continue_commit_chain') {
4210
4254
  try {
4255
+ if (!revalidateDecision()) {
4256
+ return res.status(409).json({ error: 'Bundle automation state changed before Resume could start' });
4257
+ }
4211
4258
  runAutomationProgression({
4212
4259
  projectId: result.project.id,
4213
4260
  bundleId: bundle.id,
@@ -4220,7 +4267,10 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4220
4267
  return res.status(409).json({ error: error instanceof Error ? error.message : String(error) });
4221
4268
  }
4222
4269
  }
4223
- else {
4270
+ else if (decision.outcome === 'rerun_review') {
4271
+ if (!revalidateDecision()) {
4272
+ return res.status(409).json({ error: 'Bundle automation state changed before Resume could start' });
4273
+ }
4224
4274
  const reviewResult = rerunTicketReviewOperation(owner, io, {
4225
4275
  automatic: true,
4226
4276
  initiation: { causalParentRunId: progressionRunId },
@@ -4230,8 +4280,14 @@ app.post('/bundles/:id/resume-automation', async (req, res) => {
4230
4280
  return res.status(reviewResult.status).json({ error: reviewResult.error });
4231
4281
  }
4232
4282
  }
4283
+ else {
4284
+ return res.status(409).json({ error: 'The selected Resume outcome no longer matches the bundle state' });
4285
+ }
4233
4286
  }
4234
- else if (owner.state === 'build' && owner.agent_status === 'done') {
4287
+ else if (owner.state === 'build' && owner.agent_status === 'done' && decision.outcome === 'start_review') {
4288
+ if (!revalidateDecision()) {
4289
+ return res.status(409).json({ error: 'Bundle automation state changed before Resume could start' });
4290
+ }
4235
4291
  const reviewResult = transitionBundleToReview(result.project.id, bundle.id, io, {
4236
4292
  automatic: true,
4237
4293
  initiation: { causalParentRunId: progressionRunId },
@@ -6659,32 +6715,114 @@ app.patch('/projects/:id', (req, res) => {
6659
6715
  const updated = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
6660
6716
  res.json(serializeProject(updated ?? null));
6661
6717
  });
6662
- app.delete('/projects/:id', (req, res) => {
6663
- const project = getProjectById(req.params.id);
6718
+ const assertProjectDeletionEligible = (projectId, claimId) => {
6719
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
6664
6720
  if (!project) {
6665
- return res.status(404).json({ error: 'Not found' });
6721
+ throw new ProjectDeletionError(404, 'Not found');
6666
6722
  }
6667
- const projectCount = Number(db.prepare('SELECT COUNT(*) AS count FROM projects').get()?.count ?? 0);
6723
+ if (claimId ? project.deletion_claim_id !== claimId : Boolean(project.deletion_started_at)) {
6724
+ throw new ProjectDeletionError(409, 'Project deletion is already in progress');
6725
+ }
6726
+ const projectCount = Number(db.prepare(`
6727
+ SELECT COUNT(*) AS count FROM projects
6728
+ WHERE deletion_started_at IS NULL OR id = ?
6729
+ `).get(projectId)?.count ?? 0);
6668
6730
  if (projectCount <= 1) {
6669
- return res.status(409).json({ error: 'Cannot delete the last remaining project' });
6731
+ throw new ProjectDeletionError(409, 'Cannot delete the last remaining project');
6670
6732
  }
6671
- if (countInboundProjectLinks(project.id) > 0) {
6672
- return res.status(409).json({ error: 'Cannot delete a project that is linked from another project' });
6733
+ if (countInboundProjectLinks(projectId) > 0) {
6734
+ throw new ProjectDeletionError(409, 'Cannot delete a project that is linked from another project');
6673
6735
  }
6674
- const stats = getProjectTicketStats(project.id);
6736
+ const stats = getProjectTicketStats(projectId);
6675
6737
  if (stats.ticketCount > 0 || stats.worktreeCount > 0 || stats.bundleCount > 0) {
6676
- return res.status(409).json({
6677
- error: 'Cannot delete a project with existing tickets, bundles, or worktrees',
6678
- });
6738
+ throw new ProjectDeletionError(409, 'Cannot delete a project with existing tickets, bundles, or worktrees');
6679
6739
  }
6680
- db.transaction(() => {
6740
+ };
6741
+ const completeClaimedProjectDeletion = async (projectId, claimId) => {
6742
+ await runExclusiveExecutionArtifactMaintenance(() => reconcileProjectExecutionArtifacts(projectId, {
6743
+ emitter: io,
6744
+ beginCleanup: () => {
6745
+ const marked = db.prepare(`
6746
+ UPDATE projects
6747
+ SET deletion_cleanup_started_at = COALESCE(deletion_cleanup_started_at, CURRENT_TIMESTAMP)
6748
+ WHERE id = ? AND deletion_claim_id = ?
6749
+ `).run(projectId, claimId);
6750
+ if (marked.changes !== 1) {
6751
+ throw new ProjectDeletionError(409, 'Project deletion ownership was lost');
6752
+ }
6753
+ },
6754
+ finalize: () => {
6755
+ db.transaction(() => {
6756
+ assertProjectDeletionEligible(projectId, claimId);
6757
+ if (db.prepare('SELECT 1 FROM execution_artifact_registration_intents WHERE project_id = ? LIMIT 1').get(projectId)) {
6758
+ throw new ExecutionArtifactCleanupError('active_registration', 'Cannot delete project while an artifact registration is active');
6759
+ }
6760
+ if (db.prepare("SELECT 1 FROM execution_artifacts WHERE project_id = ? AND availability <> 'deleted' LIMIT 1").get(projectId)) {
6761
+ throw new ExecutionArtifactCleanupError('cleanup_failed', 'Cannot delete project because artifact file cleanup failed');
6762
+ }
6763
+ db.prepare("DELETE FROM execution_artifacts WHERE project_id = ? AND availability = 'deleted'").run(projectId);
6764
+ deleteProjectAttachments(projectId);
6765
+ db.prepare(`
6766
+ UPDATE execution_runs
6767
+ SET parent_run_id = NULL, retry_of_run_id = NULL
6768
+ WHERE project_id = ?
6769
+ `).run(projectId);
6770
+ const deleted = db.prepare('DELETE FROM projects WHERE id = ? AND deletion_claim_id = ?').run(projectId, claimId);
6771
+ if (deleted.changes !== 1) {
6772
+ throw new ProjectDeletionError(409, 'Project deletion ownership was lost');
6773
+ }
6774
+ })();
6775
+ },
6776
+ }));
6777
+ };
6778
+ export const resumeInterruptedProjectDeletions = async () => {
6779
+ for (const project of listInterruptedProjectDeletions()) {
6780
+ try {
6781
+ await completeClaimedProjectDeletion(project.id, project.deletion_claim_id);
6782
+ }
6783
+ catch (error) {
6784
+ console.error(`Failed to resume deletion for project ${project.id}`, error);
6785
+ }
6786
+ }
6787
+ };
6788
+ app.delete('/projects/:id', async (req, res) => {
6789
+ const claimId = randomUUID();
6790
+ try {
6791
+ db.transaction(() => {
6792
+ assertProjectDeletionEligible(req.params.id);
6793
+ const claimed = db.prepare(`
6794
+ UPDATE projects
6795
+ SET deletion_started_at = CURRENT_TIMESTAMP, deletion_claim_id = ?
6796
+ WHERE id = ? AND deletion_started_at IS NULL
6797
+ `).run(claimId, req.params.id);
6798
+ if (claimed.changes !== 1) {
6799
+ throw new ProjectDeletionError(409, 'Project deletion is already in progress');
6800
+ }
6801
+ })();
6802
+ }
6803
+ catch (error) {
6804
+ if (error instanceof ProjectDeletionError) {
6805
+ return res.status(error.status).json({ error: error.message });
6806
+ }
6807
+ throw error;
6808
+ }
6809
+ try {
6810
+ await completeClaimedProjectDeletion(req.params.id, claimId);
6811
+ }
6812
+ catch (error) {
6681
6813
  db.prepare(`
6682
- UPDATE execution_runs
6683
- SET parent_run_id = NULL, retry_of_run_id = NULL
6684
- WHERE project_id = ?
6685
- `).run(req.params.id);
6686
- db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id);
6687
- })();
6814
+ UPDATE projects
6815
+ SET deletion_started_at = NULL, deletion_claim_id = NULL, deletion_cleanup_started_at = NULL
6816
+ WHERE id = ? AND deletion_claim_id = ? AND deletion_cleanup_started_at IS NULL
6817
+ `).run(req.params.id, claimId);
6818
+ if (error instanceof ExecutionArtifactCleanupError) {
6819
+ return res.status(409).json({ error: error.message });
6820
+ }
6821
+ if (error instanceof ProjectDeletionError) {
6822
+ return res.status(error.status).json({ error: error.message });
6823
+ }
6824
+ throw error;
6825
+ }
6688
6826
  res.status(204).end();
6689
6827
  });
6690
6828
  app.post('/tickets', (req, res) => {
@@ -7612,22 +7750,7 @@ app.get('/projects/:id/execution-timeline/:runId', (req, res) => {
7612
7750
  if (scope.scopeEntity && !links.some(link => link.entity_type === scope.scopeEntity?.type && link.entity_id === scope.scopeEntity.id)) {
7613
7751
  return res.status(404).json({ error: 'Execution run not found' });
7614
7752
  }
7615
- const testDetails = run.kind === 'test' ? getTestExecutionDetails(project.id, run.id) : null;
7616
- const retryEligibility = getExecutionRetryEligibility(project.id, run.id);
7617
- const details = run.kind === 'test'
7618
- ? serializeTestExecutionDetail(testDetails, retryEligibility)
7619
- : run.kind === 'github_actions'
7620
- ? { ...serializeGitHubActionsExecutionDetail(getGitHubActionsExecutionDetails(project.id, run.id)), retry_eligibility: retryEligibility }
7621
- : ['automation_commit', 'automation_push', 'automation_pull_request'].includes(run.kind)
7622
- ? { kind: run.kind, available: true, automation_stage: getAutomationStageDetails(project.id, run.id), retry_eligibility: retryEligibility }
7623
- : { kind: run.kind, available: true, retry_eligibility: retryEligibility };
7624
- return res.json({
7625
- run,
7626
- links,
7627
- events: listLifecycleEvents(project.id, run.id).map(toCanonicalLifecycleEvent),
7628
- details,
7629
- agent_runs: listAgentRunsForExecutionRun(run.id),
7630
- });
7753
+ return res.json(buildExecutionRunDetailResponse(project, run));
7631
7754
  });
7632
7755
  app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7633
7756
  const project = getProjectById(req.params.id);
@@ -7638,19 +7761,44 @@ app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7638
7761
  if (!run) {
7639
7762
  return res.status(404).json({ error: 'Execution run not found' });
7640
7763
  }
7641
- const testDetails = run.kind === 'test' ? getTestExecutionDetails(project.id, run.id) : null;
7642
- const retryEligibility = getExecutionRetryEligibility(project.id, run.id);
7643
- const details = run.kind === 'test'
7644
- ? serializeTestExecutionDetail(testDetails, retryEligibility)
7645
- : run.kind === 'github_actions'
7646
- ? { ...serializeGitHubActionsExecutionDetail(getGitHubActionsExecutionDetails(project.id, run.id)), retry_eligibility: retryEligibility }
7647
- : ['automation_commit', 'automation_push', 'automation_pull_request'].includes(run.kind)
7648
- ? { kind: run.kind, available: true, automation_stage: getAutomationStageDetails(project.id, run.id), retry_eligibility: retryEligibility }
7649
- : { kind: run.kind, available: true, retry_eligibility: retryEligibility };
7650
- const links = listExecutionRunLinks(project.id, run.id);
7651
- const agentRuns = listAgentRunsForExecutionRun(run.id);
7652
- const events = listLifecycleEvents(project.id, run.id).map(toCanonicalLifecycleEvent);
7653
- return res.json({ run, links, events, details, agent_runs: agentRuns });
7764
+ return res.json(buildExecutionRunDetailResponse(project, run));
7765
+ });
7766
+ app.get('/projects/:id/execution-runs/:runId/artifacts/:artifactId', async (req, res) => {
7767
+ const project = getProjectById(req.params.id);
7768
+ if (!project || !getExecutionRun(project.id, req.params.runId)) {
7769
+ return res.status(404).json({ error: 'Not found' });
7770
+ }
7771
+ const retrieval = await openExecutionArtifactForRetrieval(project.id, req.params.runId, req.params.artifactId, io);
7772
+ if (retrieval.kind === 'not_found') {
7773
+ return res.status(404).json({ error: 'Not found' });
7774
+ }
7775
+ if (retrieval.kind === 'unavailable') {
7776
+ return res.status(410).json({ error: 'Artifact unavailable' });
7777
+ }
7778
+ if (retrieval.kind === 'external') {
7779
+ res.setHeader('Referrer-Policy', 'no-referrer');
7780
+ res.setHeader('Location', retrieval.url);
7781
+ return res.status(303).end();
7782
+ }
7783
+ res.setHeader('Content-Type', retrieval.mediaType);
7784
+ res.setHeader('Content-Length', retrieval.byteSize);
7785
+ res.setHeader('Content-Disposition', `attachment; filename="${retrieval.filename}"`);
7786
+ res.setHeader('X-Content-Type-Options', 'nosniff');
7787
+ const stream = retrieval.handle.createReadStream({ autoClose: true });
7788
+ stream.on('error', () => {
7789
+ console.error('Execution artifact stream failed', {
7790
+ project_id: project.id,
7791
+ execution_run_id: req.params.runId,
7792
+ artifact_id: req.params.artifactId,
7793
+ });
7794
+ res.destroy();
7795
+ });
7796
+ res.on('close', () => {
7797
+ if (!stream.destroyed) {
7798
+ stream.destroy();
7799
+ }
7800
+ });
7801
+ return stream.pipe(res);
7654
7802
  });
7655
7803
  app.post('/projects/:id/execution-runs/:runId/retry', async (req, res) => {
7656
7804
  const project = getProjectById(req.params.id);
@@ -7991,12 +8139,21 @@ if (clientBuild) {
7991
8139
  });
7992
8140
  });
7993
8141
  }
8142
+ app.use((error, _req, res, next) => {
8143
+ if (error instanceof Error && error.message.includes('Project is being deleted')) {
8144
+ res.status(409).json({ error: 'Project deletion is in progress' });
8145
+ return;
8146
+ }
8147
+ next(error);
8148
+ });
7994
8149
  io.on('connection', (socket) => {
7995
8150
  console.log('client connected:', socket.id);
7996
8151
  registerTerminalSocketHandlers(io, socket);
7997
8152
  });
7998
8153
  const DEFAULT_PORT = 3001;
7999
8154
  let executionEvidenceCleanupTimer = null;
8155
+ let executionArtifactCleanupTimer = null;
8156
+ let executionArtifactCleanupPromise = null;
8000
8157
  let startServerPromise = null;
8001
8158
  let shutdownServerPromise = null;
8002
8159
  let shutdownRequested = false;
@@ -8023,6 +8180,41 @@ const startExecutionEvidenceCleanupTimer = () => {
8023
8180
  executionEvidenceCleanupTimer = setInterval(runExecutionEvidenceCleanup, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS);
8024
8181
  executionEvidenceCleanupTimer.unref();
8025
8182
  };
8183
+ export const runExecutionArtifactCleanup = (mode) => {
8184
+ if (executionArtifactCleanupPromise) {
8185
+ return executionArtifactCleanupPromise;
8186
+ }
8187
+ executionArtifactCleanupPromise = (async () => {
8188
+ if (mode === 'periodic') {
8189
+ await resumeInterruptedProjectDeletions();
8190
+ }
8191
+ await runExclusiveExecutionArtifactMaintenance(async () => {
8192
+ const intents = await reconcileExecutionArtifactRegistrationIntents({ mode });
8193
+ const artifacts = await reconcileFinalizedExecutionArtifacts({ emitter: io });
8194
+ const failures = intents.failed.length + artifacts.failed.length;
8195
+ if (failures > 0) {
8196
+ console.error(`Artifact reconciliation retained ${failures} item(s) for retry`);
8197
+ }
8198
+ if (intents.deleted + artifacts.deleted > 0) {
8199
+ console.log(`Reconciled ${intents.deleted} artifact registration intent(s) and expired ${artifacts.deleted} artifact(s)`);
8200
+ }
8201
+ });
8202
+ })().catch(error => {
8203
+ console.error('Failed to reconcile execution artifacts', error);
8204
+ }).finally(() => {
8205
+ executionArtifactCleanupPromise = null;
8206
+ });
8207
+ return executionArtifactCleanupPromise;
8208
+ };
8209
+ const startExecutionArtifactCleanupTimer = () => {
8210
+ if (executionArtifactCleanupTimer) {
8211
+ return;
8212
+ }
8213
+ executionArtifactCleanupTimer = setInterval(() => {
8214
+ void runExecutionArtifactCleanup('periodic');
8215
+ }, resolveArtifactCleanupIntervalMs());
8216
+ executionArtifactCleanupTimer.unref();
8217
+ };
8026
8218
  const resolveStartResult = (host) => {
8027
8219
  const address = httpServer.address();
8028
8220
  const { displayHost } = resolveDesktopServerHost(host);
@@ -8050,8 +8242,12 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
8050
8242
  return startServerPromise;
8051
8243
  }
8052
8244
  startServerPromise = (async () => {
8245
+ releaseInterruptedProjectDeletionClaims();
8246
+ await resumeInterruptedProjectDeletions();
8053
8247
  await reconcileStartupExecutions({ getGitHubConfig: getGitHubConnectionConfig });
8054
8248
  throwIfShutdownRequested();
8249
+ await runExecutionArtifactCleanup('startup');
8250
+ throwIfShutdownRequested();
8055
8251
  startGitHubActionsPolling(io);
8056
8252
  try {
8057
8253
  throwIfShutdownRequested();
@@ -8077,6 +8273,7 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
8077
8273
  runExecutionEvidenceCleanup();
8078
8274
  throwIfShutdownRequested();
8079
8275
  startExecutionEvidenceCleanupTimer();
8276
+ startExecutionArtifactCleanupTimer();
8080
8277
  return resolveStartResult(host);
8081
8278
  })().finally(() => {
8082
8279
  startServerPromise = null;
@@ -8098,6 +8295,11 @@ export const shutdownServer = async (signal) => {
8098
8295
  clearInterval(executionEvidenceCleanupTimer);
8099
8296
  executionEvidenceCleanupTimer = null;
8100
8297
  }
8298
+ if (executionArtifactCleanupTimer) {
8299
+ clearInterval(executionArtifactCleanupTimer);
8300
+ executionArtifactCleanupTimer = null;
8301
+ }
8302
+ await executionArtifactCleanupPromise;
8101
8303
  await stopMobileAccess();
8102
8304
  await clearAllTestSessions(io);
8103
8305
  await clearAllGitHubActionsSessions();