@defrex/autobuild 0.9.0 → 0.10.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/README.md +1 -1
- package/SPEC.md +10 -0
- package/docs/architecture.md +109 -0
- package/docs/configuration.md +24 -3
- package/docs/remote-store-protocol.md +35 -0
- package/docs/website.md +4 -0
- package/package.json +6 -4
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +85 -147
- package/packages/core/src/cli/dashboard/poll.ts +7 -2
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +80 -37
- package/packages/core/src/cli/help.ts +15 -1
- package/packages/core/src/cli/init-validation.ts +18 -0
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +1 -1
- package/packages/core/src/cli/ticket.ts +146 -1
- package/packages/core/src/cli/wait.ts +10 -2
- package/packages/core/src/cli/watch.ts +10 -2
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/pipeline-source.ts +17 -5
- package/packages/core/src/config/schema.ts +21 -0
- package/packages/core/src/integration/harness.ts +113 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +43 -9
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +216 -74
- package/packages/core/src/operator/query.ts +68 -28
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +32 -1
- package/packages/core/src/processes/build-execution-state.ts +31 -15
- package/packages/core/src/processes/build-runner.ts +184 -90
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +270 -176
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +44 -3
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +26 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +10 -1
- package/packages/core/src/store/build-scope.ts +26 -0
- package/packages/core/src/store/contract.ts +337 -0
- package/packages/core/src/store/digest.ts +71 -18
- package/packages/core/src/store/local/schema.ts +17 -0
- package/packages/core/src/store/local/store.ts +111 -4
- package/packages/core/src/store/memory.ts +68 -1
- package/packages/core/src/store/phase-session.ts +23 -0
- package/packages/core/src/store/remote/client.ts +50 -1
- package/packages/core/src/store/remote/index.ts +2 -0
- package/packages/core/src/store/remote/protocol.ts +15 -0
- package/packages/core/src/store/repo-state-events.ts +72 -7
- package/packages/core/src/store/session-handle.ts +30 -0
- package/packages/core/src/store/session-reducer.ts +52 -11
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/ticket-assets.ts +41 -8
- package/packages/core/src/store/types.ts +38 -0
- package/skills/guide/SKILL.md +26 -7
- package/skills/guide/references/remote-store-protocol.md +35 -0
- package/skills/tickets/SKILL.md +57 -5
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dispatcher's in-process repository view (AUT-647): a `BuildStore`
|
|
3
|
+
* decorator that builds the state a tick needs once, at the first refresh, and
|
|
4
|
+
* on every later refresh reads only events newer than the cursors it already
|
|
5
|
+
* holds — one journal delta, one delta per live build, and the discovery
|
|
6
|
+
* listing/digest reads that find builds appearing since.
|
|
7
|
+
*
|
|
8
|
+
* Every tick stage talks to this object instead of the raw store, so
|
|
9
|
+
*
|
|
10
|
+
* - reads (`getEvents`, `getRepoEvents`, `getRepoStateEvents`,
|
|
11
|
+
* `getRepoBuildDigests`, `listBuilds`) are answered from memory;
|
|
12
|
+
* - an event a stage appends is folded in immediately, so a later stage of the
|
|
13
|
+
* same tick sees it with no re-read (a foreign append interleaved with ours
|
|
14
|
+
* leaves a seq gap, which marks the log dirty so the next read deltas);
|
|
15
|
+
* - a failed compare-and-set marks its log dirty, so every existing CAS loop
|
|
16
|
+
* that re-reads after a conflict observes the winner's event unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Concurrency: every state transition of one source (a build slug, the journal,
|
|
19
|
+
* the listing/discovery pair) runs on that source's single in-flight promise
|
|
20
|
+
* chain — snapshot reads, delta reads, window opening and widening, the fold
|
|
21
|
+
* after an own append, a lost compare-and-set's invalidation. Readers run on the
|
|
22
|
+
* chain too, so a second reader joins the repair already in flight and never
|
|
23
|
+
* observes half-applied state; nothing interleaves inside a transition, which
|
|
24
|
+
* is why no write-generation or re-check bookkeeping is needed. Chain tasks only
|
|
25
|
+
* call the backing store and the private unlocked helpers, never a public
|
|
26
|
+
* method (that would deadlock behind itself).
|
|
27
|
+
*
|
|
28
|
+
* Safety net: every delta must begin at `cursor + 1` and stay contiguous; if
|
|
29
|
+
* it does not, the log is discarded and re-read in full, so "no event is ever
|
|
30
|
+
* skipped" is enforced here rather than assumed of the adapters.
|
|
31
|
+
*
|
|
32
|
+
* What is held:
|
|
33
|
+
*
|
|
34
|
+
* - the bounded repository journal (`getRepoStateEvents`' subset), pruned on
|
|
35
|
+
* arrival by the same keep rule — durable types plus the tail from the latest
|
|
36
|
+
* `dispatcher.run-started` — which is exact for seq-ordered input;
|
|
37
|
+
* - for *work* builds (nonterminal, aborted, reclaim-pending, execution-open),
|
|
38
|
+
* the full event array. Settled terminal builds hold only the discovery
|
|
39
|
+
* digest and cost no log read;
|
|
40
|
+
* - read windows: cursor-bearing readers (the orchestrator wake pass scans
|
|
41
|
+
* every build, settled ones included, from a per-session cursor) get a
|
|
42
|
+
* per-source window holding only events above their lowest cursor.
|
|
43
|
+
*
|
|
44
|
+
* Cold start (reducer snapshots, `store/snapshots.ts`): a process with no hot
|
|
45
|
+
* memory loads each source's stored reducer state — the pruned journal view; the
|
|
46
|
+
* build, log-index and open-execution reducers for a work build — as of a
|
|
47
|
+
* cursor, reads only events newer than it, and advances. A snapshot-backed work
|
|
48
|
+
* log holds just that delta plus the reduced state of everything before it, so a
|
|
49
|
+
* quiet tick reads zero event rows. Every snapshot is a cache the log
|
|
50
|
+
* regenerates: a missing, mismatched-version, ahead-of-log, malformed or
|
|
51
|
+
* unreadable one is a miss and the source is replayed in full, with identical
|
|
52
|
+
* results. `persistSnapshots` writes the advanced state back (best effort;
|
|
53
|
+
* cursors only ever move forward in the store).
|
|
54
|
+
*/
|
|
55
|
+
import type { AbEvent, EventEnvelope, EventWrite } from '../events/catalog'
|
|
56
|
+
import { validateRepositoryEventWrite } from '../events/repository'
|
|
57
|
+
import type { EventType } from '../events/payloads'
|
|
58
|
+
import type {
|
|
59
|
+
RepositoryEvent,
|
|
60
|
+
RepositoryEventEnvelope,
|
|
61
|
+
RepositoryEventType,
|
|
62
|
+
RepositoryEventWrite,
|
|
63
|
+
} from '../events/repository'
|
|
64
|
+
import type {
|
|
65
|
+
SessionEvent,
|
|
66
|
+
SessionEventEnvelope,
|
|
67
|
+
SessionEventType,
|
|
68
|
+
SessionEventWrite,
|
|
69
|
+
} from '../events/sessions'
|
|
70
|
+
import { dashboardFactsReducer, type DashboardFacts } from '../cli/dashboard/facts'
|
|
71
|
+
import { currentDeferralObservationReducer, type DeferralLedger } from '../kernel/auto-merge'
|
|
72
|
+
import { logIndexReducer, type LogIndex } from '../kernel/log-index'
|
|
73
|
+
import { buildReducer, reduceBuild, type BuildAcc, type BuildState } from '../kernel/reducer'
|
|
74
|
+
import { createBuildScopedStore } from '../store/build-scope'
|
|
75
|
+
import { buildDigestReducer, reduceBuildDigest, type DigestEventRow } from '../store/digest'
|
|
76
|
+
import {
|
|
77
|
+
foldJournalView,
|
|
78
|
+
JOURNAL_VIEW_REDUCER_VERSION,
|
|
79
|
+
journalViewReducer,
|
|
80
|
+
type JournalViewAcc,
|
|
81
|
+
} from '../store/repo-state-events'
|
|
82
|
+
import {
|
|
83
|
+
loadReducerSnapshot,
|
|
84
|
+
persistReducerSnapshot,
|
|
85
|
+
type ReducerSnapshot,
|
|
86
|
+
type SnapshotScope,
|
|
87
|
+
} from '../store/snapshots'
|
|
88
|
+
import { openBuildWorkspaceReducer, type OpenBuildWorkspace } from './dispatcher-selectors'
|
|
89
|
+
import {
|
|
90
|
+
type ExecutionOutcome,
|
|
91
|
+
lastExecutionOutcomeReducer,
|
|
92
|
+
openExecutionReducer,
|
|
93
|
+
type OpenExecution,
|
|
94
|
+
} from './execution-settlement'
|
|
95
|
+
import { type PublicationView, publicationStateReducer } from './publication-state'
|
|
96
|
+
import { createSessionScopedStore } from '../store/session-handle'
|
|
97
|
+
import type {
|
|
98
|
+
StreamChunk,
|
|
99
|
+
StreamOutcome,
|
|
100
|
+
StreamPart,
|
|
101
|
+
StreamRead,
|
|
102
|
+
StreamRecord,
|
|
103
|
+
StreamScope,
|
|
104
|
+
} from '../store/streams/types'
|
|
105
|
+
import type {
|
|
106
|
+
TicketAsset,
|
|
107
|
+
TicketAssetInput,
|
|
108
|
+
TicketAssetLimits,
|
|
109
|
+
TicketAssetMeta,
|
|
110
|
+
TicketAssetSummary,
|
|
111
|
+
} from '../store/ticket-assets'
|
|
112
|
+
import type {
|
|
113
|
+
Artifact,
|
|
114
|
+
ArtifactInput,
|
|
115
|
+
ArtifactMeta,
|
|
116
|
+
BuildDigest,
|
|
117
|
+
BuildRecord,
|
|
118
|
+
BuildScopedStore,
|
|
119
|
+
BuildStore,
|
|
120
|
+
NewBuildInput,
|
|
121
|
+
NewSessionInput,
|
|
122
|
+
RepositoryArtifact,
|
|
123
|
+
RepositoryArtifactMeta,
|
|
124
|
+
RepositoryRecord,
|
|
125
|
+
SessionArtifact,
|
|
126
|
+
SessionArtifactMeta,
|
|
127
|
+
SessionRecord,
|
|
128
|
+
SessionScopedStore,
|
|
129
|
+
SubscribeOptions,
|
|
130
|
+
Unsubscribe,
|
|
131
|
+
} from '../store/types'
|
|
132
|
+
|
|
133
|
+
/** Registry names the snapshots are keyed by (kernel/reducer-registry.ts). */
|
|
134
|
+
const SNAPSHOT_JOURNAL = 'journalView'
|
|
135
|
+
/** A resident view persists at most this often; a cold single-tick process
|
|
136
|
+
* (the hosted dispatcher) always persists once at its tick's end. */
|
|
137
|
+
const PERSIST_MIN_INTERVAL_MS = 30_000
|
|
138
|
+
/** Builds written back concurrently. */
|
|
139
|
+
const PERSIST_CONCURRENCY = 4
|
|
140
|
+
|
|
141
|
+
/** The reducers a work build's snapshots hold beside `build`, keyed by registry
|
|
142
|
+
* name (`kernel/reducer-registry.ts`). Every quiet-path tick stage reads its
|
|
143
|
+
* facts from these, so a snapshot-backed log needs no event array. */
|
|
144
|
+
const EXTRA_REDUCERS = {
|
|
145
|
+
// The digest reducer folds the narrow rows `digestRow` projects, so it is
|
|
146
|
+
// adapted to the event-array form the rest of the bundle shares. A restored
|
|
147
|
+
// log derives its own digest from this, never from discovery.
|
|
148
|
+
buildDigest: {
|
|
149
|
+
version: buildDigestReducer.version,
|
|
150
|
+
initial: buildDigestReducer.initial,
|
|
151
|
+
advance: (acc: ReturnType<typeof buildDigestReducer.initial>, events: readonly AbEvent[]) =>
|
|
152
|
+
buildDigestReducer.advance(acc, events.map(digestRow)),
|
|
153
|
+
finish: buildDigestReducer.finish,
|
|
154
|
+
},
|
|
155
|
+
logIndex: logIndexReducer,
|
|
156
|
+
openExecution: openExecutionReducer,
|
|
157
|
+
openBuildWorkspace: openBuildWorkspaceReducer,
|
|
158
|
+
publicationState: publicationStateReducer,
|
|
159
|
+
lastExecutionOutcome: lastExecutionOutcomeReducer,
|
|
160
|
+
// The operator dashboard's row facts (`cli/dashboard/facts.ts`) and the
|
|
161
|
+
// auto-merge deferral ledger, so a row derives from the snapshot too.
|
|
162
|
+
dashboardFacts: dashboardFactsReducer,
|
|
163
|
+
currentDeferralObservation: currentDeferralObservationReducer,
|
|
164
|
+
} as const
|
|
165
|
+
type ExtraName = keyof typeof EXTRA_REDUCERS
|
|
166
|
+
const EXTRA_NAMES = Object.keys(EXTRA_REDUCERS) as ExtraName[]
|
|
167
|
+
type ExtraAccs = Record<ExtraName, unknown>
|
|
168
|
+
|
|
169
|
+
/** The one build-scope snapshot row a work build is restored from. Every
|
|
170
|
+
* accumulator rides in it at a single cursor, so one `putReducerSnapshot` —
|
|
171
|
+
* atomic per row on every adapter — publishes them together and a reader in
|
|
172
|
+
* another process sees either the old bundle or the new, never a mix (a mixed
|
|
173
|
+
* restore would fall back to a full replay). */
|
|
174
|
+
const SNAPSHOT_WORK = 'work'
|
|
175
|
+
/** Bump when the bundle layout changes. */
|
|
176
|
+
const WORK_BUNDLE_LAYOUT = 1
|
|
177
|
+
/** Derived from the component versions (they only ever increase), so a bundle
|
|
178
|
+
* written under any other reducer version misses instead of mixing shapes. */
|
|
179
|
+
export const WORK_BUNDLE_VERSION =
|
|
180
|
+
WORK_BUNDLE_LAYOUT +
|
|
181
|
+
buildReducer.version +
|
|
182
|
+
EXTRA_NAMES.reduce((total, name) => total + EXTRA_REDUCERS[name].version, 0)
|
|
183
|
+
const WORK_BUNDLE_REDUCER = { version: WORK_BUNDLE_VERSION }
|
|
184
|
+
|
|
185
|
+
/** The facts a quiet-path stage needs about one build, without its event array. */
|
|
186
|
+
export interface BuildFacts {
|
|
187
|
+
state: BuildState
|
|
188
|
+
log: LogIndex
|
|
189
|
+
open: OpenExecution | null
|
|
190
|
+
/** The open workspace (the latest provisioned, not yet released). */
|
|
191
|
+
workspace: OpenBuildWorkspace | null
|
|
192
|
+
publication: PublicationView
|
|
193
|
+
lastExecution: ExecutionOutcome
|
|
194
|
+
/** What a dashboard row reads beyond the build state. */
|
|
195
|
+
dashboard: DashboardFacts
|
|
196
|
+
deferral: DeferralLedger
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A build that still has tick duties: nonterminal, aborted (awaiting its
|
|
200
|
+
* cleanup's `build.completed`), a `done` build with an unreclaimed release
|
|
201
|
+
* asset, or one with an execution the provider has not been seen to end. A
|
|
202
|
+
* terminal, settled build costs no event reads. */
|
|
203
|
+
export function isWorkDigest(digest: Omit<BuildDigest, 'slug'>): boolean {
|
|
204
|
+
return (
|
|
205
|
+
digest.terminal === undefined ||
|
|
206
|
+
digest.terminal === 'aborted' ||
|
|
207
|
+
digest.reclaimPending === true ||
|
|
208
|
+
digest.executionOpen === true
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function digestRow(event: AbEvent): DigestEventRow {
|
|
213
|
+
return {
|
|
214
|
+
type: event.type,
|
|
215
|
+
seq: event.seq,
|
|
216
|
+
ts: event.ts,
|
|
217
|
+
...(event.type === 'pr-attachment.reclaimed' ? { hostedSeq: event.payload.hostedSeq } : {}),
|
|
218
|
+
...(event.type === 'execution.started' || event.type === 'execution.ended'
|
|
219
|
+
? { instance: event.payload.instance }
|
|
220
|
+
: {}),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One build log held in memory: events with `seq > from`, contiguous through
|
|
225
|
+
* `cursor`. Work logs are held from 0. A window created for a cursor-bearing
|
|
226
|
+
* reader may start higher; in a resident view it then also carries `acc`, the
|
|
227
|
+
* reduced state of the history it does not retain. */
|
|
228
|
+
interface Log {
|
|
229
|
+
from: number
|
|
230
|
+
events: AbEvent[]
|
|
231
|
+
cursor: number
|
|
232
|
+
dirty: boolean
|
|
233
|
+
/** The refresh epoch this log was last brought current in. */
|
|
234
|
+
epoch: number
|
|
235
|
+
acc?: BuildAcc
|
|
236
|
+
/** The other work-build accumulators ride beside `acc`, so the facts a stage
|
|
237
|
+
* derives never need the event array. */
|
|
238
|
+
extra?: ExtraAccs
|
|
239
|
+
/** The cursor last read from, or written to, the snapshot store. */
|
|
240
|
+
persisted: number
|
|
241
|
+
/** Created by a cursor-bearing request: survives refreshes while unneeded. */
|
|
242
|
+
pinned: boolean
|
|
243
|
+
digest?: { cursor: number; value: Omit<BuildDigest, 'slug'> }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface JournalWindow {
|
|
247
|
+
from: number
|
|
248
|
+
events: RepositoryEvent[]
|
|
249
|
+
/** Highest seq held (≥ from). */
|
|
250
|
+
last: number
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Every retained journal entry is an event envelope, in increasing seq order
|
|
254
|
+
* and no newer than the snapshot's cursor. */
|
|
255
|
+
function retainedShape(retained: unknown[], cursor: number): boolean {
|
|
256
|
+
let last = 0
|
|
257
|
+
for (const event of retained) {
|
|
258
|
+
if (!isObject(event)) return false
|
|
259
|
+
if (typeof event.type !== 'string' || typeof event.ts !== 'string') return false
|
|
260
|
+
if (typeof event.repo !== 'string' || !isObject(event.actor)) return false
|
|
261
|
+
if (!Number.isInteger(event.seq) || (event.seq as number) <= last) return false
|
|
262
|
+
last = event.seq as number
|
|
263
|
+
// The payload must satisfy the repository event contract for its type, so a
|
|
264
|
+
// reducer never meets an entry a full replay would not have produced.
|
|
265
|
+
try {
|
|
266
|
+
validateRepositoryEventWrite({
|
|
267
|
+
actor: event.actor,
|
|
268
|
+
type: event.type,
|
|
269
|
+
payload: event.payload,
|
|
270
|
+
} as never)
|
|
271
|
+
} catch {
|
|
272
|
+
return false
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return last <= cursor
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Structural checks on the small accumulators a later fold dereferences. */
|
|
279
|
+
function extraAccsShape(extra: ExtraAccs): boolean {
|
|
280
|
+
const field = (acc: unknown, key: string): unknown => (isObject(acc) ? acc[key] : undefined)
|
|
281
|
+
const open = field(extra.openExecution, 'open')
|
|
282
|
+
if (open !== null && !(isObject(open) && typeof open.instance === 'string')) return false
|
|
283
|
+
const workspace = field(extra.openBuildWorkspace, 'open')
|
|
284
|
+
if (workspace !== null && !(isObject(workspace) && typeof workspace.ref === 'string')) {
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
if (typeof field(extra.lastExecutionOutcome, 'state') !== 'string') return false
|
|
288
|
+
if (!Array.isArray(field(extra.publicationState, 'ledger'))) return false
|
|
289
|
+
const dashboard = extra.dashboardFacts
|
|
290
|
+
if (
|
|
291
|
+
!(
|
|
292
|
+
isObject(dashboard) &&
|
|
293
|
+
Array.isArray(dashboard.sessionEvents) &&
|
|
294
|
+
isObject(dashboard.intervals) &&
|
|
295
|
+
isObject(dashboard.produced)
|
|
296
|
+
)
|
|
297
|
+
) {
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
if (!Array.isArray(field(extra.currentDeferralObservation, 'entries'))) return false
|
|
301
|
+
const digest = extra.buildDigest
|
|
302
|
+
return (
|
|
303
|
+
isObject(digest) &&
|
|
304
|
+
Array.isArray(digest.observations) &&
|
|
305
|
+
Array.isArray(digest.hosted) &&
|
|
306
|
+
Array.isArray(digest.reclaimed) &&
|
|
307
|
+
(digest.openInstance === null || typeof digest.openInstance === 'string')
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hasAccs(log: Log): boolean {
|
|
312
|
+
return log.acc !== undefined && log.extra !== undefined
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
316
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface RepoViewOptions {
|
|
320
|
+
repo: string
|
|
321
|
+
/** A long-running dispatcher: windows are seeded from a full read so the
|
|
322
|
+
* reduced state of unretained history stays available (one pass over the log
|
|
323
|
+
* at process start). One-shot invocations leave this off and read each
|
|
324
|
+
* window from its cursor, exactly as the wake pass always did. */
|
|
325
|
+
resident?: boolean
|
|
326
|
+
/** Injectable millisecond clock for the persist throttle (tests). */
|
|
327
|
+
now?: () => number
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Runs tasks strictly one after another; `onIdle` fires when the queue drains. */
|
|
331
|
+
class Chain {
|
|
332
|
+
private tail: Promise<void> = Promise.resolve()
|
|
333
|
+
private pending = 0
|
|
334
|
+
|
|
335
|
+
constructor(private readonly onIdle?: () => void) {}
|
|
336
|
+
|
|
337
|
+
run<T>(task: () => Promise<T>): Promise<T> {
|
|
338
|
+
this.pending += 1
|
|
339
|
+
const result = this.tail.then(task)
|
|
340
|
+
this.tail = result
|
|
341
|
+
.then(
|
|
342
|
+
() => undefined,
|
|
343
|
+
() => undefined,
|
|
344
|
+
)
|
|
345
|
+
.then(() => {
|
|
346
|
+
this.pending -= 1
|
|
347
|
+
if (this.pending === 0) this.onIdle?.()
|
|
348
|
+
})
|
|
349
|
+
return result
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export type WindowSource = { build: string } | 'journal'
|
|
354
|
+
|
|
355
|
+
export class RepoViewStore implements BuildStore {
|
|
356
|
+
private readonly repo: string
|
|
357
|
+
private readonly resident: boolean
|
|
358
|
+
private epoch = 0
|
|
359
|
+
private initialized = false
|
|
360
|
+
/** The journal alone is loaded (`startJournal`): own journal appends fold. */
|
|
361
|
+
private journalReady = false
|
|
362
|
+
private initializing: Promise<void> | undefined
|
|
363
|
+
|
|
364
|
+
// Journal.
|
|
365
|
+
private recorded = false
|
|
366
|
+
private journal: RepositoryEvent[] = []
|
|
367
|
+
private anchor: number | undefined
|
|
368
|
+
private journalCursor = 0
|
|
369
|
+
private journalDirty = false
|
|
370
|
+
private journalEpoch = -1
|
|
371
|
+
private journalWindow: JournalWindow | undefined
|
|
372
|
+
private persistedJournal = 0
|
|
373
|
+
private lastPersistAt: number | undefined
|
|
374
|
+
|
|
375
|
+
// Builds.
|
|
376
|
+
private records: BuildRecord[] = []
|
|
377
|
+
private recordsDirty = false
|
|
378
|
+
private discovery = new Map<string, BuildDigest>()
|
|
379
|
+
private discoveryDirty = false
|
|
380
|
+
private readonly logs = new Map<string, Log>()
|
|
381
|
+
private readonly work = new Set<string>()
|
|
382
|
+
private readonly buildChains = new Map<string, Chain>()
|
|
383
|
+
private readonly journalChain = new Chain()
|
|
384
|
+
/** Serializes the build listing and the discovery digests. */
|
|
385
|
+
private readonly metaChain = new Chain()
|
|
386
|
+
|
|
387
|
+
private readonly now: () => number
|
|
388
|
+
|
|
389
|
+
constructor(
|
|
390
|
+
private readonly backing: BuildStore,
|
|
391
|
+
opts: RepoViewOptions,
|
|
392
|
+
) {
|
|
393
|
+
this.repo = opts.repo
|
|
394
|
+
this.resident = opts.resident === true
|
|
395
|
+
this.now = opts.now ?? Date.now
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Refresh ────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
/** One build's transitions, strictly in order. */
|
|
401
|
+
private build<T>(slug: string, task: () => Promise<T>): Promise<T> {
|
|
402
|
+
let chain = this.buildChains.get(slug)
|
|
403
|
+
if (chain === undefined) {
|
|
404
|
+
const created: Chain = new Chain(() => {
|
|
405
|
+
if (this.buildChains.get(slug) === created) this.buildChains.delete(slug)
|
|
406
|
+
})
|
|
407
|
+
chain = created
|
|
408
|
+
this.buildChains.set(slug, created)
|
|
409
|
+
}
|
|
410
|
+
return chain.run(task)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Cold start once, shared by every reader that arrives before it finishes. */
|
|
414
|
+
private ensureInit(): Promise<void> {
|
|
415
|
+
if (this.initialized) return Promise.resolve()
|
|
416
|
+
this.initializing ??= this.refresh().finally(() => {
|
|
417
|
+
this.initializing = undefined
|
|
418
|
+
})
|
|
419
|
+
return this.initializing
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The once-per-tick entry and lazy initializer. Cold (first call): the
|
|
423
|
+
* journal's high-water mark, the bounded journal read, the build listing and
|
|
424
|
+
* digests, and a full read of every work build. Warm: only what is newer. */
|
|
425
|
+
async refresh(): Promise<void> {
|
|
426
|
+
this.epoch += 1
|
|
427
|
+
await this.journalChain.run(() => this.syncOrLoadJournal())
|
|
428
|
+
await this.refreshRecords()
|
|
429
|
+
await this.refreshDiscovery()
|
|
430
|
+
|
|
431
|
+
const own = this.records.filter((record) => record.repo === this.repo)
|
|
432
|
+
const known = new Set(own.map((record) => record.slug))
|
|
433
|
+
// Every build held at refresh start reads its tail first, regardless of
|
|
434
|
+
// what discovery says; only then may a now-settled build be evicted.
|
|
435
|
+
for (const slug of [...this.work]) {
|
|
436
|
+
await this.build(slug, async () => {
|
|
437
|
+
if (!this.work.has(slug)) return
|
|
438
|
+
if (!known.has(slug)) {
|
|
439
|
+
this.work.delete(slug)
|
|
440
|
+
this.logs.delete(slug)
|
|
441
|
+
return
|
|
442
|
+
}
|
|
443
|
+
const log = this.logs.get(slug)
|
|
444
|
+
if (log === undefined) {
|
|
445
|
+
await this.loadWork(slug)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
await this.ensureCurrent(slug, log)
|
|
449
|
+
const current = this.logs.get(slug) ?? log
|
|
450
|
+
if (!isWorkDigest(this.digestOf(slug, current))) {
|
|
451
|
+
// Its last events are in hand; leave the final state behind.
|
|
452
|
+
await this.persistLog(slug, current)
|
|
453
|
+
this.work.delete(slug)
|
|
454
|
+
if (!current.pinned) this.logs.delete(slug)
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
for (const record of own) {
|
|
459
|
+
await this.build(record.slug, async () => {
|
|
460
|
+
if (this.work.has(record.slug)) return
|
|
461
|
+
const digest = this.discovery.get(record.slug)
|
|
462
|
+
if (digest !== undefined && !isWorkDigest(digest)) return
|
|
463
|
+
await this.loadWork(record.slug)
|
|
464
|
+
this.work.add(record.slug)
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
// Unpinned logs read for one request do not outlive the epoch.
|
|
468
|
+
for (const slug of [...this.logs.keys()]) {
|
|
469
|
+
await this.build(slug, async () => {
|
|
470
|
+
const log = this.logs.get(slug)
|
|
471
|
+
if (log !== undefined && !this.work.has(slug) && !log.pinned) this.logs.delete(slug)
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
// The cold start's replay is the expensive part to lose: leave it behind
|
|
475
|
+
// now rather than only at the tick's end. Later epochs persist at the end.
|
|
476
|
+
if (this.epoch === 1) await this.persistSnapshots({ cold: true })
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private refreshRecords(): Promise<void> {
|
|
480
|
+
return this.metaChain.run(async () => {
|
|
481
|
+
// Cleared before the read: an own write that lands during it re-marks.
|
|
482
|
+
this.recordsDirty = false
|
|
483
|
+
try {
|
|
484
|
+
this.records = await this.backing.listBuilds()
|
|
485
|
+
} catch (error) {
|
|
486
|
+
this.recordsDirty = true
|
|
487
|
+
throw error
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private refreshDiscovery(): Promise<void> {
|
|
493
|
+
return this.metaChain.run(async () => {
|
|
494
|
+
this.discoveryDirty = false
|
|
495
|
+
try {
|
|
496
|
+
this.discovery = await this.backing.getRepoBuildDigests(this.repo)
|
|
497
|
+
} catch (error) {
|
|
498
|
+
this.discoveryDirty = true
|
|
499
|
+
throw error
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** The journal-only delta, for a stage that needs foreign journal appends
|
|
505
|
+
* mid-tick (the ready scan) without paying for discovery. */
|
|
506
|
+
async refreshJournal(): Promise<void> {
|
|
507
|
+
if (!this.initialized) return this.ensureInit()
|
|
508
|
+
// The build epoch is untouched: build logs stay current for this tick.
|
|
509
|
+
await this.journalChain.run(() => this.syncJournal(true))
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Journal ────────────────────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
private resetJournalState(): void {
|
|
515
|
+
this.journal = []
|
|
516
|
+
this.anchor = undefined
|
|
517
|
+
this.journalCursor = 0
|
|
518
|
+
this.journalWindow = undefined
|
|
519
|
+
this.journalDirty = false
|
|
520
|
+
this.persistedJournal = 0
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Cold journal load. The true high-water mark is read before the bounded
|
|
524
|
+
* subset: an event the subset excludes is never needed (a later anchor has a
|
|
525
|
+
* higher seq and cannot pull earlier events into the tail), and everything
|
|
526
|
+
* committed between the two reads is either returned or irrelevant. */
|
|
527
|
+
private async loadJournal(opts: { snapshot?: boolean } = {}): Promise<void> {
|
|
528
|
+
this.resetJournalState()
|
|
529
|
+
if ((await this.backing.getRepo(this.repo)) === null) {
|
|
530
|
+
this.recorded = false
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
this.recorded = true
|
|
534
|
+
if (opts.snapshot === true && (await this.restoreJournal())) return
|
|
535
|
+
const high = await this.backing.getRepoHighWater(this.repo)
|
|
536
|
+
const subset = await this.backing.getRepoStateEvents(this.repo)
|
|
537
|
+
this.foldJournal(subset)
|
|
538
|
+
this.journalCursor = Math.max(high, this.journalCursor)
|
|
539
|
+
this.journalEpoch = this.epoch
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Install the stored journal view and read only the events newer than its
|
|
543
|
+
* cursor. Any shortfall — no snapshot, a malformed one, a failed or
|
|
544
|
+
* non-contiguous delta — leaves the state reset and answers `false`, and the
|
|
545
|
+
* caller replays in full. */
|
|
546
|
+
private async restoreJournal(): Promise<boolean> {
|
|
547
|
+
const scope: SnapshotScope = { kind: 'repo', repo: this.repo }
|
|
548
|
+
const snapshot = await loadReducerSnapshot(
|
|
549
|
+
this.backing,
|
|
550
|
+
scope,
|
|
551
|
+
SNAPSHOT_JOURNAL,
|
|
552
|
+
journalViewReducer,
|
|
553
|
+
)
|
|
554
|
+
if (snapshot === null) return false
|
|
555
|
+
const state = snapshot.state
|
|
556
|
+
if (
|
|
557
|
+
!isObject(state) ||
|
|
558
|
+
!Array.isArray(state.retained) ||
|
|
559
|
+
(state.anchor !== undefined && typeof state.anchor !== 'number') ||
|
|
560
|
+
!retainedShape(state.retained, snapshot.cursor)
|
|
561
|
+
) {
|
|
562
|
+
return false
|
|
563
|
+
}
|
|
564
|
+
try {
|
|
565
|
+
const delta = (await this.backing.getRepoEvents(this.repo, snapshot.cursor)).filter(
|
|
566
|
+
(event) => event.seq > snapshot.cursor,
|
|
567
|
+
)
|
|
568
|
+
if (!contiguousFrom(delta, snapshot.cursor)) return false
|
|
569
|
+
this.journal = [...(state.retained as RepositoryEvent[])]
|
|
570
|
+
this.anchor = state.anchor as number | undefined
|
|
571
|
+
this.journalCursor = snapshot.cursor
|
|
572
|
+
this.persistedJournal = snapshot.cursor
|
|
573
|
+
this.foldJournal(delta)
|
|
574
|
+
this.journalEpoch = this.epoch
|
|
575
|
+
return true
|
|
576
|
+
} catch {
|
|
577
|
+
this.resetJournalState()
|
|
578
|
+
this.recorded = true
|
|
579
|
+
return false
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Restore the journal alone, ahead of the first `refresh()`, so the
|
|
584
|
+
* lifecycle appends a process makes before its first tick fold into the view
|
|
585
|
+
* and are not read back. Costs no listing, digest, or build read. */
|
|
586
|
+
startJournal(): Promise<void> {
|
|
587
|
+
return this.journalChain.run(async () => {
|
|
588
|
+
if (this.journalReady || this.initialized) return
|
|
589
|
+
await this.loadJournal({ snapshot: true })
|
|
590
|
+
this.journalReady = true
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private async syncOrLoadJournal(): Promise<void> {
|
|
595
|
+
if (!this.initialized) {
|
|
596
|
+
if (this.journalReady) await this.syncJournal(true)
|
|
597
|
+
else await this.loadJournal({ snapshot: true })
|
|
598
|
+
this.initialized = true
|
|
599
|
+
} else {
|
|
600
|
+
await this.syncJournal(true)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Bring the journal current: one delta read from the cursor (a repository
|
|
605
|
+
* no tick has recorded yet is re-probed instead). Runs on the journal chain. */
|
|
606
|
+
private async syncJournal(force = false): Promise<void> {
|
|
607
|
+
if (!this.recorded) {
|
|
608
|
+
if ((await this.backing.getRepo(this.repo)) === null) return
|
|
609
|
+
await this.loadJournal({ snapshot: true })
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
if (!force && !this.journalDirty && this.journalEpoch === this.epoch) return
|
|
613
|
+
const read = await this.backing.getRepoEvents(this.repo, this.journalCursor)
|
|
614
|
+
this.journalDirty = false
|
|
615
|
+
this.journalEpoch = this.epoch
|
|
616
|
+
const delta = read.filter((event) => event.seq > this.journalCursor)
|
|
617
|
+
if (delta.length === 0) return
|
|
618
|
+
if (!contiguousFrom(delta, this.journalCursor)) {
|
|
619
|
+
// A hole: discard and re-read from scratch rather than skip an event.
|
|
620
|
+
await this.loadJournal()
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
this.foldJournal(delta)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Fold events newer than the cursor into the bounded subset and the window. */
|
|
627
|
+
private foldJournal(events: readonly RepositoryEvent[]): void {
|
|
628
|
+
for (const event of events) {
|
|
629
|
+
if (event.seq <= this.journalCursor) continue
|
|
630
|
+
this.journalCursor = event.seq
|
|
631
|
+
// The same prune-on-arrival fold the registered `journalView` reducer
|
|
632
|
+
// runs, so a snapshot of this state resumes identically.
|
|
633
|
+
const acc: JournalViewAcc = { retained: this.journal }
|
|
634
|
+
if (this.anchor !== undefined) acc.anchor = this.anchor
|
|
635
|
+
foldJournalView(acc, [event])
|
|
636
|
+
this.journal = acc.retained
|
|
637
|
+
this.anchor = acc.anchor
|
|
638
|
+
const window = this.journalWindow
|
|
639
|
+
if (window !== undefined && event.seq > window.last) {
|
|
640
|
+
window.events.push(event)
|
|
641
|
+
window.last = event.seq
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The bounded journal subset, `[]` for a repository no one has recorded. */
|
|
647
|
+
recordedJournal(): RepositoryEvent[] {
|
|
648
|
+
return [...this.journal]
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Whether the repository's journal record exists. */
|
|
652
|
+
journalRecorded(): boolean {
|
|
653
|
+
return this.recorded
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── Builds ─────────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
private digestOf(slug: string, log: Log): Omit<BuildDigest, 'slug'> {
|
|
659
|
+
if (log.digest === undefined || log.digest.cursor !== log.cursor) {
|
|
660
|
+
if (log.extra !== undefined) {
|
|
661
|
+
const value = buildDigestReducer.finish(log.extra.buildDigest as never)
|
|
662
|
+
log.digest = { cursor: log.cursor, value }
|
|
663
|
+
return value
|
|
664
|
+
}
|
|
665
|
+
if (log.from > 0) {
|
|
666
|
+
// A trimmed window cannot derive a digest; discovery owns that slug.
|
|
667
|
+
const found = this.discovery.get(slug)
|
|
668
|
+
if (found !== undefined) return found
|
|
669
|
+
}
|
|
670
|
+
log.digest = { cursor: log.cursor, value: reduceBuildDigest(log.events.map(digestRow)) }
|
|
671
|
+
}
|
|
672
|
+
return log.digest.value
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Full read, installed as the slug's log. Runs on the build's chain. */
|
|
676
|
+
private async loadFull(slug: string): Promise<Log> {
|
|
677
|
+
const events = await this.backing.getEvents(slug)
|
|
678
|
+
const previous = this.logs.get(slug)
|
|
679
|
+
const log: Log = {
|
|
680
|
+
from: 0,
|
|
681
|
+
events,
|
|
682
|
+
cursor: events.at(-1)?.seq ?? 0,
|
|
683
|
+
dirty: false,
|
|
684
|
+
epoch: this.epoch,
|
|
685
|
+
persisted: previous?.persisted ?? 0,
|
|
686
|
+
pinned: previous?.pinned ?? false,
|
|
687
|
+
}
|
|
688
|
+
this.logs.set(slug, log)
|
|
689
|
+
return log
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Install a work build's log, from its stored reducer state when there is
|
|
693
|
+
* one. Runs on the build's chain. The snapshot-backed log holds only the
|
|
694
|
+
* events newer than the snapshot, plus the accumulators for everything
|
|
695
|
+
* before; any shortfall is a full read. */
|
|
696
|
+
private async loadWork(slug: string): Promise<Log> {
|
|
697
|
+
const restored = await this.restoreLog(slug)
|
|
698
|
+
return restored ?? (await this.loadFull(slug))
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private async restoreLog(slug: string): Promise<Log | null> {
|
|
702
|
+
const scope: SnapshotScope = { kind: 'build', slug }
|
|
703
|
+
const bundle = await loadReducerSnapshot(
|
|
704
|
+
this.backing,
|
|
705
|
+
scope,
|
|
706
|
+
SNAPSHOT_WORK,
|
|
707
|
+
WORK_BUNDLE_REDUCER,
|
|
708
|
+
)
|
|
709
|
+
if (bundle === null || !isObject(bundle.state)) return null
|
|
710
|
+
const buildState = bundle.state.build
|
|
711
|
+
const extra = bundle.state.extra
|
|
712
|
+
if (!isObject(extra)) return null
|
|
713
|
+
if (!buildAccShape({ ...bundle, state: buildState })) return null
|
|
714
|
+
for (const name of EXTRA_NAMES) if (!isObject(extra[name])) return null
|
|
715
|
+
if (!extraAccsShape(extra as ExtraAccs)) return null
|
|
716
|
+
if (!indexAccShape(extra.logIndex)) return null
|
|
717
|
+
const build = { cursor: bundle.cursor, state: buildState }
|
|
718
|
+
const cursor = build.cursor
|
|
719
|
+
try {
|
|
720
|
+
const delta = (await this.backing.getEvents(slug, cursor)).filter(
|
|
721
|
+
(event) => event.seq > cursor,
|
|
722
|
+
)
|
|
723
|
+
if (!contiguousFrom(delta, cursor)) return null
|
|
724
|
+
const log: Log = {
|
|
725
|
+
from: cursor,
|
|
726
|
+
events: [],
|
|
727
|
+
cursor,
|
|
728
|
+
dirty: false,
|
|
729
|
+
epoch: this.epoch,
|
|
730
|
+
acc: build.state as BuildAcc,
|
|
731
|
+
extra: extra as ExtraAccs,
|
|
732
|
+
persisted: cursor,
|
|
733
|
+
pinned: this.logs.get(slug)?.pinned ?? false,
|
|
734
|
+
}
|
|
735
|
+
// Exercise every projection a stage will derive, before the state is
|
|
736
|
+
// installed: a structurally incomplete accumulator that passed the cheap
|
|
737
|
+
// shape checks throws here and the build is replayed instead.
|
|
738
|
+
this.probe(log)
|
|
739
|
+
if (delta.length > 0) this.fold(log, delta)
|
|
740
|
+
this.probe(log)
|
|
741
|
+
this.logs.set(slug, log)
|
|
742
|
+
return log
|
|
743
|
+
} catch {
|
|
744
|
+
return null
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Bring one log current: a single delta read from its cursor, skipped when
|
|
749
|
+
* it was already brought current in this epoch and nothing marked it dirty.
|
|
750
|
+
* Runs on the build's chain, so nothing marks the log while the read is in
|
|
751
|
+
* flight. */
|
|
752
|
+
private async ensureCurrent(slug: string, log: Log): Promise<void> {
|
|
753
|
+
if (!log.dirty && log.epoch === this.epoch) return
|
|
754
|
+
const read = await this.backing.getEvents(slug, log.cursor)
|
|
755
|
+
log.dirty = false
|
|
756
|
+
log.epoch = this.epoch
|
|
757
|
+
const delta = read.filter((event) => event.seq > log.cursor)
|
|
758
|
+
if (delta.length === 0) return
|
|
759
|
+
if (!contiguousFrom(delta, log.cursor)) {
|
|
760
|
+
await this.loadFull(slug)
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
this.fold(log, delta)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Derive every projection of a log's accumulators (throws on malformed state). */
|
|
767
|
+
private probe(log: Log): void {
|
|
768
|
+
const extra = log.extra!
|
|
769
|
+
buildReducer.finish(log.acc!)
|
|
770
|
+
logIndexReducer.finish(extra.logIndex as never)
|
|
771
|
+
openExecutionReducer.finish(extra.openExecution as never)
|
|
772
|
+
openBuildWorkspaceReducer.finish(extra.openBuildWorkspace as never)
|
|
773
|
+
const publication = publicationStateReducer.finish(extra.publicationState as never)
|
|
774
|
+
publication.pending()
|
|
775
|
+
publication.abandonedPending()
|
|
776
|
+
publication.latestUncompletedRequest()
|
|
777
|
+
lastExecutionOutcomeReducer.finish(extra.lastExecutionOutcome as never)
|
|
778
|
+
buildDigestReducer.finish(extra.buildDigest as never)
|
|
779
|
+
dashboardFactsReducer.finish(extra.dashboardFacts as never)
|
|
780
|
+
currentDeferralObservationReducer.finish(extra.currentDeferralObservation as never)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private fold(log: Log, events: AbEvent[]): void {
|
|
784
|
+
log.events.push(...events)
|
|
785
|
+
log.cursor = events[events.length - 1]!.seq
|
|
786
|
+
if (log.acc !== undefined) log.acc = buildReducer.advance(log.acc, events)
|
|
787
|
+
if (log.extra !== undefined) {
|
|
788
|
+
for (const name of EXTRA_NAMES) {
|
|
789
|
+
// biome-ignore lint/suspicious/noExplicitAny: the table is heterogeneous
|
|
790
|
+
const reducer = EXTRA_REDUCERS[name] as any
|
|
791
|
+
log.extra[name] = reducer.advance(log.extra[name], events)
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Make the three accumulators exist. Needs the full history unless they are
|
|
797
|
+
* already held; the caller guarantees `from === 0` or all three present. */
|
|
798
|
+
private ensureAccs(log: Log): void {
|
|
799
|
+
log.acc ??= buildReducer.advance(buildReducer.initial(), log.events)
|
|
800
|
+
if (log.extra === undefined) {
|
|
801
|
+
const extra = {} as ExtraAccs
|
|
802
|
+
for (const name of EXTRA_NAMES) {
|
|
803
|
+
// biome-ignore lint/suspicious/noExplicitAny: the table is heterogeneous
|
|
804
|
+
const reducer = EXTRA_REDUCERS[name] as any
|
|
805
|
+
extra[name] = reducer.advance(reducer.initial(), log.events)
|
|
806
|
+
}
|
|
807
|
+
log.extra = extra
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private async openWindow(slug: string, since: number): Promise<Log> {
|
|
812
|
+
if (this.work.has(slug)) return this.loadFull(slug)
|
|
813
|
+
let log: Log
|
|
814
|
+
if (this.resident) {
|
|
815
|
+
// One pass over the log: reduce all of it, retain only what the reader
|
|
816
|
+
// has not consumed.
|
|
817
|
+
const all = await this.backing.getEvents(slug)
|
|
818
|
+
log = {
|
|
819
|
+
from: since,
|
|
820
|
+
events: all.filter((event) => event.seq > since),
|
|
821
|
+
cursor: all.at(-1)?.seq ?? 0,
|
|
822
|
+
dirty: false,
|
|
823
|
+
epoch: this.epoch,
|
|
824
|
+
acc: buildReducer.advance(buildReducer.initial(), all),
|
|
825
|
+
persisted: 0,
|
|
826
|
+
pinned: true,
|
|
827
|
+
}
|
|
828
|
+
} else {
|
|
829
|
+
const events = await this.backing.getEvents(slug, since)
|
|
830
|
+
log = {
|
|
831
|
+
from: since,
|
|
832
|
+
events,
|
|
833
|
+
cursor: Math.max(since, events.at(-1)?.seq ?? 0),
|
|
834
|
+
dirty: false,
|
|
835
|
+
epoch: this.epoch,
|
|
836
|
+
persisted: 0,
|
|
837
|
+
pinned: true,
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
this.logs.set(slug, log)
|
|
841
|
+
return log
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async getEvents(
|
|
845
|
+
slug: string,
|
|
846
|
+
cursor?: number,
|
|
847
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
848
|
+
): Promise<AbEvent[]> {
|
|
849
|
+
const sinceSeq = cursor ?? 0
|
|
850
|
+
if (opts?.waitSeconds !== undefined || opts?.signal !== undefined) {
|
|
851
|
+
return this.backing.getEvents(slug, sinceSeq, opts)
|
|
852
|
+
}
|
|
853
|
+
return this.build(slug, async () => {
|
|
854
|
+
let log = this.logs.get(slug)
|
|
855
|
+
if (log === undefined || sinceSeq < log.from) {
|
|
856
|
+
log = await this.openWindow(slug, sinceSeq)
|
|
857
|
+
} else if (cursor !== undefined) {
|
|
858
|
+
// An explicit cursor, zero included, marks a cursor-bearing consumer
|
|
859
|
+
// (the wake pass); an omitted one is a plain read.
|
|
860
|
+
log.pinned = true
|
|
861
|
+
}
|
|
862
|
+
await this.ensureCurrent(slug, log)
|
|
863
|
+
// `ensureCurrent` may have replaced the log after a gap.
|
|
864
|
+
log = this.logs.get(slug) ?? log
|
|
865
|
+
return log.events.filter((event) => event.seq > sinceSeq)
|
|
866
|
+
})
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Take one build's delta now, even if it was already read this epoch — for
|
|
870
|
+
* the decisions that deliberately re-read at the last possible moment so a
|
|
871
|
+
* concurrent writer's cancellation or request is honored. */
|
|
872
|
+
async refreshBuild(slug: string): Promise<void> {
|
|
873
|
+
await this.build(slug, async () => {
|
|
874
|
+
const log = this.logs.get(slug)
|
|
875
|
+
if (log === undefined) return
|
|
876
|
+
log.dirty = true
|
|
877
|
+
await this.ensureCurrent(slug, log)
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** The reduced state of one build without replaying history the view already
|
|
882
|
+
* folded: a held log is reduced in place; a resident window brings itself
|
|
883
|
+
* current and finishes its accumulator; anything else is one full read,
|
|
884
|
+
* then held. */
|
|
885
|
+
buildState(slug: string): Promise<BuildState> {
|
|
886
|
+
return this.build(slug, async () => {
|
|
887
|
+
let log = this.logs.get(slug)
|
|
888
|
+
if (log === undefined || (log.from > 0 && log.acc === undefined)) {
|
|
889
|
+
log = await this.loadFull(slug)
|
|
890
|
+
}
|
|
891
|
+
await this.ensureCurrent(slug, log)
|
|
892
|
+
log = this.logs.get(slug) ?? log
|
|
893
|
+
return log.acc !== undefined ? buildReducer.finish(log.acc) : reduceBuild(log.events)
|
|
894
|
+
})
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** The reduced facts of one build — state, log index, open execution —
|
|
898
|
+
* brought current with one delta read, without its event array: a work
|
|
899
|
+
* build restored from snapshots answers from the accumulators. A log that is
|
|
900
|
+
* not snapshot-backed is reduced from its events (a full read the first
|
|
901
|
+
* time), and keeps the accumulators from then on. */
|
|
902
|
+
buildFacts(slug: string): Promise<BuildFacts> {
|
|
903
|
+
return this.build(slug, async () => {
|
|
904
|
+
let log = this.logs.get(slug)
|
|
905
|
+
if (log === undefined) {
|
|
906
|
+
log = await this.loadWork(slug)
|
|
907
|
+
} else if (log.from > 0 && !hasAccs(log)) {
|
|
908
|
+
log = await this.loadFull(slug)
|
|
909
|
+
}
|
|
910
|
+
await this.ensureCurrent(slug, log)
|
|
911
|
+
log = this.logs.get(slug) ?? log
|
|
912
|
+
this.ensureAccs(log)
|
|
913
|
+
const extra = log.extra!
|
|
914
|
+
return {
|
|
915
|
+
state: buildReducer.finish(log.acc!),
|
|
916
|
+
log: logIndexReducer.finish(extra.logIndex as never),
|
|
917
|
+
open: openExecutionReducer.finish(extra.openExecution as never),
|
|
918
|
+
workspace: openBuildWorkspaceReducer.finish(extra.openBuildWorkspace as never),
|
|
919
|
+
publication: publicationStateReducer.finish(extra.publicationState as never),
|
|
920
|
+
lastExecution: lastExecutionOutcomeReducer.finish(extra.lastExecutionOutcome as never),
|
|
921
|
+
dashboard: dashboardFactsReducer.finish(extra.dashboardFacts as never),
|
|
922
|
+
deferral: currentDeferralObservationReducer.finish(
|
|
923
|
+
extra.currentDeferralObservation as never,
|
|
924
|
+
),
|
|
925
|
+
}
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ── Snapshots ──────────────────────────────────────────────────────────────
|
|
930
|
+
|
|
931
|
+
/** Write one work log's reducer state back to the snapshot store, if it has
|
|
932
|
+
* advanced past what was last read or written. Runs on the build's chain.
|
|
933
|
+
* Best effort: a failure leaves the cursor so the next call retries. A log
|
|
934
|
+
* that is dirty (a foreign append not yet read) or trimmed without its
|
|
935
|
+
* accumulators is skipped — only a cursor read from the log is persisted. */
|
|
936
|
+
private async persistLog(slug: string, log: Log): Promise<void> {
|
|
937
|
+
if (log.dirty || log.cursor <= log.persisted) return
|
|
938
|
+
if (log.from > 0 && !hasAccs(log)) return
|
|
939
|
+
this.ensureAccs(log)
|
|
940
|
+
const scope: SnapshotScope = { kind: 'build', slug }
|
|
941
|
+
const cursor = log.cursor
|
|
942
|
+
await persistReducerSnapshot(this.backing, scope, SNAPSHOT_WORK, {
|
|
943
|
+
version: WORK_BUNDLE_VERSION,
|
|
944
|
+
cursor,
|
|
945
|
+
state: { build: log.acc, extra: log.extra },
|
|
946
|
+
})
|
|
947
|
+
log.persisted = Math.max(log.persisted, cursor)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Write the journal view back when it has advanced. Runs on the journal chain. */
|
|
951
|
+
private async persistJournal(): Promise<void> {
|
|
952
|
+
if (!this.recorded || this.journalDirty || this.journalCursor <= this.persistedJournal) return
|
|
953
|
+
const cursor = this.journalCursor
|
|
954
|
+
const state: JournalViewAcc = {
|
|
955
|
+
...(this.anchor !== undefined ? { anchor: this.anchor } : {}),
|
|
956
|
+
retained: [...this.journal],
|
|
957
|
+
}
|
|
958
|
+
await persistReducerSnapshot(
|
|
959
|
+
this.backing,
|
|
960
|
+
{ kind: 'repo', repo: this.repo },
|
|
961
|
+
SNAPSHOT_JOURNAL,
|
|
962
|
+
{ version: JOURNAL_VIEW_REDUCER_VERSION, cursor, state },
|
|
963
|
+
)
|
|
964
|
+
this.persistedJournal = Math.max(this.persistedJournal, cursor)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Persist every source that advanced since it was read or last written: the
|
|
968
|
+
* journal view and each work build. A resident view throttles repeated
|
|
969
|
+
* persists; `force` and a process's first persist always run. The snapshots
|
|
970
|
+
* are a cache — nothing here can fail the caller. */
|
|
971
|
+
async persistSnapshots(
|
|
972
|
+
opts: { force?: boolean; cold?: boolean; held?: boolean } = {},
|
|
973
|
+
): Promise<void> {
|
|
974
|
+
if (!this.initialized && !this.journalReady) return
|
|
975
|
+
const now = this.now()
|
|
976
|
+
if (opts.cold !== true) {
|
|
977
|
+
if (
|
|
978
|
+
opts.force !== true &&
|
|
979
|
+
this.lastPersistAt !== undefined &&
|
|
980
|
+
now - this.lastPersistAt < PERSIST_MIN_INTERVAL_MS
|
|
981
|
+
) {
|
|
982
|
+
return
|
|
983
|
+
}
|
|
984
|
+
this.lastPersistAt = now
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
await this.journalChain.run(() => this.persistJournal())
|
|
988
|
+
} catch {
|
|
989
|
+
// A cache: the next call retries.
|
|
990
|
+
}
|
|
991
|
+
// `held` also writes a log read for one request (a settled build the view
|
|
992
|
+
// never tracks as work), so its cold full read is paid once.
|
|
993
|
+
const candidates = opts.held === true ? [...this.logs.keys()] : [...this.work]
|
|
994
|
+
const slugs = candidates.filter((slug) => {
|
|
995
|
+
const log = this.logs.get(slug)
|
|
996
|
+
return log !== undefined && log.cursor > log.persisted
|
|
997
|
+
})
|
|
998
|
+
let next = 0
|
|
999
|
+
const worker = async (): Promise<void> => {
|
|
1000
|
+
while (next < slugs.length) {
|
|
1001
|
+
const slug = slugs[next++]!
|
|
1002
|
+
try {
|
|
1003
|
+
await this.build(slug, async () => {
|
|
1004
|
+
const log = this.logs.get(slug)
|
|
1005
|
+
if (log !== undefined) await this.persistLog(slug, log)
|
|
1006
|
+
})
|
|
1007
|
+
} catch {
|
|
1008
|
+
// A cache: the next call retries.
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
await Promise.all(Array.from({ length: Math.min(PERSIST_CONCURRENCY, slugs.length) }, worker))
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** Drop retained events at or below `minCursor` for a windowed source — the
|
|
1016
|
+
* wake pass calls this once its sessions have consumed them. Only sources
|
|
1017
|
+
* that carry reduced state for what they drop can be trimmed. */
|
|
1018
|
+
releaseWindowsBelow(source: WindowSource, minCursor: number): void {
|
|
1019
|
+
if (source === 'journal') {
|
|
1020
|
+
const window = this.journalWindow
|
|
1021
|
+
if (window === undefined || minCursor <= window.from) return
|
|
1022
|
+
window.events = window.events.filter((event) => event.seq > minCursor)
|
|
1023
|
+
window.from = minCursor
|
|
1024
|
+
window.last = Math.max(window.last, minCursor)
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
const log = this.logs.get(source.build)
|
|
1028
|
+
if (log === undefined || log.acc === undefined || this.work.has(source.build)) return
|
|
1029
|
+
if (minCursor <= log.from) return
|
|
1030
|
+
log.events = log.events.filter((event) => event.seq > minCursor)
|
|
1031
|
+
log.from = minCursor
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async listBuilds(): Promise<BuildRecord[]> {
|
|
1035
|
+
await this.ensureInit()
|
|
1036
|
+
if (this.recordsDirty) await this.refreshRecords()
|
|
1037
|
+
return this.records.map((record) => structuredClone(record))
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
1041
|
+
if (repo !== this.repo) return this.backing.getRepoBuildDigests(repo)
|
|
1042
|
+
const records = (await this.listBuilds()).filter((record) => record.repo === repo)
|
|
1043
|
+
const missing = (): boolean =>
|
|
1044
|
+
records.some((record) => !this.work.has(record.slug) && !this.discovery.has(record.slug))
|
|
1045
|
+
if (this.discoveryDirty || missing()) await this.refreshDiscovery()
|
|
1046
|
+
const digests = new Map<string, BuildDigest>()
|
|
1047
|
+
for (const record of records) {
|
|
1048
|
+
const slug = record.slug
|
|
1049
|
+
const digest = await this.build(slug, async () => {
|
|
1050
|
+
// A log invalidated by an own write resolves before it feeds a digest.
|
|
1051
|
+
const held = this.logs.get(slug)
|
|
1052
|
+
if (held?.dirty === true) await this.ensureCurrent(slug, held)
|
|
1053
|
+
const log = this.logs.get(slug)
|
|
1054
|
+
if (
|
|
1055
|
+
this.work.has(slug) &&
|
|
1056
|
+
log !== undefined &&
|
|
1057
|
+
(log.from === 0 || log.extra !== undefined)
|
|
1058
|
+
) {
|
|
1059
|
+
return { slug, ...this.digestOf(slug, log) }
|
|
1060
|
+
}
|
|
1061
|
+
return this.discovery.get(slug)
|
|
1062
|
+
})
|
|
1063
|
+
if (digest !== undefined) digests.set(slug, digest)
|
|
1064
|
+
}
|
|
1065
|
+
return digests
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// ── Repository journal reads ───────────────────────────────────────────────
|
|
1069
|
+
|
|
1070
|
+
async getRepoEvents(
|
|
1071
|
+
repo: string,
|
|
1072
|
+
sinceSeq = 0,
|
|
1073
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
1074
|
+
): Promise<RepositoryEvent[]> {
|
|
1075
|
+
if (repo !== this.repo || opts?.waitSeconds !== undefined || opts?.signal !== undefined) {
|
|
1076
|
+
return this.backing.getRepoEvents(repo, sinceSeq, opts)
|
|
1077
|
+
}
|
|
1078
|
+
await this.ensureInit()
|
|
1079
|
+
return this.journalChain.run(async () => {
|
|
1080
|
+
await this.syncJournal()
|
|
1081
|
+
if (!this.recorded) return this.backing.getRepoEvents(repo, sinceSeq)
|
|
1082
|
+
let window = this.journalWindow
|
|
1083
|
+
if (window === undefined || sinceSeq < window.from) {
|
|
1084
|
+
const events = await this.backing.getRepoEvents(repo, sinceSeq)
|
|
1085
|
+
window = {
|
|
1086
|
+
from: sinceSeq,
|
|
1087
|
+
events,
|
|
1088
|
+
last: Math.max(sinceSeq, events.at(-1)?.seq ?? 0),
|
|
1089
|
+
}
|
|
1090
|
+
this.journalWindow = window
|
|
1091
|
+
// Anything newer than the cursor feeds the bounded subset too.
|
|
1092
|
+
this.foldJournal(events)
|
|
1093
|
+
}
|
|
1094
|
+
return window.events.filter((event) => event.seq > sinceSeq)
|
|
1095
|
+
})
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
1099
|
+
if (repo !== this.repo) return this.backing.getRepoStateEvents(repo)
|
|
1100
|
+
await this.ensureInit()
|
|
1101
|
+
return this.journalChain.run(async () => {
|
|
1102
|
+
await this.syncJournal()
|
|
1103
|
+
if (!this.recorded) return this.backing.getRepoStateEvents(repo)
|
|
1104
|
+
return [...this.journal]
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
getReducerSnapshot(
|
|
1109
|
+
scope: SnapshotScope,
|
|
1110
|
+
reducer: string,
|
|
1111
|
+
version: number,
|
|
1112
|
+
): Promise<ReducerSnapshot | null> {
|
|
1113
|
+
return this.backing.getReducerSnapshot(scope, reducer, version)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
putReducerSnapshot(
|
|
1117
|
+
scope: SnapshotScope,
|
|
1118
|
+
reducer: string,
|
|
1119
|
+
snapshot: ReducerSnapshot,
|
|
1120
|
+
): Promise<boolean> {
|
|
1121
|
+
return this.backing.putReducerSnapshot(scope, reducer, snapshot)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async getRepoHighWater(repo: string): Promise<number> {
|
|
1125
|
+
return this.backing.getRepoHighWater(repo)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// ── Writes: delegate, then fold ────────────────────────────────────────────
|
|
1129
|
+
|
|
1130
|
+
/** Fold an own build append on the build's chain, after any read in flight:
|
|
1131
|
+
* contiguous folds in place, a gap (a foreign append interleaved) marks the
|
|
1132
|
+
* log dirty so the next read deltas. */
|
|
1133
|
+
private foldBuildAppend(slug: string, envelope: AbEvent): Promise<void> {
|
|
1134
|
+
return this.build(slug, async () => {
|
|
1135
|
+
this.recordsDirty = true
|
|
1136
|
+
const log = this.logs.get(slug)
|
|
1137
|
+
if (log === undefined) {
|
|
1138
|
+
// An append to a build the view holds no log for may change its digest.
|
|
1139
|
+
this.discoveryDirty = true
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
if (envelope.seq <= log.cursor) return
|
|
1143
|
+
if (envelope.seq === log.cursor + 1) this.fold(log, [envelope])
|
|
1144
|
+
else log.dirty = true
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Fold an own journal append on the journal chain. A seq gap means a foreign
|
|
1149
|
+
* append interleaved: repair it now, because stages snapshot the journal
|
|
1150
|
+
* synchronously through `recordedJournal()` and must see our own write. */
|
|
1151
|
+
private foldJournalAppend(envelope: RepositoryEvent): Promise<void> {
|
|
1152
|
+
return this.journalChain.run(async () => {
|
|
1153
|
+
// Before the cold load there is no journal state to extend; it reads the
|
|
1154
|
+
// journal, this append included.
|
|
1155
|
+
if (!this.initialized && !this.journalReady) return
|
|
1156
|
+
this.recorded = true
|
|
1157
|
+
if (envelope.seq <= this.journalCursor) return
|
|
1158
|
+
if (envelope.seq === this.journalCursor + 1) {
|
|
1159
|
+
this.foldJournal([envelope])
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
this.journalDirty = true
|
|
1163
|
+
await this.syncJournal()
|
|
1164
|
+
})
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async createBuild(input: NewBuildInput): Promise<BuildRecord> {
|
|
1168
|
+
const record = await this.backing.createBuild(input)
|
|
1169
|
+
this.recordsDirty = true
|
|
1170
|
+
if (record.repo === this.repo) {
|
|
1171
|
+
await this.build(record.slug, async () => {
|
|
1172
|
+
await this.loadFull(record.slug)
|
|
1173
|
+
this.work.add(record.slug)
|
|
1174
|
+
})
|
|
1175
|
+
}
|
|
1176
|
+
return record
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
|
|
1180
|
+
const envelope = await this.backing.append(slug, event)
|
|
1181
|
+
await this.foldBuildAppend(slug, envelope as unknown as AbEvent)
|
|
1182
|
+
return envelope
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async appendIfCurrent<T extends EventType>(
|
|
1186
|
+
slug: string,
|
|
1187
|
+
expectedSeq: number,
|
|
1188
|
+
event: EventWrite<T>,
|
|
1189
|
+
): Promise<EventEnvelope<T> | null> {
|
|
1190
|
+
const envelope = await this.backing.appendIfCurrent(slug, expectedSeq, event)
|
|
1191
|
+
if (envelope === null) {
|
|
1192
|
+
await this.build(slug, async () => {
|
|
1193
|
+
const log = this.logs.get(slug)
|
|
1194
|
+
if (log !== undefined) log.dirty = true
|
|
1195
|
+
})
|
|
1196
|
+
return null
|
|
1197
|
+
}
|
|
1198
|
+
await this.foldBuildAppend(slug, envelope as unknown as AbEvent)
|
|
1199
|
+
return envelope
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async appendWithArtifacts<T extends EventType>(
|
|
1203
|
+
slug: string,
|
|
1204
|
+
artifacts: ArtifactInput[],
|
|
1205
|
+
makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
|
|
1206
|
+
): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
|
|
1207
|
+
const result = await this.backing.appendWithArtifacts(slug, artifacts, makeEvent)
|
|
1208
|
+
await this.foldBuildAppend(slug, result.event as unknown as AbEvent)
|
|
1209
|
+
return result
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async appendRepo<T extends RepositoryEventType>(
|
|
1213
|
+
repo: string,
|
|
1214
|
+
event: RepositoryEventWrite<T>,
|
|
1215
|
+
): Promise<RepositoryEventEnvelope<T>> {
|
|
1216
|
+
const envelope = await this.backing.appendRepo(repo, event)
|
|
1217
|
+
if (repo === this.repo) await this.foldJournalAppend(envelope as unknown as RepositoryEvent)
|
|
1218
|
+
return envelope
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async appendRepoWithArtifacts<T extends RepositoryEventType>(
|
|
1222
|
+
repo: string,
|
|
1223
|
+
artifacts: ArtifactInput[],
|
|
1224
|
+
makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
|
|
1225
|
+
): Promise<{ event: RepositoryEventEnvelope<T>; artifacts: RepositoryArtifactMeta[] }> {
|
|
1226
|
+
const result = await this.backing.appendRepoWithArtifacts(repo, artifacts, makeEvent)
|
|
1227
|
+
if (repo === this.repo) await this.foldJournalAppend(result.event as unknown as RepositoryEvent)
|
|
1228
|
+
return result
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
1232
|
+
const claimed = await this.backing.claimLease(slug, holder, ttlMs)
|
|
1233
|
+
this.recordsDirty = true
|
|
1234
|
+
return claimed
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async heartbeat(slug: string, holder: string): Promise<boolean> {
|
|
1238
|
+
const beat = await this.backing.heartbeat(slug, holder)
|
|
1239
|
+
this.recordsDirty = true
|
|
1240
|
+
return beat
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async releaseLease(slug: string, holder: string): Promise<void> {
|
|
1244
|
+
await this.backing.releaseLease(slug, holder)
|
|
1245
|
+
this.recordsDirty = true
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
async ensureRepo(repo: string): Promise<RepositoryRecord> {
|
|
1249
|
+
return this.backing.ensureRepo(repo)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ── Plain delegation ───────────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
scopeBuild(slug: string): BuildScopedStore {
|
|
1255
|
+
return createBuildScopedStore(this, slug)
|
|
1256
|
+
}
|
|
1257
|
+
scopeSession(id: string): SessionScopedStore {
|
|
1258
|
+
return createSessionScopedStore(this, id)
|
|
1259
|
+
}
|
|
1260
|
+
getBuild(slug: string): Promise<BuildRecord | null> {
|
|
1261
|
+
return this.backing.getBuild(slug)
|
|
1262
|
+
}
|
|
1263
|
+
putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
|
|
1264
|
+
return this.backing.putArtifact(slug, artifact)
|
|
1265
|
+
}
|
|
1266
|
+
getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
|
|
1267
|
+
return this.backing.getArtifact(slug, kind, rev)
|
|
1268
|
+
}
|
|
1269
|
+
listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
|
|
1270
|
+
return this.backing.listArtifacts(slug, kind)
|
|
1271
|
+
}
|
|
1272
|
+
subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
|
|
1273
|
+
return this.backing.subscribe(slug, opts, onEvent)
|
|
1274
|
+
}
|
|
1275
|
+
getRepo(repo: string): Promise<RepositoryRecord | null> {
|
|
1276
|
+
return this.backing.getRepo(repo)
|
|
1277
|
+
}
|
|
1278
|
+
putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
|
|
1279
|
+
return this.backing.putRepoArtifact(repo, artifact)
|
|
1280
|
+
}
|
|
1281
|
+
getRepoArtifact(repo: string, kind: string, rev?: number): Promise<RepositoryArtifact | null> {
|
|
1282
|
+
return this.backing.getRepoArtifact(repo, kind, rev)
|
|
1283
|
+
}
|
|
1284
|
+
listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
|
|
1285
|
+
return this.backing.listRepoArtifacts(repo, kind)
|
|
1286
|
+
}
|
|
1287
|
+
ticketAssetLimits(repo: string): Promise<TicketAssetLimits> {
|
|
1288
|
+
return this.backing.ticketAssetLimits(repo)
|
|
1289
|
+
}
|
|
1290
|
+
putTicketAsset(
|
|
1291
|
+
repo: string,
|
|
1292
|
+
ticketId: string,
|
|
1293
|
+
asset: TicketAssetInput,
|
|
1294
|
+
): Promise<TicketAssetMeta> {
|
|
1295
|
+
return this.backing.putTicketAsset(repo, ticketId, asset)
|
|
1296
|
+
}
|
|
1297
|
+
getTicketAsset(
|
|
1298
|
+
repo: string,
|
|
1299
|
+
ticketId: string,
|
|
1300
|
+
kind: string,
|
|
1301
|
+
name: string,
|
|
1302
|
+
rev?: number,
|
|
1303
|
+
): Promise<TicketAsset | null> {
|
|
1304
|
+
return this.backing.getTicketAsset(repo, ticketId, kind, name, rev)
|
|
1305
|
+
}
|
|
1306
|
+
getPinnedTicketAsset(
|
|
1307
|
+
slug: string,
|
|
1308
|
+
kind: string,
|
|
1309
|
+
name: string,
|
|
1310
|
+
rev?: number,
|
|
1311
|
+
): Promise<TicketAsset | null> {
|
|
1312
|
+
return this.backing.getPinnedTicketAsset(slug, kind, name, rev)
|
|
1313
|
+
}
|
|
1314
|
+
listTicketAssets(
|
|
1315
|
+
repo: string,
|
|
1316
|
+
ticketId: string,
|
|
1317
|
+
opts?: { revisions?: boolean },
|
|
1318
|
+
): Promise<TicketAssetSummary[]> {
|
|
1319
|
+
return this.backing.listTicketAssets(repo, ticketId, opts)
|
|
1320
|
+
}
|
|
1321
|
+
removeTicketAsset(
|
|
1322
|
+
repo: string,
|
|
1323
|
+
ticketId: string,
|
|
1324
|
+
kind: string,
|
|
1325
|
+
name: string,
|
|
1326
|
+
): Promise<TicketAssetMeta | null> {
|
|
1327
|
+
return this.backing.removeTicketAsset(repo, ticketId, kind, name)
|
|
1328
|
+
}
|
|
1329
|
+
claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
1330
|
+
return this.backing.claimRepoLease(repo, holder, ttlMs)
|
|
1331
|
+
}
|
|
1332
|
+
heartbeatRepo(repo: string, holder: string): Promise<boolean> {
|
|
1333
|
+
return this.backing.heartbeatRepo(repo, holder)
|
|
1334
|
+
}
|
|
1335
|
+
releaseRepoLease(repo: string, holder: string): Promise<void> {
|
|
1336
|
+
return this.backing.releaseRepoLease(repo, holder)
|
|
1337
|
+
}
|
|
1338
|
+
createSession(input: NewSessionInput): Promise<SessionRecord> {
|
|
1339
|
+
return this.backing.createSession(input)
|
|
1340
|
+
}
|
|
1341
|
+
getSession(id: string): Promise<SessionRecord | null> {
|
|
1342
|
+
return this.backing.getSession(id)
|
|
1343
|
+
}
|
|
1344
|
+
listSessions(repo: string): Promise<SessionRecord[]> {
|
|
1345
|
+
return this.backing.listSessions(repo)
|
|
1346
|
+
}
|
|
1347
|
+
appendSessionEvent<T extends SessionEventType>(
|
|
1348
|
+
id: string,
|
|
1349
|
+
event: SessionEventWrite<T>,
|
|
1350
|
+
): Promise<SessionEventEnvelope<T>> {
|
|
1351
|
+
return this.backing.appendSessionEvent(id, event)
|
|
1352
|
+
}
|
|
1353
|
+
appendSessionEventIfCurrent<T extends SessionEventType>(
|
|
1354
|
+
id: string,
|
|
1355
|
+
expectedSeq: number,
|
|
1356
|
+
event: SessionEventWrite<T>,
|
|
1357
|
+
): Promise<SessionEventEnvelope<T> | null> {
|
|
1358
|
+
return this.backing.appendSessionEventIfCurrent(id, expectedSeq, event)
|
|
1359
|
+
}
|
|
1360
|
+
getSessionEvents(
|
|
1361
|
+
id: string,
|
|
1362
|
+
sinceSeq?: number,
|
|
1363
|
+
opts?: { waitSeconds?: number; signal?: AbortSignal },
|
|
1364
|
+
): Promise<SessionEvent[]> {
|
|
1365
|
+
return this.backing.getSessionEvents(id, sinceSeq, opts)
|
|
1366
|
+
}
|
|
1367
|
+
appendSessionWithArtifacts<T extends SessionEventType>(
|
|
1368
|
+
id: string,
|
|
1369
|
+
artifacts: ArtifactInput[],
|
|
1370
|
+
makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
|
|
1371
|
+
): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
|
|
1372
|
+
return this.backing.appendSessionWithArtifacts(id, artifacts, makeEvent)
|
|
1373
|
+
}
|
|
1374
|
+
putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
|
|
1375
|
+
return this.backing.putSessionArtifact(id, artifact)
|
|
1376
|
+
}
|
|
1377
|
+
getSessionArtifact(id: string, kind: string, rev?: number): Promise<SessionArtifact | null> {
|
|
1378
|
+
return this.backing.getSessionArtifact(id, kind, rev)
|
|
1379
|
+
}
|
|
1380
|
+
listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
|
|
1381
|
+
return this.backing.listSessionArtifacts(id, kind)
|
|
1382
|
+
}
|
|
1383
|
+
createStream(scope: StreamScope, label: string): Promise<StreamRecord> {
|
|
1384
|
+
return this.backing.createStream(scope, label)
|
|
1385
|
+
}
|
|
1386
|
+
appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
|
|
1387
|
+
return this.backing.appendStreamParts(streamId, parts)
|
|
1388
|
+
}
|
|
1389
|
+
readStream(
|
|
1390
|
+
streamId: string,
|
|
1391
|
+
opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
|
|
1392
|
+
): Promise<StreamRead> {
|
|
1393
|
+
return this.backing.readStream(streamId, opts)
|
|
1394
|
+
}
|
|
1395
|
+
closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
|
|
1396
|
+
return this.backing.closeStream(streamId, outcome)
|
|
1397
|
+
}
|
|
1398
|
+
getStream(streamId: string): Promise<StreamRecord | null> {
|
|
1399
|
+
return this.backing.getStream(streamId)
|
|
1400
|
+
}
|
|
1401
|
+
listStreams(scope: StreamScope): Promise<StreamRecord[]> {
|
|
1402
|
+
return this.backing.listStreams(scope)
|
|
1403
|
+
}
|
|
1404
|
+
close(): Promise<void> {
|
|
1405
|
+
return this.backing.close()
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** Cheap structural checks on a stored accumulator: a snapshot that fails one
|
|
1410
|
+
* is a miss. They guard a hand-edited or truncated row; a well-formed row of the
|
|
1411
|
+
* right version is the reducer's own output. */
|
|
1412
|
+
function buildAccShape(snapshot: ReducerSnapshot): boolean {
|
|
1413
|
+
const state = snapshot.state
|
|
1414
|
+
return (
|
|
1415
|
+
isObject(state) && Array.isArray(state.openEscalations) && Array.isArray(state.observations)
|
|
1416
|
+
)
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function indexAccShape(state: unknown): boolean {
|
|
1420
|
+
return (
|
|
1421
|
+
isObject(state) &&
|
|
1422
|
+
Array.isArray(state.candidates) &&
|
|
1423
|
+
isObject(state.maxRoundEver) &&
|
|
1424
|
+
Array.isArray(state.guidanceDeliveries)
|
|
1425
|
+
)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Events are a contiguous run starting right after `cursor`. */
|
|
1429
|
+
function contiguousFrom(events: readonly { seq: number }[], cursor: number): boolean {
|
|
1430
|
+
let expected = cursor + 1
|
|
1431
|
+
for (const event of events) {
|
|
1432
|
+
if (event.seq !== expected) return false
|
|
1433
|
+
expected += 1
|
|
1434
|
+
}
|
|
1435
|
+
return true
|
|
1436
|
+
}
|