@straiffi/archon 1.7.4 → 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 (98) hide show
  1. package/dist/client/assets/{ActivityView-BgMz3NNT.js → ActivityView-CT9iCPyc.js} +1 -1
  2. package/dist/client/assets/{GitHubActionsDialog-CNqWDS3E.js → GitHubActionsDialog-BA6xyQeX.js} +1 -1
  3. package/dist/client/assets/{TestsDialog-Cll7xJe0.js → TestsDialog-D2qrW5Ug.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BL5dYiul.js → architectureDiagram-3BPJPVTR-R6v7w0Nl.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-GPEHLZMM-Bi5x2v80.js → blockDiagram-GPEHLZMM-CHlBlnJq.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-AAUBKEIU-esEm54Ay.js → c4Diagram-AAUBKEIU-BvZKqUc6.js} +1 -1
  7. package/dist/client/assets/channel-Ckv8cPSf.js +1 -0
  8. package/dist/client/assets/{chunk-2J33WTMH-DACfY8xM.js → chunk-2J33WTMH-2gsIkBeO.js} +1 -1
  9. package/dist/client/assets/{chunk-3OPIFGDE-DZNUiaUs.js → chunk-3OPIFGDE-CcqPBjqx.js} +1 -1
  10. package/dist/client/assets/{chunk-5ZQYHXKU-ClDfkMAp.js → chunk-5ZQYHXKU-DX_wK5Rp.js} +1 -1
  11. package/dist/client/assets/{chunk-727SXJPM-CCwA3GWT.js → chunk-727SXJPM-B9WUxzJL.js} +1 -1
  12. package/dist/client/assets/{chunk-AQP2D5EJ-Byl4DRZ9.js → chunk-AQP2D5EJ-BG0s75_H.js} +1 -1
  13. package/dist/client/assets/{chunk-CSCIHK7Q-CXymZQ7A.js → chunk-CSCIHK7Q-CyNcvvqW.js} +1 -1
  14. package/dist/client/assets/{chunk-KSCS5N6A-VV3D8g6a.js → chunk-KSCS5N6A-CWVy_Wi0.js} +1 -1
  15. package/dist/client/assets/{chunk-L5ZTLDWV-BL9N5GRI.js → chunk-L5ZTLDWV-3dsXRB0X.js} +1 -1
  16. package/dist/client/assets/{chunk-LZXEDZCA-DKNN-zno.js → chunk-LZXEDZCA-B09QSB9z.js} +2 -2
  17. package/dist/client/assets/{chunk-ND2GUHAM-CHCpQ1yr.js → chunk-ND2GUHAM-BxzAdxTN.js} +1 -1
  18. package/dist/client/assets/{chunk-NZK2D7GU-B0Dm74mt.js → chunk-NZK2D7GU-D4ZfFwbB.js} +1 -1
  19. package/dist/client/assets/{chunk-O5CBEL6O-DAa5z2xG.js → chunk-O5CBEL6O-BxQULuKS.js} +1 -1
  20. package/dist/client/assets/{chunk-WU5MYG2G-DrVJWKVR.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-B0fsl8mE.js → dagre-BM42HDAG-Dx_V7OMy.js} +1 -1
  24. package/dist/client/assets/{diagram-2AECGRRQ-Cx_VEbSS.js → diagram-2AECGRRQ-Cc3VTg9P.js} +1 -1
  25. package/dist/client/assets/{diagram-5GNKFQAL-Cv7VVn5r.js → diagram-5GNKFQAL-mJ1xhs2G.js} +1 -1
  26. package/dist/client/assets/{diagram-KO2AKTUF-C8Sl5czV.js → diagram-KO2AKTUF-CLyT9ac8.js} +1 -1
  27. package/dist/client/assets/{diagram-LMA3HP47-C3dZaLz2.js → diagram-LMA3HP47-CVr47yij.js} +1 -1
  28. package/dist/client/assets/{diagram-OG6HWLK6-BAU9DtTr.js → diagram-OG6HWLK6-Dp5TJ8lM.js} +1 -1
  29. package/dist/client/assets/{erDiagram-TEJ5UH35-DpNc-eBD.js → erDiagram-TEJ5UH35-BBxwyu0r.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-I6XJVG4X-DGwTAT1S.js → flowDiagram-I6XJVG4X-CuFYzVQ-.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-6RSMTGT7-MPZhEgzJ.js → ganttDiagram-6RSMTGT7-aRQarcQY.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-CY0tHL5N.js → gitGraphDiagram-PVQCEYII-B3mHSkBv.js} +1 -1
  33. package/dist/client/assets/index-BJGHMaMz.css +2 -0
  34. package/dist/client/assets/{index-CJRgmWwY.js → index-Oz0S7bcT.js} +106 -106
  35. package/dist/client/assets/{infoDiagram-5YYISTIA-BBZeKjf4.js → infoDiagram-5YYISTIA-BSyryQJ9.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CM_iFrES.js → ishikawaDiagram-YF4QCWOH-BAGbrtSG.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-JHISSGLW-Do_lY94X.js → journeyDiagram-JHISSGLW-CDTht6k6.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-UN3LZRKU-brxg3j37.js → kanban-definition-UN3LZRKU-BDtuMp0H.js} +1 -1
  39. package/dist/client/assets/{line-PHHbm9rj.js → line-n0aNicmA.js} +1 -1
  40. package/dist/client/assets/{mermaid-parser.core-CLxAkW69.js → mermaid-parser.core-CdE4vZ4k.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-CebvSmx9.js → mermaid.core-C546CQj1.js} +3 -3
  42. package/dist/client/assets/{mindmap-definition-RKZ34NQL-CX_-ZaJF.js → mindmap-definition-RKZ34NQL-BwO779am.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-4H26LBE5-BJuHvql_.js → pieDiagram-4H26LBE5-DU1k71kn.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-DiJll7l9.js → quadrantDiagram-W4KKPZXB-CvM5lyQg.js} +1 -1
  45. package/dist/client/assets/{requirementDiagram-4Y6WPE33-Bg-tXgcj.js → requirementDiagram-4Y6WPE33-DDBdSq0B.js} +1 -1
  46. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-BoK2egfZ.js → sankeyDiagram-5OEKKPKP-ELM2mu81.js} +1 -1
  47. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-DcrDWNzv.js → sequenceDiagram-3UESZ5HK-D7uF-nRR.js} +1 -1
  48. package/dist/client/assets/{stateDiagram-AJRCARHV-B995qt1S.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-DqSVAhqL.js → timeline-definition-PNZ67QCA-zHJGFvZ8.js} +1 -1
  51. package/dist/client/assets/{vennDiagram-CIIHVFJN-D9wVraVY.js → vennDiagram-CIIHVFJN-EG1HFfD7.js} +1 -1
  52. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-CLff9zVd.js → wardleyDiagram-YWT4CUSO-DdQevxXg.js} +1 -1
  53. package/dist/client/assets/{xychartDiagram-2RQKCTM6-BuHUMMH2.js → xychartDiagram-2RQKCTM6-BjihNG0E.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 +270 -54
  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/buildSequences.js +25 -0
  62. package/dist/server/lib/buildSequences.js.map +1 -1
  63. package/dist/server/lib/executionArtifactCleanupState.js +12 -0
  64. package/dist/server/lib/executionArtifactCleanupState.js.map +1 -0
  65. package/dist/server/lib/executionArtifactRetention.js +405 -0
  66. package/dist/server/lib/executionArtifactRetention.js.map +1 -0
  67. package/dist/server/lib/executionArtifacts.js +705 -0
  68. package/dist/server/lib/executionArtifacts.js.map +1 -0
  69. package/dist/server/lib/executionRetry.js +9 -1
  70. package/dist/server/lib/executionRetry.js.map +1 -1
  71. package/dist/server/lib/executionRetryContext.js +21 -0
  72. package/dist/server/lib/executionRetryContext.js.map +1 -1
  73. package/dist/server/lib/executionRuns.js +62 -2
  74. package/dist/server/lib/executionRuns.js.map +1 -1
  75. package/dist/server/lib/executionTimeline.js +38 -10
  76. package/dist/server/lib/executionTimeline.js.map +1 -1
  77. package/dist/server/lib/lightweightPrompt.js +2 -2
  78. package/dist/server/lib/lightweightPrompt.js.map +1 -1
  79. package/dist/server/lib/projects.js +16 -3
  80. package/dist/server/lib/projects.js.map +1 -1
  81. package/dist/server/lib/pullRequestContent.js +148 -25
  82. package/dist/server/lib/pullRequestContent.js.map +1 -1
  83. package/dist/server/lib/queue.js +1 -0
  84. package/dist/server/lib/queue.js.map +1 -1
  85. package/dist/server/lib/runtimePaths.js +7 -0
  86. package/dist/server/lib/runtimePaths.js.map +1 -1
  87. package/dist/server/lib/ticketAutomationCommit.js +11 -4
  88. package/dist/server/lib/ticketAutomationCommit.js.map +1 -1
  89. package/dist/server/lib/types.js +11 -0
  90. package/dist/server/lib/types.js.map +1 -1
  91. package/dist/server/workers/followUp.js +94 -6
  92. package/dist/server/workers/followUp.js.map +1 -1
  93. package/package.json +1 -1
  94. package/dist/client/assets/channel-CqNHF3FD.js +0 -1
  95. package/dist/client/assets/classDiagram-4FO5ZUOK-BjEgQwKg.js +0 -1
  96. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-gGomH-F_.js +0 -1
  97. package/dist/client/assets/index-mRkJWu70.css +0 -2
  98. 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';
@@ -57,7 +57,9 @@ import { clearAllPreviewProxies } from './lib/previewProxy.js';
57
57
  import { createExecutionRun, addExecutionRunLink, addExecutionRunLinks, getExecutionRun, hasActiveBuildExecutionRunForTicket, getGitHubActionsExecutionDetails, getTestExecutionDetails, GitHubActionsDispatchConflictError, listExecutionRunLinks, listExecutionRuns, listLifecycleEvents, listProjectLifecycleEvents, toCanonicalLifecycleEvent, transitionExecutionRun, withExecutionRunTransaction, } from './lib/executionRuns.js';
58
58
  import { dispatchExecutionRetry, getExecutionRetryEligibility, resolveExecutionRetry } from './lib/executionRetry.js';
59
59
  import { listExecutionTimeline } from './lib/executionTimeline.js';
60
+ import { loadExecutionArtifactSummariesForRuns, openExecutionArtifactForRetrieval, } from './lib/executionArtifacts.js';
60
61
  import { cleanupAllExecutionEvidence, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS, resolveExecutionEvidenceRetentionDays, } from './lib/executionRunRetention.js';
62
+ import { ExecutionArtifactCleanupError, reconcileExecutionArtifactRegistrationIntents, reconcileFinalizedExecutionArtifacts, reconcileProjectExecutionArtifacts, resolveArtifactCleanupIntervalMs, } from './lib/executionArtifactRetention.js';
61
63
  import { shutdownRealtimeServer } from './lib/shutdown.js';
62
64
  import { resolveClientBuildPaths, shouldServeClientApp, STATIC_CLIENT_DIRECTORY_OPTIONS, STATIC_CLIENT_INDEX_OPTIONS, } from './lib/staticClient.js';
63
65
  import { deleteIntegrationConnection, getGitHubConnectionConfig, getJiraConnectionConfig, listIntegrationConnections, upsertIntegrationConnection, } from './lib/integrations/index.js';
@@ -72,7 +74,7 @@ import { PlanningReferenceError, resolveExternalPlanningDescription } from './li
72
74
  import { refreshIfNeeded as refreshAssignedIssues, clearCachedAssignedIssues } from './lib/integrations/assignedIssuesCache.js';
73
75
  import { buildCommitMessagePrompt, normalizeGeneratedCommitMessage } from './lib/commitMessage.js';
74
76
  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';
77
+ import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listInterruptedProjectDeletions, listProjects, releaseInterruptedProjectDeletionClaims, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, } from './lib/projects.js';
76
78
  import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopTicket } from './lib/run.js';
