codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

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 (134) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +683 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -1,16 +1,23 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
2
  import { subscribe } from "./bus.js";
3
- import { downloadArchiveFile, fetchArchiveSnapshot, listArchiveSnapshots, listArchiveTree, readArchiveFile, } from "./archiveApi.js";
3
+ import { downloadArchiveFile, downloadLocalArchiveFile, fetchArchiveSnapshot, listArchiveSnapshots, listArchiveTree, listLocalArchiveTree, listLocalRunArchives, readArchiveFile, readLocalArchiveFile, } from "./archiveApi.js";
4
4
  import { escapeHtml, flash, statusPill, setButtonLoading } from "./utils.js";
5
5
  let initialized = false;
6
6
  let snapshots = [];
7
- let selected = null;
8
- let activeSnapshotKey = "";
7
+ let localArchives = [];
8
+ let selectedItem = null;
9
+ let activeSourceKey = "";
9
10
  let activeSubTab = "snapshot";
10
- let lastSnapshotsSignature = "";
11
- /** Compute a signature of the snapshots list for change detection. */
12
- function snapshotsSignature(items) {
13
- return items.map((s) => `${s.snapshot_id}:${s.worktree_repo_id}:${s.status || ""}`).join("|");
11
+ let lastArchiveSignature = "";
12
+ /** Compute a signature of the archive lists for change detection. */
13
+ function archiveSignature(snapshotItems, localItems) {
14
+ const snapSig = snapshotItems
15
+ .map((s) => `${s.snapshot_id}:${s.worktree_repo_id}:${s.status || ""}`)
16
+ .join("|");
17
+ const localSig = localItems
18
+ .map((s) => `${s.run_id}:${s.archived_at || ""}:${s.has_tickets ? "t" : "f"}:${s.has_runs ? "t" : "f"}`)
19
+ .join("|");
20
+ return `${snapSig}::${localSig}`;
14
21
  }
15
22
  const listEl = document.getElementById("archive-snapshot-list");
16
23
  const detailEl = document.getElementById("archive-snapshot-detail");
@@ -23,7 +30,7 @@ let fileEls = null;
23
30
  let treeRequestToken = 0;
24
31
  let fileRequestToken = 0;
25
32
  let artifactRequestToken = 0;
26
- const QUICK_LINKS = [
33
+ const SNAPSHOT_QUICK_LINKS = [
27
34
  { label: "Active Context", path: "workspace/active_context.md", kind: "file" },
28
35
  { label: "Decisions", path: "workspace/decisions.md", kind: "file" },
29
36
  { label: "Spec", path: "workspace/spec.md", kind: "file" },
@@ -32,6 +39,10 @@ const QUICK_LINKS = [
32
39
  { label: "Flows", path: "flows", kind: "folder" },
33
40
  { label: "Logs", path: "logs", kind: "folder" },
34
41
  ];
42
+ const LOCAL_QUICK_LINKS = [
43
+ { label: "Archived tickets", path: "archived_tickets", kind: "folder" },
44
+ { label: "Archived runs", path: "archived_runs", kind: "folder" },
45
+ ];
35
46
  function formatTimestamp(ts) {
36
47
  if (!ts)
37
48
  return "–";
@@ -54,6 +65,12 @@ function formatBytes(bytes) {
54
65
  function snapshotKey(snapshot) {
55
66
  return `${snapshot.snapshot_id}::${snapshot.worktree_repo_id}`;
56
67
  }
68
+ function listItemKey(item) {
69
+ if (item.kind === "snapshot") {
70
+ return `snapshot:${snapshotKey(item.summary)}`;
71
+ }
72
+ return `local:${item.summary.run_id}`;
73
+ }
57
74
  function parentPath(path) {
58
75
  const parts = path.split("/").filter(Boolean);
59
76
  if (parts.length <= 1)
@@ -67,32 +84,33 @@ function renderEmptyDetail(message) {
67
84
  detailEl.innerHTML = `
68
85
  <div class="archive-empty-state">
69
86
  <div class="archive-empty-title">${escapeHtml(message)}</div>
70
- <div class="archive-empty-hint">Select a snapshot on the left to view metadata.</div>
87
+ <div class="archive-empty-hint">Select a snapshot or local run archive on the left to view details.</div>
71
88
  </div>
72
89
  `;
73
90
  }
74
- function renderList(items) {
91
+ function renderList(snapshotItems, localItems) {
75
92
  if (!listEl)
76
93
  return;
77
- if (!items.length) {
94
+ const hasItems = snapshotItems.length > 0 || localItems.length > 0;
95
+ if (!hasItems) {
78
96
  listEl.innerHTML = "";
79
97
  if (emptyEl)
80
98
  emptyEl.classList.remove("hidden");
81
- renderEmptyDetail("No archived snapshots yet.");
99
+ renderEmptyDetail("No archives yet.");
82
100
  return;
83
101
  }
84
102
  if (emptyEl)
85
103
  emptyEl.classList.add("hidden");
86
- const selectedKey = selected ? snapshotKey(selected) : "";
87
- listEl.innerHTML = items
104
+ const selectedKey = selectedItem ? listItemKey(selectedItem) : "";
105
+ const snapshotHtml = snapshotItems
88
106
  .map((item) => {
89
- const isActive = selectedKey && selectedKey === snapshotKey(item);
107
+ const isActive = selectedKey && selectedKey === `snapshot:${snapshotKey(item)}`;
90
108
  const created = formatTimestamp(item.created_at);
91
109
  const branch = item.branch ? `· ${item.branch}` : "";
92
110
  const status = item.status ? item.status : "unknown";
93
111
  const note = item.note ? ` · ${item.note}` : "";
94
112
  return `
95
- <button class="archive-snapshot${isActive ? " active" : ""}" data-snapshot-id="${escapeHtml(item.snapshot_id)}" data-worktree-id="${escapeHtml(item.worktree_repo_id)}">
113
+ <button class="archive-snapshot${isActive ? " active" : ""}" data-kind="snapshot" data-snapshot-id="${escapeHtml(item.snapshot_id)}" data-worktree-id="${escapeHtml(item.worktree_repo_id)}">
96
114
  <div class="archive-snapshot-title">${escapeHtml(item.snapshot_id)}</div>
97
115
  <div class="archive-snapshot-meta muted small">${escapeHtml(created)} ${escapeHtml(branch)}</div>
98
116
  <div class="archive-snapshot-meta muted small">Status: ${escapeHtml(status)}${escapeHtml(note)}</div>
@@ -100,6 +118,34 @@ function renderList(items) {
100
118
  `;
101
119
  })
102
120
  .join("");
121
+ const localHtml = localItems
122
+ .map((item) => {
123
+ const isActive = selectedKey && selectedKey === `local:${item.run_id}`;
124
+ const created = formatTimestamp(item.archived_at);
125
+ const tickets = item.has_tickets ? "tickets" : "no tickets";
126
+ const runs = item.has_runs ? "runs" : "no runs";
127
+ return `
128
+ <button class="archive-snapshot${isActive ? " active" : ""}" data-kind="local" data-run-id="${escapeHtml(item.run_id)}">
129
+ <div class="archive-snapshot-title">${escapeHtml(item.run_id)}</div>
130
+ <div class="archive-snapshot-meta muted small">${escapeHtml(created)} · Local run archive</div>
131
+ <div class="archive-snapshot-meta muted small">${escapeHtml(tickets)} · ${escapeHtml(runs)}</div>
132
+ </button>
133
+ `;
134
+ })
135
+ .join("");
136
+ const snapshotSection = `
137
+ <div class="archive-list-section">
138
+ <div class="archive-list-header muted small">Worktree snapshots</div>
139
+ ${snapshotHtml || `<div class="archive-list-empty muted small">No snapshots.</div>`}
140
+ </div>
141
+ `;
142
+ const localSection = `
143
+ <div class="archive-list-section">
144
+ <div class="archive-list-header muted small">Local run archives</div>
145
+ ${localHtml || `<div class="archive-list-empty muted small">No run archives.</div>`}
146
+ </div>
147
+ `;
148
+ listEl.innerHTML = `${snapshotSection}${localSection}`;
103
149
  }
104
150
  function renderSummaryGrid(summary, meta) {
105
151
  const created = formatTimestamp(summary.created_at);
@@ -233,10 +279,10 @@ function renderArtifactSection(summary, meta) {
233
279
  </div>
234
280
  `;
235
281
  }
236
- function renderSubTabs() {
282
+ function renderSubTabs(summaryLabel) {
237
283
  return `
238
284
  <div class="archive-subtabs">
239
- <button class="archive-subtab${activeSubTab === "snapshot" ? " active" : ""}" data-subtab="snapshot">Snapshot</button>
285
+ <button class="archive-subtab${activeSubTab === "snapshot" ? " active" : ""}" data-subtab="snapshot">${escapeHtml(summaryLabel)}</button>
240
286
  <button class="archive-subtab${activeSubTab === "files" ? " active" : ""}" data-subtab="files">Files</button>
241
287
  </div>
242
288
  `;
@@ -272,14 +318,17 @@ function wireSubTabs() {
272
318
  }
273
319
  });
274
320
  }
275
- function renderFileSection() {
276
- const quickLinks = QUICK_LINKS.map((item) => `<button class="ghost sm" data-archive-path="${escapeHtml(item.path)}" data-archive-kind="${item.kind}">${escapeHtml(item.label)}</button>`).join("");
321
+ function renderFileSection(quickLinksData, description) {
322
+ const quickLinks = quickLinksData
323
+ .map((item) => `<button class="ghost sm" data-archive-path="${escapeHtml(item.path)}" data-archive-kind="${item.kind}">${escapeHtml(item.label)}</button>`)
324
+ .join("");
325
+ const descriptionText = description || "Browse archive files (read-only).";
277
326
  return `
278
327
  <div class="archive-file-section">
279
328
  <div class="archive-file-header-row">
280
329
  <div>
281
330
  <div class="archive-section-title">Archive files</div>
282
- <div class="muted small">Browse snapshot files (read-only).</div>
331
+ <div class="muted small">${escapeHtml(descriptionText)}</div>
283
332
  </div>
284
333
  <div class="archive-quick-links" id="archive-quick-links">
285
334
  ${quickLinks}
@@ -311,6 +360,47 @@ function renderFileSection() {
311
360
  </div>
312
361
  `;
313
362
  }
363
+ function renderLocalSummary(run) {
364
+ const archivedAt = formatTimestamp(run.archived_at);
365
+ const tickets = run.has_tickets ? "Yes" : "No";
366
+ const runs = run.has_runs ? "Yes" : "No";
367
+ const actionButtons = [
368
+ run.has_tickets
369
+ ? `<button class="ghost sm" data-archive-path="archived_tickets" data-archive-kind="folder">Archived tickets</button>`
370
+ : "",
371
+ run.has_runs
372
+ ? `<button class="ghost sm" data-archive-path="archived_runs" data-archive-kind="folder">Archived runs</button>`
373
+ : "",
374
+ ]
375
+ .filter(Boolean)
376
+ .join("");
377
+ return `
378
+ <div class="archive-meta-grid">
379
+ <div class="archive-meta-row">
380
+ <div class="archive-meta-label muted small">Run ID</div>
381
+ <div class="archive-meta-value">${escapeHtml(run.run_id)}</div>
382
+ </div>
383
+ <div class="archive-meta-row">
384
+ <div class="archive-meta-label muted small">Archived at</div>
385
+ <div class="archive-meta-value">${escapeHtml(archivedAt)}</div>
386
+ </div>
387
+ <div class="archive-meta-row">
388
+ <div class="archive-meta-label muted small">Archived tickets</div>
389
+ <div class="archive-meta-value">${escapeHtml(tickets)}</div>
390
+ </div>
391
+ <div class="archive-meta-row">
392
+ <div class="archive-meta-label muted small">Archived runs</div>
393
+ <div class="archive-meta-value">${escapeHtml(runs)}</div>
394
+ </div>
395
+ </div>
396
+ <div class="archive-summary-block">
397
+ <div class="archive-section-title">Artifacts</div>
398
+ <div class="archive-quick-links archive-artifact-actions" id="archive-local-artifact-actions">
399
+ ${actionButtons || `<span class="muted small">No archived folders found.</span>`}
400
+ </div>
401
+ </div>
402
+ `;
403
+ }
314
404
  function collectFileEls() {
315
405
  const list = document.getElementById("archive-file-list");
316
406
  const breadcrumbs = document.getElementById("archive-breadcrumbs");
@@ -358,7 +448,7 @@ function renderBreadcrumbs(path) {
358
448
  nav.className = "workspace-breadcrumbs-inner";
359
449
  const rootBtn = document.createElement("button");
360
450
  rootBtn.type = "button";
361
- rootBtn.textContent = "Snapshot";
451
+ rootBtn.textContent = fileState?.rootLabel || "Archive";
362
452
  rootBtn.addEventListener("click", () => {
363
453
  void navigateTo("");
364
454
  });
@@ -500,14 +590,43 @@ function renderFileList() {
500
590
  list.appendChild(row);
501
591
  });
502
592
  }
593
+ async function listTreeForState(state, path) {
594
+ if (state.kind === "snapshot" && state.snapshotId && state.worktreeRepoId) {
595
+ return listArchiveTree(state.snapshotId, state.worktreeRepoId, path);
596
+ }
597
+ if (state.kind === "local" && state.runId) {
598
+ return listLocalArchiveTree(state.runId, path);
599
+ }
600
+ throw new Error("Invalid archive source");
601
+ }
602
+ async function readFileForState(state, path) {
603
+ if (state.kind === "snapshot" && state.snapshotId) {
604
+ return readArchiveFile(state.snapshotId, state.worktreeRepoId ?? null, path);
605
+ }
606
+ if (state.kind === "local" && state.runId) {
607
+ return readLocalArchiveFile(state.runId, path);
608
+ }
609
+ throw new Error("Invalid archive source");
610
+ }
611
+ function downloadFileForState(state, path) {
612
+ if (state.kind === "snapshot" && state.snapshotId) {
613
+ downloadArchiveFile(state.snapshotId, state.worktreeRepoId ?? null, path);
614
+ return;
615
+ }
616
+ if (state.kind === "local" && state.runId) {
617
+ downloadLocalArchiveFile(state.runId, path);
618
+ return;
619
+ }
620
+ throw new Error("Invalid archive source");
621
+ }
503
622
  async function navigateTo(path) {
504
623
  if (!fileState || !fileEls)
505
624
  return;
506
625
  fileEls.list.innerHTML = "Loading…";
507
626
  const requestId = ++treeRequestToken;
508
627
  try {
509
- const res = await listArchiveTree(fileState.snapshotId, fileState.worktreeRepoId, path);
510
- if (!fileState || fileState.snapshotKey !== activeSnapshotKey)
628
+ const res = await listTreeForState(fileState, path);
629
+ if (!fileState || fileState.sourceKey !== activeSourceKey)
511
630
  return;
512
631
  if (requestId !== treeRequestToken)
513
632
  return;
@@ -530,14 +649,14 @@ async function openFilePath(path) {
530
649
  return;
531
650
  const folder = parentPath(path);
532
651
  await navigateTo(folder);
533
- if (!fileState || fileState.snapshotKey !== activeSnapshotKey)
652
+ if (!fileState || fileState.sourceKey !== activeSourceKey)
534
653
  return;
535
654
  const node = fileState.nodes.find((item) => item.path === path && item.type === "file");
536
655
  if (node) {
537
656
  await selectFile(node);
538
657
  }
539
658
  else {
540
- flash("File not found in archive snapshot.", "error");
659
+ flash("File not found in archive.", "error");
541
660
  }
542
661
  }
543
662
  async function selectFile(node, forceLoad = false) {
@@ -556,7 +675,7 @@ async function selectFile(node, forceLoad = false) {
556
675
  fileEls.downloadBtn.onclick = () => {
557
676
  if (!fileState)
558
677
  return;
559
- downloadArchiveFile(fileState.snapshotId, fileState.worktreeRepoId, node.path);
678
+ downloadFileForState(fileState, node.path);
560
679
  };
561
680
  if (node.size_bytes && node.size_bytes > MAX_PREVIEW_BYTES && !forceLoad) {
562
681
  fileEls.fileContent.textContent = `Preview disabled for ${formatBytes(node.size_bytes)} file. Use Download or Load anyway.`;
@@ -572,8 +691,8 @@ async function selectFile(node, forceLoad = false) {
572
691
  fileEls.fileContent.textContent = "Loading…";
573
692
  fileEls.fileContent.classList.remove("hidden");
574
693
  try {
575
- const text = await readArchiveFile(fileState.snapshotId, fileState.worktreeRepoId, node.path);
576
- if (!fileState || fileState.snapshotKey !== activeSnapshotKey)
694
+ const text = await readFileForState(fileState, node.path);
695
+ if (!fileState || fileState.sourceKey !== activeSourceKey)
577
696
  return;
578
697
  if (requestId !== fileRequestToken)
579
698
  return;
@@ -589,20 +708,35 @@ async function selectFile(node, forceLoad = false) {
589
708
  flash("Failed to load archive file.", "error");
590
709
  }
591
710
  }
592
- function initArchiveFileViewer(summary) {
711
+ function initArchiveFileViewer(source) {
593
712
  fileEls = collectFileEls();
594
713
  if (!fileEls)
595
714
  return;
596
- const key = snapshotKey(summary);
597
- activeSnapshotKey = key;
598
- fileState = {
599
- snapshotId: summary.snapshot_id,
600
- worktreeRepoId: summary.worktree_repo_id,
601
- snapshotKey: key,
602
- currentPath: "",
603
- nodes: [],
604
- selectedFile: null,
605
- };
715
+ const key = listItemKey(source);
716
+ activeSourceKey = key;
717
+ if (source.kind === "snapshot") {
718
+ fileState = {
719
+ kind: "snapshot",
720
+ snapshotId: source.summary.snapshot_id,
721
+ worktreeRepoId: source.summary.worktree_repo_id,
722
+ sourceKey: key,
723
+ rootLabel: "Snapshot",
724
+ currentPath: "",
725
+ nodes: [],
726
+ selectedFile: null,
727
+ };
728
+ }
729
+ else {
730
+ fileState = {
731
+ kind: "local",
732
+ runId: source.summary.run_id,
733
+ sourceKey: key,
734
+ rootLabel: "Run archive",
735
+ currentPath: "",
736
+ nodes: [],
737
+ selectedFile: null,
738
+ };
739
+ }
606
740
  resetFileViewer();
607
741
  wireArchivePathButtons(fileEls.quickLinks);
608
742
  fileEls.refreshBtn?.addEventListener("click", () => {
@@ -654,7 +788,7 @@ async function loadArtifactListings(summary) {
654
788
  flowList.textContent = "Loading…";
655
789
  try {
656
790
  const runs = await listArchiveTree(summary.snapshot_id, summary.worktree_repo_id, "runs");
657
- if (!fileState || fileState.snapshotKey !== activeSnapshotKey)
791
+ if (!fileState || fileState.sourceKey !== activeSourceKey)
658
792
  return;
659
793
  if (requestId !== artifactRequestToken)
660
794
  return;
@@ -668,7 +802,7 @@ async function loadArtifactListings(summary) {
668
802
  }
669
803
  try {
670
804
  const flows = await listArchiveTree(summary.snapshot_id, summary.worktree_repo_id, "flows");
671
- if (!fileState || fileState.snapshotKey !== activeSnapshotKey)
805
+ if (!fileState || fileState.sourceKey !== activeSourceKey)
672
806
  return;
673
807
  if (requestId !== artifactRequestToken)
674
808
  return;
@@ -699,20 +833,20 @@ async function loadSnapshotDetail(target) {
699
833
  </div>
700
834
  <span class="pill pill-idle" id="archive-detail-status">${escapeHtml(summary.status || "unknown")}</span>
701
835
  </div>
702
- ${renderSubTabs()}
836
+ ${renderSubTabs("Snapshot")}
703
837
  <div id="archive-tab-snapshot" class="archive-tab-content archive-tab-snapshot${activeSubTab === "snapshot" ? " active" : ""}">
704
838
  ${renderSummaryGrid(summary, meta)}
705
839
  ${renderArtifactSection(summary, meta)}
706
840
  </div>
707
841
  <div id="archive-tab-files" class="archive-tab-content archive-tab-files${activeSubTab === "files" ? " active" : ""}">
708
- ${renderFileSection()}
842
+ ${renderFileSection(SNAPSHOT_QUICK_LINKS, "Browse snapshot files (read-only).")}
709
843
  </div>
710
844
  `;
711
845
  const statusEl = document.getElementById("archive-detail-status");
712
846
  if (statusEl)
713
847
  statusPill(statusEl, summary.status || "unknown");
714
848
  wireSubTabs();
715
- initArchiveFileViewer(summary);
849
+ initArchiveFileViewer({ kind: "snapshot", summary });
716
850
  wireArchivePathButtons(document.getElementById("archive-artifact-actions"));
717
851
  void loadArtifactListings(summary);
718
852
  }
@@ -724,15 +858,44 @@ async function loadSnapshotDetail(target) {
724
858
  flash("Failed to load archive snapshot.", "error");
725
859
  }
726
860
  }
727
- function selectSnapshot(target) {
728
- selected = target;
729
- renderList(snapshots);
730
- void loadSnapshotDetail(target);
861
+ async function loadLocalDetail(target) {
862
+ if (!detailEl)
863
+ return;
864
+ detailEl.innerHTML = `<div class="muted small">Loading run archive…</div>`;
865
+ detailEl.innerHTML = `
866
+ <div class="archive-detail-header">
867
+ <div>
868
+ <div class="archive-detail-title">${escapeHtml(target.run_id)}</div>
869
+ <div class="archive-detail-subtitle muted small">Local run archive</div>
870
+ </div>
871
+ <span class="pill pill-idle" id="archive-detail-status">local</span>
872
+ </div>
873
+ ${renderSubTabs("Overview")}
874
+ <div id="archive-tab-snapshot" class="archive-tab-content archive-tab-snapshot${activeSubTab === "snapshot" ? " active" : ""}">
875
+ ${renderLocalSummary(target)}
876
+ </div>
877
+ <div id="archive-tab-files" class="archive-tab-content archive-tab-files${activeSubTab === "files" ? " active" : ""}">
878
+ ${renderFileSection(LOCAL_QUICK_LINKS, "Browse archived run files (read-only).")}
879
+ </div>
880
+ `;
881
+ wireSubTabs();
882
+ initArchiveFileViewer({ kind: "local", summary: target });
883
+ wireArchivePathButtons(document.getElementById("archive-local-artifact-actions"));
884
+ }
885
+ function selectItem(target) {
886
+ selectedItem = target;
887
+ renderList(snapshots, localArchives);
888
+ if (target.kind === "snapshot") {
889
+ void loadSnapshotDetail(target.summary);
890
+ }
891
+ else {
892
+ void loadLocalDetail(target.summary);
893
+ }
731
894
  }
732
- async function loadSnapshots(forceReload = false) {
895
+ async function loadArchiveData(forceReload = false) {
733
896
  if (!listEl)
734
897
  return;
735
- const isInitialLoad = snapshots.length === 0;
898
+ const isInitialLoad = snapshots.length === 0 && localArchives.length === 0;
736
899
  const showRefreshIndicator = !isInitialLoad;
737
900
  if (showRefreshIndicator) {
738
901
  setButtonLoading(refreshBtn, true);
@@ -744,47 +907,67 @@ async function loadSnapshots(forceReload = false) {
744
907
  if (emptyEl)
745
908
  emptyEl.classList.add("hidden");
746
909
  try {
747
- const items = await listArchiveSnapshots();
748
- const sorted = items.slice().sort((a, b) => {
910
+ const [snapshotItems, localItems] = await Promise.all([listArchiveSnapshots(), listLocalRunArchives()]);
911
+ const sortedSnapshots = snapshotItems.slice().sort((a, b) => {
749
912
  const aTime = a.created_at ? new Date(a.created_at).getTime() : 0;
750
913
  const bTime = b.created_at ? new Date(b.created_at).getTime() : 0;
751
914
  if (aTime !== bTime)
752
915
  return bTime - aTime;
753
916
  return (b.snapshot_id || "").localeCompare(a.snapshot_id || "");
754
917
  });
755
- // Check if snapshots have changed
756
- const newSignature = snapshotsSignature(sorted);
757
- const hasChanged = newSignature !== lastSnapshotsSignature;
918
+ const sortedLocals = localItems.slice().sort((a, b) => {
919
+ const aTime = a.archived_at ? new Date(a.archived_at).getTime() : 0;
920
+ const bTime = b.archived_at ? new Date(b.archived_at).getTime() : 0;
921
+ if (aTime !== bTime)
922
+ return bTime - aTime;
923
+ return (b.run_id || "").localeCompare(a.run_id || "");
924
+ });
925
+ // Check if archives have changed
926
+ const newSignature = archiveSignature(sortedSnapshots, sortedLocals);
927
+ const hasChanged = newSignature !== lastArchiveSignature;
758
928
  // Skip update if nothing changed and not forced
759
929
  if (!forceReload && !hasChanged && !isInitialLoad) {
760
930
  return;
761
931
  }
762
- lastSnapshotsSignature = newSignature;
763
- snapshots = sorted;
764
- renderList(sorted);
765
- if (!sorted.length)
932
+ lastArchiveSignature = newSignature;
933
+ snapshots = sortedSnapshots;
934
+ localArchives = sortedLocals;
935
+ renderList(sortedSnapshots, sortedLocals);
936
+ if (!sortedSnapshots.length && !sortedLocals.length)
766
937
  return;
767
- const selectedKey = selected ? snapshotKey(selected) : "";
768
- const match = selectedKey
769
- ? sorted.find((item) => snapshotKey(item) === selectedKey)
938
+ const selectedKey = selectedItem ? listItemKey(selectedItem) : "";
939
+ const matchSnapshot = selectedKey && selectedKey.startsWith("snapshot:")
940
+ ? sortedSnapshots.find((item) => `snapshot:${snapshotKey(item)}` === selectedKey)
941
+ : null;
942
+ const matchLocal = selectedKey && selectedKey.startsWith("local:")
943
+ ? sortedLocals.find((item) => `local:${item.run_id}` === selectedKey)
770
944
  : null;
771
945
  // Only reload detail if selection changed or forced
772
- if (forceReload || !match || isInitialLoad) {
773
- const next = match || sorted[0];
774
- selectSnapshot(next);
946
+ if (forceReload || (!matchSnapshot && !matchLocal) || isInitialLoad) {
947
+ const nextSnapshot = sortedSnapshots[0];
948
+ const nextLocal = sortedLocals[0];
949
+ if (nextSnapshot) {
950
+ selectItem({ kind: "snapshot", summary: nextSnapshot });
951
+ }
952
+ else if (nextLocal) {
953
+ selectItem({ kind: "local", summary: nextLocal });
954
+ }
775
955
  }
776
- else if (match) {
777
- // Update selected reference but don't reload detail
778
- selected = match;
779
- renderList(sorted);
956
+ else if (matchSnapshot) {
957
+ selectedItem = { kind: "snapshot", summary: matchSnapshot };
958
+ renderList(sortedSnapshots, sortedLocals);
959
+ }
960
+ else if (matchLocal) {
961
+ selectedItem = { kind: "local", summary: matchLocal };
962
+ renderList(sortedSnapshots, sortedLocals);
780
963
  }
781
964
  }
782
965
  catch (err) {
783
966
  listEl.innerHTML = "";
784
- renderEmptyDetail("Unable to load archive snapshots.");
967
+ renderEmptyDetail("Unable to load archives.");
785
968
  if (emptyEl)
786
969
  emptyEl.classList.add("hidden");
787
- flash("Failed to load archive snapshots.", "error");
970
+ flash("Failed to load archives.", "error");
788
971
  }
789
972
  finally {
790
973
  if (showRefreshIndicator) {
@@ -799,12 +982,22 @@ function handleListClick(event) {
799
982
  const btn = target.closest(".archive-snapshot");
800
983
  if (!btn)
801
984
  return;
802
- const snapshotId = btn.dataset.snapshotId;
803
- const worktreeId = btn.dataset.worktreeId;
804
- if (!snapshotId || !worktreeId)
805
- return;
806
- const match = snapshots.find((item) => item.snapshot_id === snapshotId && item.worktree_repo_id === worktreeId);
807
- selectSnapshot(match || { snapshot_id: snapshotId, worktree_repo_id: worktreeId });
985
+ const kind = btn.dataset.kind;
986
+ if (kind === "snapshot") {
987
+ const snapshotId = btn.dataset.snapshotId;
988
+ const worktreeId = btn.dataset.worktreeId;
989
+ if (!snapshotId || !worktreeId)
990
+ return;
991
+ const match = snapshots.find((item) => item.snapshot_id === snapshotId && item.worktree_repo_id === worktreeId);
992
+ selectItem({ kind: "snapshot", summary: match || { snapshot_id: snapshotId, worktree_repo_id: worktreeId } });
993
+ }
994
+ else if (kind === "local") {
995
+ const runId = btn.dataset.runId;
996
+ if (!runId)
997
+ return;
998
+ const match = localArchives.find((item) => item.run_id === runId);
999
+ selectItem({ kind: "local", summary: match || { run_id: runId, has_tickets: false, has_runs: false } });
1000
+ }
808
1001
  }
809
1002
  export function initArchive() {
810
1003
  if (initialized)
@@ -814,13 +1007,13 @@ export function initArchive() {
814
1007
  return;
815
1008
  listEl.addEventListener("click", handleListClick);
816
1009
  refreshBtn?.addEventListener("click", () => {
817
- void loadSnapshots(true); // Force reload on manual refresh
1010
+ void loadArchiveData(true); // Force reload on manual refresh
818
1011
  });
819
1012
  subscribe("repo:health", (payload) => {
820
1013
  const status = payload?.status || "";
821
1014
  if (status === "ok" || status === "degraded") {
822
- void loadSnapshots(); // Non-forced: only updates if data changed
1015
+ void loadArchiveData(); // Non-forced: only updates if data changed
823
1016
  }
824
1017
  });
825
- void loadSnapshots(true); // Initial load
1018
+ void loadArchiveData(true); // Initial load
826
1019
  }
@@ -12,6 +12,10 @@ export async function fetchArchiveSnapshot(snapshotId, worktreeRepoId) {
12
12
  const url = `/api/archive/snapshots/${encodeURIComponent(snapshotId)}${qs ? `?${qs}` : ""}`;
13
13
  return (await api(url));
14
14
  }
15
+ export async function listLocalRunArchives() {
16
+ const res = (await api("/api/archive/local-runs"));
17
+ return res?.archives ?? [];
18
+ }
15
19
  export async function listArchiveTree(snapshotId, worktreeRepoId, path = "") {
16
20
  const params = new URLSearchParams({ snapshot_id: snapshotId });
17
21
  if (worktreeRepoId)
@@ -21,6 +25,13 @@ export async function listArchiveTree(snapshotId, worktreeRepoId, path = "") {
21
25
  const url = `/api/archive/tree?${params.toString()}`;
22
26
  return (await api(url));
23
27
  }
28
+ export async function listLocalArchiveTree(runId, path = "") {
29
+ const params = new URLSearchParams({ run_id: runId });
30
+ if (path)
31
+ params.set("path", path);
32
+ const url = `/api/archive/local/tree?${params.toString()}`;
33
+ return (await api(url));
34
+ }
24
35
  export async function readArchiveFile(snapshotId, worktreeRepoId, path) {
25
36
  const params = new URLSearchParams({ snapshot_id: snapshotId, path });
26
37
  if (worktreeRepoId)
@@ -28,6 +39,11 @@ export async function readArchiveFile(snapshotId, worktreeRepoId, path) {
28
39
  const url = `/api/archive/file?${params.toString()}`;
29
40
  return (await api(url));
30
41
  }
42
+ export async function readLocalArchiveFile(runId, path) {
43
+ const params = new URLSearchParams({ run_id: runId, path });
44
+ const url = `/api/archive/local/file?${params.toString()}`;
45
+ return (await api(url));
46
+ }
31
47
  export function downloadArchiveFile(snapshotId, worktreeRepoId, path) {
32
48
  const params = new URLSearchParams({ snapshot_id: snapshotId, path });
33
49
  if (worktreeRepoId)
@@ -35,3 +51,8 @@ export function downloadArchiveFile(snapshotId, worktreeRepoId, path) {
35
51
  const url = resolvePath(`/api/archive/download?${params.toString()}`);
36
52
  window.location.href = url;
37
53
  }
54
+ export function downloadLocalArchiveFile(runId, path) {
55
+ const params = new URLSearchParams({ run_id: runId, path });
56
+ const url = resolvePath(`/api/archive/local/download?${params.toString()}`);
57
+ window.location.href = url;
58
+ }