@defrex/autobuild 0.8.0

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 (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,66 @@
1
+ /** Pure stable-identity selection for every selectable dashboard row. */
2
+ import type { DashboardModel, DashboardSelection } from './model'
3
+
4
+ /** Render order is selection order: the always-present process-global row,
5
+ * optional repository harvest, then the model's already slug-sorted builds. */
6
+ export function dashboardSelections(
7
+ model: Pick<DashboardModel, 'harvest' | 'builds'>,
8
+ ): DashboardSelection[] {
9
+ return [
10
+ { kind: 'global' },
11
+ ...(model.harvest !== undefined ? [{ kind: 'harvest' } as const] : []),
12
+ ...model.builds.map((build) => ({ kind: 'build' as const, slug: build.slug })),
13
+ ]
14
+ }
15
+
16
+ export function sameSelection(
17
+ left: DashboardSelection | undefined,
18
+ right: DashboardSelection | undefined,
19
+ ): boolean {
20
+ if (left === undefined || right === undefined) return left === right
21
+ return (
22
+ left.kind === right.kind &&
23
+ (left.kind === 'global' ||
24
+ left.kind === 'harvest' ||
25
+ (right.kind === 'build' && left.slug === right.slug))
26
+ )
27
+ }
28
+
29
+ export function initialSelection(
30
+ rows: readonly DashboardSelection[],
31
+ ): DashboardSelection | undefined {
32
+ return rows[0]
33
+ }
34
+
35
+ /** Move by one (or any supplied delta) without wrapping. */
36
+ export function moveSelection(
37
+ rows: readonly DashboardSelection[],
38
+ selected: DashboardSelection | undefined,
39
+ delta: number,
40
+ ): DashboardSelection | undefined {
41
+ if (rows.length === 0) return undefined
42
+ const current = selected === undefined ? 0 : rows.findIndex((row) => sameSelection(row, selected))
43
+ const index = current === -1 ? 0 : current
44
+ const next = Math.max(0, Math.min(rows.length - 1, index + delta))
45
+ return rows[next]
46
+ }
47
+
48
+ /**
49
+ * Preserve the selected ROW across repaints, harvest appearance, and build
50
+ * re-sorts. If it disappears, its old row index chooses the successor now
51
+ * occupying that row, or the final predecessor when the old row was last.
52
+ * Only an empty list clears selection.
53
+ */
54
+ export function reconcileSelection(
55
+ previousRows: readonly DashboardSelection[],
56
+ nextRows: readonly DashboardSelection[],
57
+ selected: DashboardSelection | undefined,
58
+ ): DashboardSelection | undefined {
59
+ if (nextRows.length === 0) return undefined
60
+ if (selected === undefined) return initialSelection(nextRows)
61
+ const retained = nextRows.find((row) => sameSelection(row, selected))
62
+ if (retained !== undefined) return retained
63
+ const oldIndex = previousRows.findIndex((row) => sameSelection(row, selected))
64
+ if (oldIndex === -1) return initialSelection(nextRows)
65
+ return nextRows[Math.min(oldIndex, nextRows.length - 1)]
66
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The session view's live-read cycle (SPEC §9): ONE module both controllers
3
+ * call, so the follow/close/fallback semantics cannot drift between the
4
+ * `DispatchLoop` (the wire-injected path the capture harness exercises) and
5
+ * `DispatchFrontend` (the production interactive path).
6
+ *
7
+ * `SessionStreamFeed.poll(view)` performs one immediate store read
8
+ * (`waitSeconds: 0` — the store waits only when `waitSeconds > 0`), so no read
9
+ * can hold a frame longer than the controller's poll interval, and the writer's
10
+ * 250 ms flush cadence puts new parts on screen within one poll. It returns the
11
+ * next view fields — appended parts, advanced `lastSeq`, status/outcome on
12
+ * close, the pruned-chunk artifact fallback, or an error message that the next
13
+ * poll retries — and never throws into the frame.
14
+ *
15
+ * The returned update carries the identity and `since` cursor it started from;
16
+ * `applySessionFeedUpdate` is the apply-fence: a read that raced an Escape, a
17
+ * session switch, or a newer apply is discarded instead of retargeting the
18
+ * view. The stored part sequence is only ever appended to — the view never
19
+ * rewrites history.
20
+ */
21
+ import type { UIMessage } from 'ai'
22
+ import type { StreamOutcome, StreamPart, StreamRead } from '../../store/streams/types'
23
+ import type { SessionDashboardView } from './model'
24
+
25
+ /** The read-only store surface the feed consumes (injected; no terminal). */
26
+ export interface SessionFeedStore {
27
+ readStream(streamId: string, opts?: { since?: number; waitSeconds?: number }): Promise<StreamRead>
28
+ getArtifact(
29
+ slug: string,
30
+ kind: string,
31
+ rev?: number,
32
+ ): Promise<{
33
+ content: Uint8Array | string
34
+ } | null>
35
+ }
36
+
37
+ /** The mutable fields of a session view the feed may produce. */
38
+ export interface SessionViewFields {
39
+ status: 'open' | 'closed'
40
+ outcome?: StreamOutcome
41
+ source:
42
+ | { kind: 'parts'; parts: StreamPart[]; lastSeq: number }
43
+ | { kind: 'document'; document: UIMessage[] }
44
+ error?: string
45
+ }
46
+
47
+ /** The identity-bearing result of one poll: `since` is the cursor the read
48
+ * started from (the parts `lastSeq`, or 0 for a document retry), and
49
+ * everything is applied only through the fence below. */
50
+ export interface SessionFeedUpdate {
51
+ slug: string
52
+ sessionId: string
53
+ stream: string
54
+ since: number
55
+ /** Which source shape this update was polled from — the fence compares it
56
+ * against the current view so a read that raced a source switch is
57
+ * discarded. */
58
+ polledSource: 'parts' | 'document'
59
+ fields: SessionViewFields
60
+ }
61
+
62
+ /** The shape of a session view the feed polls. */
63
+ export type PollableSessionView = SessionDashboardView
64
+
65
+ function errorMessage(error: unknown): string {
66
+ return error instanceof Error ? error.message : String(error)
67
+ }
68
+
69
+ function parseDocument(content: Uint8Array | string): UIMessage[] | undefined {
70
+ try {
71
+ const text = typeof content === 'string' ? content : new TextDecoder().decode(content)
72
+ const parsed: unknown = JSON.parse(text)
73
+ if (!Array.isArray(parsed)) return undefined
74
+ return parsed as UIMessage[]
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ export class SessionStreamFeed {
81
+ constructor(
82
+ private readonly store: SessionFeedStore,
83
+ private readonly slug: string,
84
+ ) {}
85
+
86
+ /** One immediate read cycle for the given view. Returns `undefined` when
87
+ * nothing changed (a document-source view is final; an open stream with no
88
+ * new chunks leaves the view alone). Errors are retained as view fields and
89
+ * retried by the next poll, never thrown. */
90
+ async poll(view: PollableSessionView): Promise<SessionFeedUpdate | undefined> {
91
+ // A finalized document is the end state; only a failed read of it retries.
92
+ if (view.source.kind === 'document' && view.error === undefined) return undefined
93
+
94
+ const polledSource = view.source.kind
95
+ const since = view.source.kind === 'parts' ? view.source.lastSeq : 0
96
+ const base: SessionViewFields = {
97
+ status: view.status,
98
+ ...(view.outcome !== undefined ? { outcome: view.outcome } : {}),
99
+ source: view.source,
100
+ ...(view.error !== undefined ? { error: view.error } : {}),
101
+ }
102
+ try {
103
+ const read = await this.store.readStream(this.streamId(view), { since, waitSeconds: 0 })
104
+
105
+ // A clean close appends no chunk, so a closed read with no new chunks
106
+ // is the normal close signal — not a reason to swap sources. When the
107
+ // view already holds part content, keep it: `error` and `abort` lines
108
+ // exist only on the parts path, and a failed artifact read must never
109
+ // turn a healthy view into an error view. The finalized-artifact
110
+ // fallback applies only when there is nothing to keep — a closed
111
+ // session opened to an empty (e.g. pruned) chunk log.
112
+ if (
113
+ read.status === 'closed' &&
114
+ read.chunks.length === 0 &&
115
+ view.source.kind === 'parts' &&
116
+ view.source.parts.length === 0
117
+ ) {
118
+ return await this.artifactFallback(view, base, since, polledSource, read)
119
+ }
120
+
121
+ // A document-source view only polls to retry a failed read; the
122
+ // finalized content is already in hand, so a successful read just
123
+ // clears the error and tracks the stream's status/outcome.
124
+ if (view.source.kind === 'document') {
125
+ const outcome = read.outcome ?? base.outcome
126
+ if (read.status === base.status && outcome === base.outcome && base.error === undefined)
127
+ return undefined
128
+ return this.update(view, since, polledSource, {
129
+ status: read.status,
130
+ ...(outcome !== undefined ? { outcome } : {}),
131
+ source: view.source,
132
+ })
133
+ }
134
+
135
+ const parts = view.source.kind === 'parts' ? [...view.source.parts] : []
136
+ let lastSeq = since
137
+ for (const chunk of read.chunks) {
138
+ parts.push(...chunk.parts)
139
+ if (chunk.seq > lastSeq) lastSeq = chunk.seq
140
+ }
141
+ const fields: SessionViewFields = {
142
+ status: read.status,
143
+ ...(read.outcome !== undefined ? { outcome: read.outcome } : {}),
144
+ source: { kind: 'parts', parts, lastSeq },
145
+ }
146
+ if (
147
+ read.chunks.length === 0 &&
148
+ read.status === base.status &&
149
+ read.outcome === base.outcome &&
150
+ base.error === undefined
151
+ ) {
152
+ return undefined
153
+ }
154
+ return this.update(view, since, polledSource, fields)
155
+ } catch (error) {
156
+ return this.update(view, since, polledSource, { ...base, error: errorMessage(error) })
157
+ }
158
+ }
159
+
160
+ /** Read the closed stream's deposited document artifact. A failed or absent
161
+ * read keeps the view's current content and carries the error for the next
162
+ * poll to retry. */
163
+ private async artifactFallback(
164
+ view: PollableSessionView,
165
+ base: SessionViewFields,
166
+ since: number,
167
+ polledSource: 'parts' | 'document',
168
+ read: StreamRead,
169
+ ): Promise<SessionFeedUpdate> {
170
+ try {
171
+ const artifact = await this.store.getArtifact(this.slug, `stream:${this.streamId(view)}`, 0)
172
+ const document = artifact === null ? undefined : parseDocument(artifact.content)
173
+ if (document === undefined) {
174
+ return this.update(view, since, polledSource, {
175
+ ...base,
176
+ status: 'closed',
177
+ ...(read.outcome !== undefined ? { outcome: read.outcome } : {}),
178
+ error:
179
+ artifact === null
180
+ ? `stream artifact stream:${this.streamId(view)} is not retrievable`
181
+ : 'stream artifact is not a readable session document',
182
+ })
183
+ }
184
+ return this.update(view, since, polledSource, {
185
+ status: 'closed',
186
+ ...(read.outcome !== undefined ? { outcome: read.outcome } : {}),
187
+ source: { kind: 'document', document },
188
+ })
189
+ } catch (error) {
190
+ return this.update(view, since, polledSource, {
191
+ ...base,
192
+ status: 'closed',
193
+ ...(read.outcome !== undefined ? { outcome: read.outcome } : {}),
194
+ error: errorMessage(error),
195
+ })
196
+ }
197
+ }
198
+
199
+ private streamId(view: PollableSessionView): string {
200
+ return view.stream
201
+ }
202
+
203
+ private update(
204
+ view: PollableSessionView,
205
+ since: number,
206
+ polledSource: 'parts' | 'document',
207
+ fields: SessionViewFields,
208
+ ): SessionFeedUpdate {
209
+ return {
210
+ slug: view.slug,
211
+ sessionId: view.sessionId,
212
+ stream: view.stream,
213
+ since,
214
+ polledSource,
215
+ fields,
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * The apply-fence: apply a feed update to `current` only when it is still the
222
+ * same session view this update was polled from, at the same cursor. Anything
223
+ * else — an Escape to detail, a session switch, a newer apply — rejects the
224
+ * stale read by returning `undefined`.
225
+ */
226
+ export function applySessionFeedUpdate(
227
+ current: SessionDashboardView | undefined,
228
+ update: SessionFeedUpdate,
229
+ ): SessionDashboardView | undefined {
230
+ if (current === undefined) return undefined
231
+ if (
232
+ current.slug !== update.slug ||
233
+ current.sessionId !== update.sessionId ||
234
+ current.stream !== update.stream
235
+ ) {
236
+ return undefined
237
+ }
238
+ if (update.polledSource === 'parts') {
239
+ if (current.source.kind !== 'parts' || current.source.lastSeq !== update.since) return undefined
240
+ } else if (current.source.kind !== 'document') {
241
+ return undefined
242
+ }
243
+ return { ...current, ...update.fields }
244
+ }