77
79
  import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
78
80
  import { getBundleBuildBlockerResponse, shouldStartBundleBuildNow, transitionTicketState, } from './lib/ticketWorkflowOperations.js';
@@ -94,7 +96,30 @@ import { exportSpecToDocs, checkSpecExportCollision } from './lib/specExport.js'
94
96
  import { createTicketDependency, deleteTicketDependenciesForTicket, deleteTicketDependency, getTicketDependency, isTicketRunSetupReady, listTicketDependencies, validateCreateTicketDependency, } from './lib/ticketDependencies.js';
95
97
  import { startBuild } from './workers/build.js';
96
98
  import { expandPlanningTicketOutline, generatePlanningTicketOutline, generatePlanText, prepareLinkedPlanningResearch, startPlan, stopAllPlanAgents, } from './workers/plan.js';
97
- 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
+ };
98
123
  const getErrorMessage = (error) => {
99
124
  return error instanceof Error ? error.message : String(error);
100
125
  };
@@ -112,6 +137,9 @@ const requireExistingProject = (projectId) => {
112
137
  if (!project) {
113
138
  return { status: 404, error: 'Not found' };
114
139
  }
140
+ if (project.deletion_started_at) {
141
+ return { status: 409, error: 'Project deletion is in progress' };
142
+ }
115
143
  return { project };
116
144
  };
