@defrex/autobuild 0.12.0 → 0.14.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 +138 -16
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +155 -73
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +8 -1
- package/docs/remote-store-protocol.md +94 -2
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +23 -16
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +23 -79
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +304 -82
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +84 -42
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +23 -5
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +166 -36
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- package/packages/core/src/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +38 -4
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +139 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +132 -22
- package/packages/core/src/plugin-sdk/index.ts +19 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- package/packages/core/src/processes/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +223 -205
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +58 -74
- package/packages/core/src/processes/harvest.ts +72 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1099
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +283 -1
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +180 -55
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +40 -3
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +97 -12
- package/skills/guide/references/operator-api.md +8 -1
- package/skills/guide/references/remote-store-protocol.md +94 -2
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +2 -2
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { DispatchRepositoryFeed } from './dispatch-repository-feed'
|
|
1
2
|
import { StandbyIdentityResolver } from './dispatcher-liveness'
|
|
2
3
|
import { configSchema, type Config } from '../config/schema'
|
|
3
4
|
import { humanActor } from '../events/envelope'
|
|
4
5
|
import type { RepositoryEvent } from '../events/repository'
|
|
5
6
|
import { reduceDispatchStatus, type DispatchStatus } from '../kernel/dispatch-status'
|
|
6
7
|
import type { BuildState } from '../kernel/reducer'
|
|
7
|
-
import {
|
|
8
|
+
import { unclaimedObservationCount } from '../processes/harvest'
|
|
8
9
|
import {
|
|
9
10
|
controlHarvestRun,
|
|
10
11
|
OperatorControlError,
|
|
@@ -68,15 +69,6 @@ function isStaleAbortControlError(error: unknown): error is BuildControlError {
|
|
|
68
69
|
)
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
|
|
72
|
-
return (
|
|
73
|
-
event.type.startsWith('harvest.') ||
|
|
74
|
-
event.type === 'dispatcher.intake-set' ||
|
|
75
|
-
event.type === 'dispatcher.pause-set' ||
|
|
76
|
-
event.type === 'dispatcher.auto-merge-default-set'
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
72
|
interface ResumePrompt {
|
|
81
73
|
slug: string
|
|
82
74
|
escalationIds: string[]
|
|
@@ -121,11 +113,7 @@ export class DispatchFrontend {
|
|
|
121
113
|
private readonly region: LiveRegion
|
|
122
114
|
private readonly keyboard
|
|
123
115
|
private child: DispatchChildHandle | undefined
|
|
124
|
-
private
|
|
125
|
-
/** Set only after a successful initial fold; every later `events()` call
|
|
126
|
-
* takes the delta path. */
|
|
127
|
-
private seeded = false
|
|
128
|
-
private repositoryEvents: RepositoryEvent[] = []
|
|
116
|
+
private readonly repositoryFeed: DispatchRepositoryFeed
|
|
129
117
|
private dispatchStatus: DispatchStatus = reduceDispatchStatus([], this.runId)
|
|
130
118
|
private eventRefreshTail: Promise<void> = Promise.resolve()
|
|
131
119
|
private cache: DashboardBuildPollCache | undefined
|
|
@@ -155,8 +143,12 @@ export class DispatchFrontend {
|
|
|
155
143
|
private operatorStopRequested = false
|
|
156
144
|
|
|
157
145
|
constructor(private readonly opts: DispatchFrontendOptions) {
|
|
158
|
-
this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo)
|
|
159
146
|
this.clock = opts.clock ?? systemClock
|
|
147
|
+
this.repositoryFeed = new DispatchRepositoryFeed(opts.store, opts.repo, this.clock)
|
|
148
|
+
this.standbyResolver = new StandbyIdentityResolver(opts.store, opts.repo, {
|
|
149
|
+
allowLookup: () => this.repositoryFeed.incompleteIdentities,
|
|
150
|
+
remember: (events, complete) => this.repositoryFeed.remember(events, complete),
|
|
151
|
+
})
|
|
160
152
|
this.keyboard = createKeyboardProtocol(
|
|
161
153
|
(chunk) => opts.terminal.write(chunk),
|
|
162
154
|
opts.terminal.modes,
|
|
@@ -170,47 +162,14 @@ export class DispatchFrontend {
|
|
|
170
162
|
* are retained for their existing replay reducers. */
|
|
171
163
|
private async events(): Promise<RepositoryEvent[]> {
|
|
172
164
|
const refresh = this.eventRefreshTail.then(async () => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// to pay is replaced by AUT-489's bounded read where that is provably
|
|
176
|
-
// equivalent. The bounded attempt is confined to the pre-launch
|
|
177
|
-
// window (`this.child === undefined`): before `launchChild` no
|
|
178
|
-
// run-scoped journal fact can carry `this.runId` (only the supervised
|
|
179
|
-
// child appends run-scoped facts with it), so every run-scoped event
|
|
180
|
-
// in the bounded subset is skipped by `reduceDispatchStatus` exactly
|
|
181
|
-
// as it would be in a full replay, and the dashboard filter keeps
|
|
182
|
-
// only durable types, which the subset carries in full. After launch
|
|
183
|
-
// a bounded subset cannot guarantee the child's `run-started` under a
|
|
184
|
-
// competing dispatcher's later anchor — dropping it would leave
|
|
185
|
-
// `effectiveConfig` unset and the first frame would never render —
|
|
186
|
-
// so a post-launch seed always takes the full read (the pre-AUT-534
|
|
187
|
-
// behavior). `once` mode's teardown fold runs after the child exits,
|
|
188
|
-
// so the same guard routes it to the full read.
|
|
189
|
-
const seed =
|
|
190
|
-
this.child === undefined
|
|
191
|
-
? await this.opts.store.getRepoStateEvents(this.opts.repo).catch(() =>
|
|
192
|
-
// Bounded-read failure falls back to the exact pre-AUT-534
|
|
193
|
-
// full read within this same seed attempt.
|
|
194
|
-
this.opts.store.getRepoEvents(this.opts.repo, 0),
|
|
195
|
-
)
|
|
196
|
-
: await this.opts.store.getRepoEvents(this.opts.repo, 0)
|
|
197
|
-
this.standbyResolver.ingest(seed)
|
|
198
|
-
this.repositorySeq = seed.at(-1)?.seq ?? 0
|
|
199
|
-
this.dispatchStatus = reduceDispatchStatus(seed, this.runId)
|
|
200
|
-
this.repositoryEvents.push(...seed.filter(isDashboardRepositoryEvent))
|
|
201
|
-
this.seeded = true
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
const delta = await this.opts.store.getRepoEvents(this.opts.repo, this.repositorySeq)
|
|
205
|
-
if (delta.length === 0) return
|
|
165
|
+
const delta = await this.repositoryFeed.refresh(false)
|
|
166
|
+
this.standbyResolver.ingest(this.repositoryFeed.identities)
|
|
206
167
|
this.standbyResolver.ingest(delta)
|
|
207
|
-
this.repositorySeq = delta.at(-1)!.seq
|
|
208
168
|
this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
|
|
209
|
-
this.repositoryEvents.push(...delta.filter(isDashboardRepositoryEvent))
|
|
210
169
|
})
|
|
211
170
|
this.eventRefreshTail = refresh.catch(() => {})
|
|
212
171
|
await refresh
|
|
213
|
-
return this.
|
|
172
|
+
return this.repositoryFeed.events
|
|
214
173
|
}
|
|
215
174
|
|
|
216
175
|
private async report(message: string, level: 'info' | 'warning' = 'info'): Promise<void> {
|
|
@@ -333,14 +292,10 @@ export class DispatchFrontend {
|
|
|
333
292
|
}
|
|
334
293
|
const config = await this.loadConfig(ref.kind, ref.rev)
|
|
335
294
|
try {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// traffic stays flat as finished builds accumulate. A journal record
|
|
341
|
-
// that does not yet exist reads as an empty journal (AUT-524); the
|
|
342
|
-
// sample writes nothing.
|
|
343
|
-
this.observationCount = await sampleUnclaimedObservationCount(this.opts.store, this.opts.repo)
|
|
295
|
+
this.observationCount = unclaimedObservationCount({
|
|
296
|
+
digests: await this.opts.store.getRepoBuildDigests(this.opts.repo),
|
|
297
|
+
harvestEvents: repositoryEvents,
|
|
298
|
+
})
|
|
344
299
|
} catch {
|
|
345
300
|
// Before the first successful sample there is no factual zero to place in
|
|
346
301
|
// a complete frame. Leave the prior frame untouched and retry on the next
|
|
@@ -401,6 +356,7 @@ export class DispatchFrontend {
|
|
|
401
356
|
) {
|
|
402
357
|
this.view = undefined
|
|
403
358
|
}
|
|
359
|
+
await this.repositoryFeed.save()
|
|
404
360
|
this.model = projected
|
|
405
361
|
this.syncControls()
|
|
406
362
|
this.paint()
|
|
@@ -1057,13 +1013,13 @@ export class DispatchFrontend {
|
|
|
1057
1013
|
})
|
|
1058
1014
|
}
|
|
1059
1015
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1016
|
+
try {
|
|
1017
|
+
const delta = await this.repositoryFeed.warm()
|
|
1018
|
+
this.standbyResolver.ingest(this.repositoryFeed.identities)
|
|
1019
|
+
this.dispatchStatus = reduceDispatchStatus(delta, this.runId, this.dispatchStatus)
|
|
1020
|
+
} catch {
|
|
1021
|
+
/* Display initialization must not gate the kernel. */
|
|
1022
|
+
}
|
|
1067
1023
|
|
|
1068
1024
|
const options: DispatchChildOptions = {
|
|
1069
1025
|
targetRepo: this.opts.checkout ?? this.opts.repo,
|
|
@@ -1104,6 +1060,7 @@ export class DispatchFrontend {
|
|
|
1104
1060
|
this.opts.signal?.removeEventListener('abort', stop)
|
|
1105
1061
|
await this.child.stop()
|
|
1106
1062
|
await this.finishPresentation()
|
|
1063
|
+
await this.repositoryFeed.save(true)
|
|
1107
1064
|
}
|
|
1108
1065
|
}
|
|
1109
1066
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { readDispatcherRunFacts, readOpenTick } from '../store/reduced-reads'
|
|
1
2
|
import { distributionPath } from '../distribution'
|
|
2
3
|
import { DISPATCHER } from '../events/envelope'
|
|
3
|
-
import { hasOpenTick } from '../processes/tick-state'
|
|
4
4
|
import type { BuildStore } from '../store/types'
|
|
5
5
|
|
|
6
6
|
export const DISPATCH_CHILD_OPTIONS_ENV = 'AB_DISPATCH_CHILD_OPTIONS'
|
|
@@ -168,10 +168,8 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
168
168
|
// chooses/awaits process termination, avoiding check-then-append races.
|
|
169
169
|
const completed = child.exited.then(async (exitCode): Promise<DispatchChildResult> => {
|
|
170
170
|
const processSignal = child.signalCode === null ? requestedSignal : String(child.signalCode)
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
(event) => event.type === 'dispatcher.run-stopped' && event.payload.run === deps.run,
|
|
174
|
-
)
|
|
171
|
+
const recorded = (await readDispatcherRunFacts(deps.store, deps.repo, deps.run, false)).value
|
|
172
|
+
.stop
|
|
175
173
|
if (recorded?.type === 'dispatcher.run-stopped') {
|
|
176
174
|
const signal = processSignal ?? recorded.payload.signal
|
|
177
175
|
return {
|
|
@@ -245,12 +243,21 @@ export function superviseDispatchChild(deps: DispatchChildSupervisorDeps): Dispa
|
|
|
245
243
|
// Ready with no durable build to recover. Let an open tick reach its
|
|
246
244
|
// normal boundary; all non-tick work is lease-backed/recoverable and a
|
|
247
245
|
// child stuck there is safe to terminate.
|
|
248
|
-
|
|
246
|
+
let open = true
|
|
247
|
+
try {
|
|
248
|
+
const record = await deps.store.getRepo(deps.repo)
|
|
249
|
+
open =
|
|
250
|
+
record?.tick?.run === deps.run
|
|
251
|
+
? record.tick.open !== undefined
|
|
252
|
+
: await readOpenTick(deps.store, deps.repo, deps.run, false)
|
|
253
|
+
} catch {
|
|
254
|
+
// Unreadable telemetry cannot authorize an unsafe kill.
|
|
255
|
+
}
|
|
249
256
|
// The child may have exited while the asynchronous journal read was in
|
|
250
257
|
// flight. Re-check at the force boundary so evidence never claims a
|
|
251
258
|
// SIGKILL that was not actually sent.
|
|
252
259
|
if (child.exitCode !== null) return
|
|
253
|
-
if (
|
|
260
|
+
if (open) {
|
|
254
261
|
await child.exited
|
|
255
262
|
return
|
|
256
263
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { type RepositoryEvent, validateRepositoryEventWrite } from '../events/repository'
|
|
2
|
+
import { defineReducer } from '../kernel/incremental'
|
|
3
|
+
import { loadReducerSnapshot, persistReducerSnapshot } from '../store/snapshots'
|
|
4
|
+
import type { BuildStore, Clock } from '../store/types'
|
|
5
|
+
|
|
6
|
+
export function isDashboardRepositoryEvent(event: RepositoryEvent): boolean {
|
|
7
|
+
return (
|
|
8
|
+
event.type.startsWith('harvest.') ||
|
|
9
|
+
event.type === 'dispatcher.intake-set' ||
|
|
10
|
+
event.type === 'dispatcher.pause-set' ||
|
|
11
|
+
event.type === 'dispatcher.auto-merge-default-set'
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const dispatchFrontendReducer = defineReducer({
|
|
16
|
+
version: 2,
|
|
17
|
+
initial: (): { retained: RepositoryEvent[]; incompleteIdentities: boolean } => ({
|
|
18
|
+
retained: [],
|
|
19
|
+
incompleteIdentities: true,
|
|
20
|
+
}),
|
|
21
|
+
fold(acc, events: readonly RepositoryEvent[]) {
|
|
22
|
+
const facts = new Map(acc.retained.map((event) => [event.seq, event]))
|
|
23
|
+
for (const event of events) {
|
|
24
|
+
if (isDashboardRepositoryEvent(event) || event.type === 'dispatcher.run-started')
|
|
25
|
+
facts.set(event.seq, event)
|
|
26
|
+
}
|
|
27
|
+
acc.retained = [...facts.values()].sort((a, b) => a.seq - b.seq)
|
|
28
|
+
},
|
|
29
|
+
finish: (acc) => acc.retained,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** One serialized, frontend-only repository projection. The consumed cursor is
|
|
33
|
+
* independent of retained facts: quiet ticks must never survive in the cache. */
|
|
34
|
+
export class DispatchRepositoryFeed {
|
|
35
|
+
private cursor = 0
|
|
36
|
+
private ready = false
|
|
37
|
+
private retained: RepositoryEvent[] = []
|
|
38
|
+
private tail: Promise<void> = Promise.resolve()
|
|
39
|
+
private savedAt = -Infinity
|
|
40
|
+
private boundary: number | undefined
|
|
41
|
+
private recovery = false
|
|
42
|
+
incompleteIdentities = true
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly store: BuildStore,
|
|
46
|
+
private readonly repo: string,
|
|
47
|
+
private readonly clock: Clock,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
get events(): RepositoryEvent[] {
|
|
51
|
+
return this.retained.filter(isDashboardRepositoryEvent)
|
|
52
|
+
}
|
|
53
|
+
get identities(): RepositoryEvent[] {
|
|
54
|
+
return this.retained.filter((e) => e.type === 'dispatcher.run-started')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private validate(events: RepositoryEvent[], cursor: number, contiguous: boolean): void {
|
|
58
|
+
let previous = cursor
|
|
59
|
+
for (const event of events) {
|
|
60
|
+
if (
|
|
61
|
+
event.repo !== this.repo ||
|
|
62
|
+
!Number.isInteger(event.seq) ||
|
|
63
|
+
event.seq <= previous ||
|
|
64
|
+
(contiguous && event.seq !== previous + 1) ||
|
|
65
|
+
typeof event.ts !== 'string' ||
|
|
66
|
+
!Number.isFinite(Date.parse(event.ts))
|
|
67
|
+
) {
|
|
68
|
+
throw new Error('invalid dashboard repository delta')
|
|
69
|
+
}
|
|
70
|
+
validateRepositoryEventWrite(event)
|
|
71
|
+
previous = event.seq
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async warm(): Promise<RepositoryEvent[]> {
|
|
76
|
+
this.recovery = true
|
|
77
|
+
this.boundary = await this.store.getRepoHighWater(this.repo)
|
|
78
|
+
this.recovery = false
|
|
79
|
+
try {
|
|
80
|
+
return await this.refresh()
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.recovery = true
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
refresh(persist = true): Promise<RepositoryEvent[]> {
|
|
88
|
+
let result: RepositoryEvent[] = []
|
|
89
|
+
const next = this.tail.then(async () => {
|
|
90
|
+
let cursor = this.cursor
|
|
91
|
+
let retained = this.retained
|
|
92
|
+
let incompleteIdentities = this.incompleteIdentities
|
|
93
|
+
let delta: RepositoryEvent[]
|
|
94
|
+
if (!this.ready) {
|
|
95
|
+
if ((await this.store.getRepo(this.repo)) === null) {
|
|
96
|
+
this.ready = true
|
|
97
|
+
this.recovery = false
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const high = await this.store.getRepoHighWater(this.repo)
|
|
101
|
+
const snapshot = await loadReducerSnapshot(
|
|
102
|
+
this.store,
|
|
103
|
+
{ kind: 'repo', repo: this.repo },
|
|
104
|
+
'dispatchFrontend',
|
|
105
|
+
dispatchFrontendReducer,
|
|
106
|
+
)
|
|
107
|
+
let restored = false
|
|
108
|
+
if (snapshot && snapshot.cursor <= high) {
|
|
109
|
+
try {
|
|
110
|
+
const state = snapshot.state as {
|
|
111
|
+
retained: RepositoryEvent[]
|
|
112
|
+
incompleteIdentities: boolean
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(state.retained) || typeof state.incompleteIdentities !== 'boolean')
|
|
115
|
+
throw new Error('invalid projection')
|
|
116
|
+
this.validate(state.retained, 0, false)
|
|
117
|
+
if (
|
|
118
|
+
state.retained.some(
|
|
119
|
+
(e) =>
|
|
120
|
+
e.seq > snapshot.cursor ||
|
|
121
|
+
!(isDashboardRepositoryEvent(e) || e.type === 'dispatcher.run-started'),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
throw new Error('invalid retained fact')
|
|
125
|
+
incompleteIdentities = state.incompleteIdentities
|
|
126
|
+
retained = state.retained
|
|
127
|
+
cursor = snapshot.cursor
|
|
128
|
+
restored = true
|
|
129
|
+
} catch {
|
|
130
|
+
/* A malformed cache uses the bounded seed. */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!restored) {
|
|
134
|
+
const seed = await this.store.getRepoStateEvents(this.repo)
|
|
135
|
+
this.validate(seed, 0, false)
|
|
136
|
+
incompleteIdentities = true
|
|
137
|
+
retained = dispatchFrontendReducer.reduce(seed)
|
|
138
|
+
cursor = Math.max(high, seed.at(-1)?.seq ?? 0)
|
|
139
|
+
}
|
|
140
|
+
delta = await this.store.getRepoEvents(this.repo, cursor)
|
|
141
|
+
this.validate(delta, cursor, true)
|
|
142
|
+
// A failed prelaunch seed must not skip this invocation's config/start
|
|
143
|
+
// even if a competing run advanced the bounded seed's anchor.
|
|
144
|
+
if (this.recovery) {
|
|
145
|
+
const recovery = await this.store.getRepoEvents(this.repo, this.boundary ?? 0)
|
|
146
|
+
this.validate(recovery, this.boundary ?? 0, true)
|
|
147
|
+
delta = [...new Map([...delta, ...recovery].map((e) => [e.seq, e])).values()].sort(
|
|
148
|
+
(a, b) => a.seq - b.seq,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
delta = await this.store.getRepoEvents(this.repo, cursor)
|
|
153
|
+
this.validate(delta, cursor, true)
|
|
154
|
+
// A standby correlation may have supplied an older identity while the
|
|
155
|
+
// delta read was pending. Keep those memoized facts in this commit.
|
|
156
|
+
retained = this.retained
|
|
157
|
+
incompleteIdentities = this.incompleteIdentities
|
|
158
|
+
}
|
|
159
|
+
retained = dispatchFrontendReducer.advance({ retained, incompleteIdentities }, delta).retained
|
|
160
|
+
this.cursor = Math.max(cursor, delta.at(-1)?.seq ?? 0)
|
|
161
|
+
this.incompleteIdentities = incompleteIdentities
|
|
162
|
+
this.retained = retained
|
|
163
|
+
this.ready = true
|
|
164
|
+
this.recovery = false
|
|
165
|
+
result = delta
|
|
166
|
+
if (persist) await this.save()
|
|
167
|
+
})
|
|
168
|
+
this.tail = next.catch(() => {})
|
|
169
|
+
return next.then(() => result)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
remember(events: readonly RepositoryEvent[], complete = false): void {
|
|
173
|
+
this.retained = dispatchFrontendReducer.advance(
|
|
174
|
+
{ retained: this.retained, incompleteIdentities: this.incompleteIdentities },
|
|
175
|
+
events.filter((e) => e.seq <= this.cursor),
|
|
176
|
+
).retained
|
|
177
|
+
if (complete) this.incompleteIdentities = false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async save(force = false): Promise<void> {
|
|
181
|
+
if (!this.ready || (!force && this.clock().getTime() - this.savedAt < 5000)) return
|
|
182
|
+
// A rejected equal-cursor put and an unavailable store still cost a
|
|
183
|
+
// request. Bound attempts, not just successful writes, on quiet polls.
|
|
184
|
+
this.savedAt = this.clock().getTime()
|
|
185
|
+
await persistReducerSnapshot(
|
|
186
|
+
this.store,
|
|
187
|
+
{ kind: 'repo', repo: this.repo },
|
|
188
|
+
'dispatchFrontend',
|
|
189
|
+
{
|
|
190
|
+
version: dispatchFrontendReducer.version,
|
|
191
|
+
cursor: this.cursor,
|
|
192
|
+
state: { retained: this.retained, incompleteIdentities: this.incompleteIdentities },
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
}
|