@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.
- package/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-stream batching writer (SPEC §9): the store-side sink the
|
|
3
|
+
* build-runner hands to a runtime registration's `openSessionStream`
|
|
4
|
+
* capability. Buffers appended protocol parts and flushes them to the
|
|
5
|
+
* store on a short cadence — well inside the one-second latency bound —
|
|
6
|
+
* instead of one request per delta.
|
|
7
|
+
*
|
|
8
|
+
* Failure containment is the contract: `append` NEVER throws, an append
|
|
9
|
+
* error retries on the next tick, and after three consecutive failed
|
|
10
|
+
* flushes the writer stops streaming the session, best-effort closes the
|
|
11
|
+
* stream as `aborted` (first close wins), and reports once through the
|
|
12
|
+
* diagnostics hook. A failing stream path can therefore never fail a turn,
|
|
13
|
+
* a deposit, or a phase. `close` is flush-then-close, idempotent, first
|
|
14
|
+
* outcome wins.
|
|
15
|
+
*
|
|
16
|
+
* Per-part payloads above the documented `STREAM_PART_MAX_BYTES` are
|
|
17
|
+
* truncated on a UTF-8 boundary and followed by a `data-ab-truncation`
|
|
18
|
+
* part naming the omitted byte count, so no single delta can exceed the
|
|
19
|
+
* store's batch ceiling.
|
|
20
|
+
*
|
|
21
|
+
* Flushes are single-flight: at most one drain runs at a time, so parts are
|
|
22
|
+
* appended to the store strictly in buffer order (per-stream sequencing can
|
|
23
|
+
* never reorder), retries re-queue at the front under the same discipline,
|
|
24
|
+
* and `close` waits out any in-flight drain before closing the stream.
|
|
25
|
+
*/
|
|
26
|
+
import type { SessionStreamSink } from '../../ports/types'
|
|
27
|
+
import { STREAM_PART_MAX_BYTES, truncationPart } from '../../ports/runner/stream-parts'
|
|
28
|
+
import type { JsonRecord } from '../../ports/runner/json-record'
|
|
29
|
+
import {
|
|
30
|
+
serializedBatchSize,
|
|
31
|
+
STREAM_BATCH_MAX_BYTES,
|
|
32
|
+
type StreamOutcome,
|
|
33
|
+
type StreamPart,
|
|
34
|
+
type StreamRecord,
|
|
35
|
+
type StreamScope,
|
|
36
|
+
} from './types'
|
|
37
|
+
|
|
38
|
+
export interface SessionStreamSinkOptions {
|
|
39
|
+
/** Any BuildStore handle scoped so `createStream(scope)` succeeds — the
|
|
40
|
+
* build-runner passes its build-scoped handle. */
|
|
41
|
+
store: {
|
|
42
|
+
createStream(scope: StreamScope, label: string): Promise<StreamRecord>
|
|
43
|
+
appendStreamParts(streamId: string, parts: StreamPart[]): Promise<unknown>
|
|
44
|
+
closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord>
|
|
45
|
+
}
|
|
46
|
+
scope: StreamScope
|
|
47
|
+
/** Called at most once per sink, when streaming gives up. */
|
|
48
|
+
onDiagnostic?: (message: string) => void
|
|
49
|
+
/** Flush cadence in milliseconds. Default 250. */
|
|
50
|
+
flushMs?: number
|
|
51
|
+
/** Injectable scheduler for tests. Default: an unref'ed interval. */
|
|
52
|
+
schedule?: (flush: () => void, ms: number) => () => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Consecutive failed flushes before the writer stops streaming a session. */
|
|
56
|
+
const MAX_FLUSH_FAILURES = 3
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cut `text` to at most `maxBytes` UTF-8 bytes without splitting a
|
|
60
|
+
* multi-byte sequence. Returns the prefix and the omitted byte count.
|
|
61
|
+
*/
|
|
62
|
+
function truncateUtf8(text: string, maxBytes: number): { text: string; omitted: number } {
|
|
63
|
+
const bytes = new TextEncoder().encode(text)
|
|
64
|
+
if (bytes.byteLength <= maxBytes) return { text, omitted: 0 }
|
|
65
|
+
let cut = maxBytes
|
|
66
|
+
while (cut > 0 && (bytes[cut]! & 0xc0) === 0x80) cut -= 1
|
|
67
|
+
return {
|
|
68
|
+
text: new TextDecoder().decode(bytes.slice(0, cut)),
|
|
69
|
+
omitted: bytes.byteLength - cut,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bound one part's payload against `STREAM_PART_MAX_BYTES`. Text and
|
|
75
|
+
* reasoning deltas truncate their `delta`; tool inputs/outputs truncate
|
|
76
|
+
* their JSON serialization, keeping the payload's JSON kind (objects stay
|
|
77
|
+
* objects, strings stay strings); the prompt part truncates `data.text`.
|
|
78
|
+
* Anything else passes through untouched. Returns the (possibly replaced)
|
|
79
|
+
* part plus a `data-ab-truncation` part when bytes were omitted.
|
|
80
|
+
*/
|
|
81
|
+
function boundPart(part: StreamPart, maxBytes: number): StreamPart[] {
|
|
82
|
+
const record = part as JsonRecord
|
|
83
|
+
switch (part.type) {
|
|
84
|
+
case 'text-delta':
|
|
85
|
+
case 'reasoning-delta': {
|
|
86
|
+
const delta = record.delta
|
|
87
|
+
if (typeof delta !== 'string') return [part]
|
|
88
|
+
const { text, omitted } = truncateUtf8(delta, maxBytes)
|
|
89
|
+
return omitted === 0
|
|
90
|
+
? [part]
|
|
91
|
+
: [{ ...record, type: part.type, delta: text }, truncationPart(omitted)]
|
|
92
|
+
}
|
|
93
|
+
case 'tool-input-available':
|
|
94
|
+
case 'tool-output-available': {
|
|
95
|
+
const key = part.type === 'tool-input-available' ? 'input' : 'output'
|
|
96
|
+
const value = record[key]
|
|
97
|
+
if (value === undefined) return [part]
|
|
98
|
+
// Strings truncate as strings; objects stay objects (the shape the
|
|
99
|
+
// part type carries) with the truncated serialization inside.
|
|
100
|
+
if (typeof value === 'string') {
|
|
101
|
+
const { text, omitted } = truncateUtf8(value, maxBytes)
|
|
102
|
+
return omitted === 0
|
|
103
|
+
? [part]
|
|
104
|
+
: [{ ...record, type: part.type, [key]: text }, truncationPart(omitted)]
|
|
105
|
+
}
|
|
106
|
+
const serialized = JSON.stringify(value)
|
|
107
|
+
if (typeof serialized !== 'string') return [part]
|
|
108
|
+
const { text, omitted } = truncateUtf8(serialized, maxBytes)
|
|
109
|
+
return omitted === 0
|
|
110
|
+
? [part]
|
|
111
|
+
: [{ ...record, type: part.type, [key]: { truncated: text } }, truncationPart(omitted)]
|
|
112
|
+
}
|
|
113
|
+
default: {
|
|
114
|
+
// data-ab-prompt carries the turn's prompt in data.text; other data
|
|
115
|
+
// parts are small bracket metadata and pass through.
|
|
116
|
+
if (part.type !== 'data-ab-prompt') return [part]
|
|
117
|
+
const data = record.data
|
|
118
|
+
if (typeof data !== 'object' || data === null) return [part]
|
|
119
|
+
const text = (data as JsonRecord).text
|
|
120
|
+
if (typeof text !== 'string') return [part]
|
|
121
|
+
const { text: bounded, omitted } = truncateUtf8(text, maxBytes)
|
|
122
|
+
if (omitted === 0) return [part]
|
|
123
|
+
return [
|
|
124
|
+
{ ...record, type: part.type, data: { ...(data as JsonRecord), text: bounded } },
|
|
125
|
+
truncationPart(omitted),
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create the bracket-scoped sink. `open` creates the store stream and
|
|
133
|
+
* returns its id; appends buffer until then and flush on the cadence.
|
|
134
|
+
*/
|
|
135
|
+
export function createSessionStreamSink(options: SessionStreamSinkOptions): SessionStreamSink {
|
|
136
|
+
const { store, scope } = options
|
|
137
|
+
const flushMs = options.flushMs ?? 250
|
|
138
|
+
const maxPartBytes = STREAM_PART_MAX_BYTES
|
|
139
|
+
|
|
140
|
+
let streamId: string | undefined
|
|
141
|
+
let openError: unknown
|
|
142
|
+
const buffer: StreamPart[] = []
|
|
143
|
+
let flushFailures = 0
|
|
144
|
+
let defunct = false
|
|
145
|
+
let diagnosticSent = false
|
|
146
|
+
let closed = false
|
|
147
|
+
let firstOutcome: StreamOutcome | undefined
|
|
148
|
+
let cancelSchedule: (() => void) | undefined
|
|
149
|
+
/** Single-flight drain discipline: at most one `doFlush` runs at a time;
|
|
150
|
+
* everything else joins the in-flight promise, so the shared buffer is
|
|
151
|
+
* never mutated by two concurrent flushes and per-stream part order is
|
|
152
|
+
* the store-arrival order. */
|
|
153
|
+
let inFlight: Promise<void> | undefined
|
|
154
|
+
|
|
155
|
+
const schedule =
|
|
156
|
+
options.schedule ??
|
|
157
|
+
((flush, ms) => {
|
|
158
|
+
const timer = setInterval(flush, ms)
|
|
159
|
+
timer.unref?.()
|
|
160
|
+
return () => clearInterval(timer)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
function onDiagnosticOnce(message: string): void {
|
|
164
|
+
if (diagnosticSent) return
|
|
165
|
+
diagnosticSent = true
|
|
166
|
+
options.onDiagnostic?.(message)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function ensureTimer(): void {
|
|
170
|
+
if (cancelSchedule !== undefined || defunct || closed) return
|
|
171
|
+
cancelSchedule = schedule(() => void requestFlush(), flushMs)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One drain of the buffer. Never overlapping: it only starts when no
|
|
175
|
+
* drain is in flight (`requestFlush` gates that), so the shared buffer is
|
|
176
|
+
* never mutated by two concurrent flushes. */
|
|
177
|
+
async function doFlush(): Promise<void> {
|
|
178
|
+
if (streamId === undefined || defunct || closed) return
|
|
179
|
+
// Split the buffer into store-sized batches (parts were already
|
|
180
|
+
// per-part bounded at append time, so every batch eventually lands).
|
|
181
|
+
// Parts appended mid-drain are picked up by the loop's re-check, so a
|
|
182
|
+
// drain ends only when the buffer is empty or the writer gave up.
|
|
183
|
+
while (streamId !== undefined && !defunct && !closed && buffer.length > 0) {
|
|
184
|
+
const batch: StreamPart[] = [buffer[0]!]
|
|
185
|
+
buffer.shift()
|
|
186
|
+
while (
|
|
187
|
+
buffer.length > 0 &&
|
|
188
|
+
serializedBatchSize([...batch, buffer[0]!]) <= STREAM_BATCH_MAX_BYTES
|
|
189
|
+
) {
|
|
190
|
+
batch.push(buffer.shift()!)
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
await store.appendStreamParts(streamId, batch)
|
|
194
|
+
flushFailures = 0
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// Put the parts back in order and retry on the next tick.
|
|
197
|
+
buffer.unshift(...batch)
|
|
198
|
+
flushFailures += 1
|
|
199
|
+
if (flushFailures >= MAX_FLUSH_FAILURES) {
|
|
200
|
+
defunct = true
|
|
201
|
+
cancelSchedule?.()
|
|
202
|
+
cancelSchedule = undefined
|
|
203
|
+
onDiagnosticOnce(
|
|
204
|
+
`session stream "${streamId}" stopped after ${MAX_FLUSH_FAILURES} failed appends: ` +
|
|
205
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
206
|
+
)
|
|
207
|
+
// Reentrant close: this drain is the current call stack, so
|
|
208
|
+
// `closeSink` skips its own drain (`defunct` guards the loop).
|
|
209
|
+
await closeSink('aborted')
|
|
210
|
+
}
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The one way to start (or join) a drain. Never overlaps a running one. */
|
|
217
|
+
function requestFlush(): Promise<void> {
|
|
218
|
+
if (inFlight !== undefined) return inFlight
|
|
219
|
+
const run = doFlush()
|
|
220
|
+
const tracked = run.finally(() => {
|
|
221
|
+
if (inFlight === tracked) inFlight = undefined
|
|
222
|
+
})
|
|
223
|
+
inFlight = tracked
|
|
224
|
+
return tracked
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** First-outcome-wins, flush-then-close, best-effort. An undeliverable
|
|
228
|
+
* buffer at close time means the stream's content is incomplete: it closes
|
|
229
|
+
* `aborted` with one diagnostic, whatever outcome was requested. */
|
|
230
|
+
async function closeSink(outcome: StreamOutcome): Promise<void> {
|
|
231
|
+
if (closed || firstOutcome !== undefined) return
|
|
232
|
+
firstOutcome = outcome
|
|
233
|
+
cancelSchedule?.()
|
|
234
|
+
cancelSchedule = undefined
|
|
235
|
+
if (streamId !== undefined) {
|
|
236
|
+
try {
|
|
237
|
+
// Flush-then-close under the single-flight discipline: wait out any
|
|
238
|
+
// in-flight drain, then keep draining until the buffer is empty —
|
|
239
|
+
// or the writer has given up (defunct, or a failed batch awaiting
|
|
240
|
+
// its next-tick retry, which closes `aborted` below).
|
|
241
|
+
while (
|
|
242
|
+
!defunct &&
|
|
243
|
+
!closed &&
|
|
244
|
+
flushFailures === 0 &&
|
|
245
|
+
(inFlight !== undefined || buffer.length > 0)
|
|
246
|
+
) {
|
|
247
|
+
if (inFlight !== undefined) {
|
|
248
|
+
await inFlight
|
|
249
|
+
} else {
|
|
250
|
+
await requestFlush()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// flush is defensive; appends already handle their own failures.
|
|
255
|
+
}
|
|
256
|
+
let effective = outcome
|
|
257
|
+
if (flushFailures > 0) {
|
|
258
|
+
effective = 'aborted'
|
|
259
|
+
onDiagnosticOnce(
|
|
260
|
+
`session stream "${streamId}" closed with ${buffer.length} undelivered parts ` +
|
|
261
|
+
`after ${flushFailures} failed appends`,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await store.closeStream(streamId, effective)
|
|
266
|
+
} catch (error) {
|
|
267
|
+
onDiagnosticOnce(
|
|
268
|
+
`session stream "${streamId}" failed to close: ` +
|
|
269
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
closed = true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
async open(label) {
|
|
278
|
+
try {
|
|
279
|
+
const record = await store.createStream(scope, label)
|
|
280
|
+
streamId = record.id
|
|
281
|
+
ensureTimer()
|
|
282
|
+
// Parts appended while opening flush immediately after the id lands.
|
|
283
|
+
await requestFlush()
|
|
284
|
+
return record.id
|
|
285
|
+
} catch (error) {
|
|
286
|
+
openError = error
|
|
287
|
+
throw error
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
append(parts) {
|
|
292
|
+
// Never throws: after defunct/close or before a successful open,
|
|
293
|
+
// parts are dropped or buffered without surfacing an error.
|
|
294
|
+
if (defunct || closed) return
|
|
295
|
+
if (openError !== undefined && streamId === undefined) return
|
|
296
|
+
for (const part of parts) {
|
|
297
|
+
buffer.push(...boundPart(part, maxPartBytes))
|
|
298
|
+
}
|
|
299
|
+
if (streamId !== undefined) ensureTimer()
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async close(outcome) {
|
|
303
|
+
await closeSink(outcome)
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stream primitive (SPEC §7.6): the BuildStore's third kind of durable
|
|
3
|
+
* content, alongside the append-only event log of typed facts and the
|
|
4
|
+
* content-addressed artifacts of bulk bytes. A stream is an append-only,
|
|
5
|
+
* per-stream sequenced log of chunks — a chunk being a batch of protocol
|
|
6
|
+
* parts — with an open-then-closed lifecycle that finalizes into an artifact.
|
|
7
|
+
*
|
|
8
|
+
* The chunk vocabulary is the Vercel AI SDK UI Message Stream protocol,
|
|
9
|
+
* version 1 of the current major (AI SDK 7). Storing the protocol's parts
|
|
10
|
+
* rather than a private shape means a read route can later re-emit them as
|
|
11
|
+
* Server-Sent Events that the AI SDK's own client hooks consume unchanged,
|
|
12
|
+
* and a closed stream finalizes into the protocol's `UIMessage[]` document.
|
|
13
|
+
*
|
|
14
|
+
* Streams are presentation, never routing: no kernel, engine, reducer, or
|
|
15
|
+
* dispatcher decision reads stream content; outcomes travel only the typed
|
|
16
|
+
* CLI. This module — like everything under `store/` — encodes nothing about
|
|
17
|
+
* this repository's specifics.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The literal format identifier carried on every stream record. */
|
|
21
|
+
export const STREAM_FORMAT = 'ai-ui-message-stream/v1'
|
|
22
|
+
|
|
23
|
+
/** The serialized-batch ceiling: the same byte bound artifacts enforce. */
|
|
24
|
+
export const STREAM_BATCH_MAX_BYTES = 1_048_576
|
|
25
|
+
|
|
26
|
+
/** Upper bound for a read's wait, in whole seconds. Larger requests clamp. */
|
|
27
|
+
export const MAX_STREAM_WAIT_SECONDS = 30
|
|
28
|
+
|
|
29
|
+
/** One protocol part: a JSON object with a nonempty-string `type`. The store
|
|
30
|
+
* performs no further protocol validation on append — assembly at close does
|
|
31
|
+
* that, following the protocol for its defined part types and dropping and
|
|
32
|
+
* counting parts of undefined types. */
|
|
33
|
+
export type StreamPart = { type: string } & Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
/** A store-assigned, per-stream sequenced batch of parts. */
|
|
36
|
+
export interface StreamChunk {
|
|
37
|
+
stream: string
|
|
38
|
+
/** Per-stream sequence assigned by the store, starting at 1. Producers
|
|
39
|
+
* cannot fake ordering. */
|
|
40
|
+
seq: number
|
|
41
|
+
ts: string
|
|
42
|
+
parts: StreamPart[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a stream lives. The vocabulary is closed: builds, the repository
|
|
46
|
+
* journal, and operator sessions (SPEC §7.1.1) — every turn's content is one
|
|
47
|
+
* session-scoped stream. */
|
|
48
|
+
export type StreamScope =
|
|
49
|
+
| { kind: 'build'; build: string }
|
|
50
|
+
| { kind: 'repo'; repo: string }
|
|
51
|
+
| { kind: 'session'; session: string }
|
|
52
|
+
|
|
53
|
+
export type StreamStatus = 'open' | 'closed'
|
|
54
|
+
export type StreamOutcome = 'completed' | 'aborted'
|
|
55
|
+
|
|
56
|
+
/** Reference to the artifact the close deposited on the owning scope. */
|
|
57
|
+
export interface StreamArtifactRef {
|
|
58
|
+
kind: string
|
|
59
|
+
revision: number
|
|
60
|
+
blobRef: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface StreamRecord {
|
|
64
|
+
id: string
|
|
65
|
+
scope: StreamScope
|
|
66
|
+
/** Caller-supplied label; presentation metadata only. */
|
|
67
|
+
label: string
|
|
68
|
+
format: typeof STREAM_FORMAT
|
|
69
|
+
status: StreamStatus
|
|
70
|
+
createdAt: string
|
|
71
|
+
closedAt?: string
|
|
72
|
+
outcome?: StreamOutcome
|
|
73
|
+
/** Present once closed: the finalized `UIMessage[]` document's artifact. */
|
|
74
|
+
artifact?: StreamArtifactRef
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface StreamRead {
|
|
78
|
+
chunks: StreamChunk[]
|
|
79
|
+
status: StreamStatus
|
|
80
|
+
outcome?: StreamOutcome
|
|
81
|
+
artifact?: StreamArtifactRef
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Appending a batch whose serialized size exceeds the ceiling. Typed so the
|
|
85
|
+
* remote server maps it to `413 {kind:'validation'}` like the artifact
|
|
86
|
+
* ceiling and the remote client rehydrates it; the message names the bound. */
|
|
87
|
+
export class StreamBatchTooLargeError extends Error {
|
|
88
|
+
constructor(
|
|
89
|
+
readonly bytes: number | undefined,
|
|
90
|
+
readonly maxBytes: number = STREAM_BATCH_MAX_BYTES,
|
|
91
|
+
message?: string,
|
|
92
|
+
) {
|
|
93
|
+
super(message ?? `stream batch of ${bytes} bytes exceeds the ${maxBytes}-byte ceiling`)
|
|
94
|
+
this.name = 'StreamBatchTooLargeError'
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Appending to a closed stream. Typed so the remote server maps it to
|
|
99
|
+
* `409 {kind:'conflict'}` and the remote client rehydrates it. The argument
|
|
100
|
+
* is the stream id locally, or the server's message over the wire. */
|
|
101
|
+
export class StreamClosedError extends Error {
|
|
102
|
+
constructor(streamOrMessage: string) {
|
|
103
|
+
super(
|
|
104
|
+
streamOrMessage.startsWith('stream "')
|
|
105
|
+
? streamOrMessage
|
|
106
|
+
: `stream "${streamOrMessage}" is closed`,
|
|
107
|
+
)
|
|
108
|
+
this.name = 'StreamClosedError'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Append validation: a nonempty array of parts, each a JSON object whose
|
|
113
|
+
* `type` is a nonempty string. Throws a plain `Error` otherwise. */
|
|
114
|
+
export function validateStreamParts(parts: StreamPart[]): void {
|
|
115
|
+
if (!Array.isArray(parts) || parts.length === 0) {
|
|
116
|
+
throw new Error('stream parts must be a nonempty array')
|
|
117
|
+
}
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (typeof part !== 'object' || part === null || Array.isArray(part)) {
|
|
120
|
+
throw new Error('every stream part must be a JSON object')
|
|
121
|
+
}
|
|
122
|
+
if (typeof part.type !== 'string' || part.type.length === 0) {
|
|
123
|
+
throw new Error('every stream part must carry a nonempty string "type"')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Serialized JSON size of a batch, in bytes — the value compared against
|
|
129
|
+
* `STREAM_BATCH_MAX_BYTES`. */
|
|
130
|
+
export function serializedBatchSize(parts: StreamPart[]): number {
|
|
131
|
+
return new TextEncoder().encode(JSON.stringify(parts)).byteLength
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The read-wait bound: whole seconds, clamped above 30 and below 0. */
|
|
135
|
+
export function clampWaitSeconds(waitSeconds: number): number {
|
|
136
|
+
return Math.max(0, Math.min(MAX_STREAM_WAIT_SECONDS, Math.floor(waitSeconds)))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The close-time artifact input every adapter deposits: the assembled
|
|
141
|
+
* `UIMessage[]` document as JSON bytes, kind `stream:<streamId>`, with
|
|
142
|
+
* metadata naming the stream id, label, scope, outcome, chunk count, and
|
|
143
|
+
* dropped-part count. Shared so every adapter's finalized artifact is
|
|
144
|
+
* byte-identical for the same stream.
|
|
145
|
+
*/
|
|
146
|
+
export function streamArtifactInput(
|
|
147
|
+
stream: string,
|
|
148
|
+
scope: StreamScope,
|
|
149
|
+
label: string,
|
|
150
|
+
outcome: StreamOutcome,
|
|
151
|
+
document: unknown,
|
|
152
|
+
chunkCount: number,
|
|
153
|
+
droppedPartCount: number,
|
|
154
|
+
): {
|
|
155
|
+
kind: string
|
|
156
|
+
content: string
|
|
157
|
+
metadata: Record<string, unknown>
|
|
158
|
+
} {
|
|
159
|
+
return {
|
|
160
|
+
kind: `stream:${stream}`,
|
|
161
|
+
content: JSON.stringify(document),
|
|
162
|
+
metadata: {
|
|
163
|
+
stream,
|
|
164
|
+
label,
|
|
165
|
+
scope: { ...scope },
|
|
166
|
+
outcome,
|
|
167
|
+
chunkCount,
|
|
168
|
+
droppedPartCount,
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared read-wait loop (SPEC §7.6): when a read finds no newer chunk and
|
|
3
|
+
* the stream is open, the adapter honors the wait bound — returning no later
|
|
4
|
+
* than the bound, but as soon as a chunk is appended or the stream closes.
|
|
5
|
+
* The memory, SQLite, and PostgreSQL adapters all delegate here so wait
|
|
6
|
+
* semantics are adapter-uniform; the remote adapter instead delegates the
|
|
7
|
+
* wait to its backing store over one held-open request.
|
|
8
|
+
*
|
|
9
|
+
* A small poll loop rather than a waiter registry: the uniformity across
|
|
10
|
+
* synchronous (SQLite) and asynchronous (PostgreSQL) backends is worth more
|
|
11
|
+
* than wake-on-write precision. The loop serves two cadences: held *stream*
|
|
12
|
+
* reads are presentation content and poll at the ~25 ms `STREAM_WAIT_POLL_MS`;
|
|
13
|
+
* held *event* reads (build, repository, session — AUT-334/381/383) take the
|
|
14
|
+
* one-second `EVENT_WAIT_POLL_MS` budget when the adapter passes it per call
|
|
15
|
+
* (see `EVENT_WAIT_POLL_MS` for which adapters do). Closed streams never wait.
|
|
16
|
+
*
|
|
17
|
+
* Both loops accept an optional `signal` (AUT-380): when it aborts, the hold
|
|
18
|
+
* ends promptly — the inter-poll sleep resolves early, the loop stops, and the
|
|
19
|
+
* read resolves with its current result instead of polling to the wait bound.
|
|
20
|
+
* This is what lets a remote server cancel a disconnected peer's backing read
|
|
21
|
+
* rather than let it poll an unobserved database. Resolving (never rejecting)
|
|
22
|
+
* keeps every caller on one code path: callers treat the empty result exactly
|
|
23
|
+
* as they treat the hold expiring.
|
|
24
|
+
*/
|
|
25
|
+
import { clampWaitSeconds, type StreamRead } from './types'
|
|
26
|
+
|
|
27
|
+
export const STREAM_WAIT_POLL_MS = 25
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The one-second held-event poll budget (the hosted per-query budget,
|
|
31
|
+
* AUT-334/381/383): held event reads — build, repository, and session —
|
|
32
|
+
* re-query at most once per second, so an append is observed at the next
|
|
33
|
+
* poll, typically within about one second. Held *stream* reads are excluded:
|
|
34
|
+
* they are presentation content and stay at the ~25 ms `STREAM_WAIT_POLL_MS`
|
|
35
|
+
* cadence. This deliberately diverges from `STREAM_WAIT_POLL_MS`; adapters
|
|
36
|
+
* pass it as `pollMs` on their event-family calls to `readEventsWithWait` —
|
|
37
|
+
* the PostgreSQL and memory stores do on all three families (AUT-334/381/383).
|
|
38
|
+
* The SQLite store has not adopted the budget (out of scope for AUT-383), so
|
|
39
|
+
* its held event reads fall back to the stream default until it does.
|
|
40
|
+
*
|
|
41
|
+
* The PostgreSQL adapter treats the bound as nominal, not hard: a worst-case
|
|
42
|
+
* ≤1 s guarantee would need Postgres LISTEN/NOTIFY wake-on-append — one
|
|
43
|
+
* dedicated connection per held request on a pooled provider (Neon) — a cost
|
|
44
|
+
* considered and declined (AUT-334). Held event reads there re-query the
|
|
45
|
+
* database at most once per second, so an append by another connection is
|
|
46
|
+
* observed at the next poll: typically within about one second, worst case
|
|
47
|
+
* one poll interval plus the query round-trip.
|
|
48
|
+
*/
|
|
49
|
+
export const EVENT_WAIT_POLL_MS = 1000
|
|
50
|
+
|
|
51
|
+
/** Sleep `ms`, or return early when `signal` aborts. Resolves (never
|
|
52
|
+
* rejects) on either path; the abort listener is removed on both so a
|
|
53
|
+
* long-held read cannot leak listeners. No signal (or an already-aborted
|
|
54
|
+
* one) never waits. */
|
|
55
|
+
function sleepWithAbort(ms: number, signal?: AbortSignal): Promise<void> {
|
|
56
|
+
if (signal === undefined) return Bun.sleep(ms)
|
|
57
|
+
if (signal.aborted) return Promise.resolve()
|
|
58
|
+
return new Promise<void>((resolve) => {
|
|
59
|
+
let settled = false
|
|
60
|
+
const finish = (): void => {
|
|
61
|
+
if (settled) return
|
|
62
|
+
settled = true
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
signal.removeEventListener('abort', onAbort)
|
|
65
|
+
resolve()
|
|
66
|
+
}
|
|
67
|
+
const timer = setTimeout(finish, ms)
|
|
68
|
+
const onAbort = (): void => finish()
|
|
69
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read once, then poll until newer chunks arrive, the stream closes, the
|
|
75
|
+
* clamped wall-clock deadline passes, or `signal` aborts. `read` must
|
|
76
|
+
* re-query the store on every call; a throw from it propagates immediately.
|
|
77
|
+
* An abort ends the hold with the current result (a pre-aborted signal still
|
|
78
|
+
* performs the initial read — reads are cheap and side-effect-free — but
|
|
79
|
+
* never waits).
|
|
80
|
+
*/
|
|
81
|
+
export async function readStreamWithWait(opts: {
|
|
82
|
+
read: () => Promise<StreamRead>
|
|
83
|
+
waitSeconds?: number
|
|
84
|
+
pollMs?: number
|
|
85
|
+
signal?: AbortSignal
|
|
86
|
+
}): Promise<StreamRead> {
|
|
87
|
+
const pollMs = opts.pollMs ?? STREAM_WAIT_POLL_MS
|
|
88
|
+
const deadline = Date.now() + clampWaitSeconds(opts.waitSeconds ?? 0) * 1000
|
|
89
|
+
let result = await opts.read()
|
|
90
|
+
while (
|
|
91
|
+
!opts.signal?.aborted &&
|
|
92
|
+
result.status === 'open' &&
|
|
93
|
+
result.chunks.length === 0 &&
|
|
94
|
+
Date.now() < deadline
|
|
95
|
+
) {
|
|
96
|
+
await sleepWithAbort(pollMs, opts.signal)
|
|
97
|
+
if (opts.signal?.aborted) break
|
|
98
|
+
result = await opts.read()
|
|
99
|
+
}
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The event-read variant of the shared wait loop: read once, then poll until
|
|
105
|
+
* newer events arrive, the clamped deadline passes, or `signal` aborts.
|
|
106
|
+
* Session event reads (§7.1.1) honor the same clamp/early-return rules as
|
|
107
|
+
* stream reads so the operator UI's poll loop cannot drift between the two;
|
|
108
|
+
* there is no "closed" state to short-circuit on — an event log is always
|
|
109
|
+
* open. An abort ends the hold with the current result (a pre-aborted signal
|
|
110
|
+
* still performs the initial read but never waits).
|
|
111
|
+
*/
|
|
112
|
+
export async function readEventsWithWait<T>(opts: {
|
|
113
|
+
read: () => Promise<T[]>
|
|
114
|
+
waitSeconds?: number
|
|
115
|
+
pollMs?: number
|
|
116
|
+
signal?: AbortSignal
|
|
117
|
+
}): Promise<T[]> {
|
|
118
|
+
const pollMs = opts.pollMs ?? STREAM_WAIT_POLL_MS
|
|
119
|
+
const deadline = Date.now() + clampWaitSeconds(opts.waitSeconds ?? 0) * 1000
|
|
120
|
+
let result = await opts.read()
|
|
121
|
+
while (result.length === 0 && !opts.signal?.aborted && Date.now() < deadline) {
|
|
122
|
+
await sleepWithAbort(pollMs, opts.signal)
|
|
123
|
+
if (opts.signal?.aborted) break
|
|
124
|
+
result = await opts.read()
|
|
125
|
+
}
|
|
126
|
+
return result
|
|
127
|
+
}
|