117
145
  const requireProjectMemoryProject = (projectId) => {
@@ -261,6 +289,26 @@ const serializeGitHubActionsExecutionDetail = (details) => {
261
289
  jobs_count: details.jobs_count,
262
290
  };
263
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
+ };
264
312
  const formatWorktreeDeleteError = (target, branch, error) => {
265
313
  return `Unable to delete the ${target} worktree for branch "${branch}": ${getErrorMessage(error)}`;
266
314
  };
@@ -2400,6 +2448,17 @@ app.post('/projects/:id/chat-sessions/:sessionId/attachments', (req, res) => {
2400
2448
  if (error) {
2401
2449
  return res.status(400).json({ error: getChatAttachmentUploadErrorMessage(error) });
2402
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
+ }
2403
2462
  const files = (req.files ?? []);
2404
2463
  if (files.length === 0) {
2405
2464
  return res.status(400).json({ error: 'At least one image is required.' });
@@ -2417,11 +2476,11 @@ app.post('/projects/:id/chat-sessions/:sessionId/attachments', (req, res) => {
2417
2476
  try {
2418
2477
  const attachments = files.map(file => {
2419
2478
  const attachment = createChatAttachment({
2420
- chatSessionId: chatSession.id,
2479
+ chatSessionId: currentChatSession.id,
2421
2480
  originalName: file.originalname,
2422
2481
  mimeType: file.mimetype,
2423
2482
  buffer: file.buffer,
2424
- projectId: project.id,
2483
+ projectId: currentProject.id,
2425
2484
  });
2426
2485
  createdAttachmentIds.push(attachment.id);
2427
2486
  return attachment;
@@ -2692,6 +2751,17 @@ app.post('/projects/:id/spec-sessions/:sessionId/attachments', (req, res) => {
2692
2751
  if (error) {
2693
2752
  return res.status(400).json({ error: getSpecAttachmentUploadErrorMessage(error) });
2694
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
+ }
2695
2765
  const files = (req.files ?? []);
2696
2766
  if (files.length === 0) {
2697
2767
  return res.status(400).json({ error: 'At least one image is required.' });
@@ -2709,11 +2779,11 @@ app.post('/projects/:id/spec-sessions/:sessionId/attachments', (req, res) => {
2709
2779
  try {
2710
2780
  const attachments = files.map(file => {
2711
2781
  const attachment = createSpecAttachment({
2712
- specSessionId: specSession.id,
2782
+ specSessionId: currentSpecSession.id,
2713
2783
  originalName: file.originalname,
2714
2784
  mimeType: file.mimetype,
2715
2785
  buffer: file.buffer,
2716
- projectId: project.id,
2786
+ projectId: currentProject.id,
2717
2787
  });
2718
2788
  createdAttachmentIds.push(attachment.id);
2719
2789
  return attachment;
@@ -6659,32 +6729,114 @@ app.patch('/projects/:id', (req, res) => {
6659
6729
  const updated = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id);
6660
6730
  res.json(serializeProject(updated ?? null));
6661
6731
  });
6662
- app.delete('/projects/:id', (req, res) => {
6663
- const project = getProjectById(req.params.id);
6732
+ const assertProjectDeletionEligible = (projectId, claimId) => {
6733
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
6664
6734
  if (!project) {
6665
- return res.status(404).json({ error: 'Not found' });
6735
+ throw new ProjectDeletionError(404, 'Not found');
6666
6736
  }
6667
- 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);
6668
6744
  if (projectCount <= 1) {
6669
- return res.status(409).json({ error: 'Cannot delete the last remaining project' });
6745
+ throw new ProjectDeletionError(409, 'Cannot delete the last remaining project');
6670
6746
  }
6671
- if (countInboundProjectLinks(project.id) > 0) {
6672
- 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');
6673
6749
  }
6674
- const stats = getProjectTicketStats(project.id);
6750
+ const stats = getProjectTicketStats(projectId);
6675
6751
  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
- });
6752
+ throw new ProjectDeletionError(409, 'Cannot delete a project with existing tickets, bundles, or worktrees');
6753
+ }
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
+ })();
6679
6816
  }
6680
- db.transaction(() => {
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) {
6681
6827
  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
- })();
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
+ }
6688
6840
  res.status(204).end();
6689
6841
  });
6690
6842
  app.post('/tickets', (req, res) => {
@@ -7612,22 +7764,7 @@ app.get('/projects/:id/execution-timeline/:runId', (req, res) => {
7612
7764
  if (scope.scopeEntity && !links.some(link => link.entity_type === scope.scopeEntity?.type && link.entity_id === scope.scopeEntity.id)) {
7613
7765
  return res.status(404).json({ error: 'Execution run not found' });
7614
7766
  }
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
- });
7767
+ return res.json(buildExecutionRunDetailResponse(project, run));
7631
7768
  });
7632
7769
  app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7633
7770
  const project = getProjectById(req.params.id);
@@ -7638,19 +7775,44 @@ app.get('/projects/:id/execution-runs/:runId', (req, res) => {
7638
7775
  if (!run) {
7639
7776
  return res.status(404).json({ error: 'Execution run not found' });
7640
7777
  }
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 });
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();
7809
+ });
7810
+ res.on('close', () => {
7811
+ if (!stream.destroyed) {
7812
+ stream.destroy();
7813
+ }
7814
+ });
7815
+ return stream.pipe(res);
7654
7816
  });
7655
7817
  app.post('/projects/:id/execution-runs/:runId/retry', async (req, res) => {
7656
7818
  const project = getProjectById(req.params.id);
@@ -7991,12 +8153,21 @@ if (clientBuild) {
7991
8153
  });
7992
8154
  });
7993
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
+ });
7994
8163
  io.on('connection', (socket) => {
7995
8164
  console.log('client connected:', socket.id);
7996
8165
  registerTerminalSocketHandlers(io, socket);
7997
8166
  });
7998
8167
  const DEFAULT_PORT = 3001;
7999
8168
  let executionEvidenceCleanupTimer = null;
8169
+ let executionArtifactCleanupTimer = null;
8170
+ let executionArtifactCleanupPromise = null;
8000
8171
  let startServerPromise = null;
8001
8172
  let shutdownServerPromise = null;
8002
8173
  let shutdownRequested = false;
@@ -8023,6 +8194,41 @@ const startExecutionEvidenceCleanupTimer = () => {
8023
8194
  executionEvidenceCleanupTimer = setInterval(runExecutionEvidenceCleanup, EXECUTION_EVIDENCE_CLEANUP_INTERVAL_MS);
8024
8195
  executionEvidenceCleanupTimer.unref();
8025
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
+ };
8026
8232
  const resolveStartResult = (host) => {
8027
8233
  const address = httpServer.address();
8028
8234
  const { displayHost } = resolveDesktopServerHost(host);
@@ -8050,8 +8256,12 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
8050
8256
  return startServerPromise;
8051
8257
  }
8052
8258
  startServerPromise = (async () => {
8259
+ releaseInterruptedProjectDeletionClaims();
8260
+ await resumeInterruptedProjectDeletions();
8053
8261
  await reconcileStartupExecutions({ getGitHubConfig: getGitHubConnectionConfig });
8054
8262
  throwIfShutdownRequested();
8263
+ await runExecutionArtifactCleanup('startup');
8264
+ throwIfShutdownRequested();
8055
8265
  startGitHubActionsPolling(io);
8056
8266
  try {
8057
8267
  throwIfShutdownRequested();
@@ -8077,6 +8287,7 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
8077
8287
  runExecutionEvidenceCleanup();
8078
8288
  throwIfShutdownRequested();
8079
8289
  startExecutionEvidenceCleanupTimer();
8290
+ startExecutionArtifactCleanupTimer();
8080
8291
  return resolveStartResult(host);
8081
8292
  })().finally(() => {
8082
8293
  startServerPromise = null;
@@ -8098,6 +8309,11 @@ export const shutdownServer = async (signal) => {
8098
8309
  clearInterval(executionEvidenceCleanupTimer);
8099
8310
  executionEvidenceCleanupTimer = null;
8100
8311
  }
8312
+ if (executionArtifactCleanupTimer) {
8313
+ clearInterval(executionArtifactCleanupTimer);
8314
+ executionArtifactCleanupTimer = null;
8315
+ }
8316
+ await executionArtifactCleanupPromise;
8101
8317
  await stopMobileAccess();
8102
8318
  await clearAllTestSessions(io);
8103
8319
  await clearAllGitHubActionsSessions();