@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
|
@@ -40,9 +40,10 @@ import {
|
|
|
40
40
|
type SessionEventWrite,
|
|
41
41
|
} from '../../events/sessions'
|
|
42
42
|
import { createBuildScopedStore } from '../build-scope'
|
|
43
|
+
import type { ReducerSnapshot, SnapshotScope } from '../snapshots'
|
|
43
44
|
import { validateCreatedEvent } from '../new-build'
|
|
44
45
|
import { createSessionScopedStore } from '../session-handle'
|
|
45
|
-
import { DIGEST_EVENT_TYPES, reduceBuildDigest } from '../digest'
|
|
46
|
+
import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
|
|
46
47
|
import {
|
|
47
48
|
REPOSITORY_STATE_EVENT_TYPES,
|
|
48
49
|
readRepoStateEventsWithAnchorRecheck,
|
|
@@ -135,6 +136,18 @@ import {
|
|
|
135
136
|
// Exported for the schema-parity test, which pins this DDL against the
|
|
136
137
|
// drizzle schema; schema.ts stays the source of truth, this is its inlined form.
|
|
137
138
|
export const BOOTSTRAP_DDL = [
|
|
139
|
+
// Reducer snapshots: additive and idempotent, so databases that predate the
|
|
140
|
+
// table gain it on open.
|
|
141
|
+
`CREATE TABLE IF NOT EXISTS reducer_snapshots (
|
|
142
|
+
scope_kind TEXT NOT NULL,
|
|
143
|
+
scope_key TEXT NOT NULL,
|
|
144
|
+
reducer TEXT NOT NULL,
|
|
145
|
+
version INTEGER NOT NULL,
|
|
146
|
+
cursor INTEGER NOT NULL,
|
|
147
|
+
state TEXT NOT NULL,
|
|
148
|
+
updated_at TEXT NOT NULL,
|
|
149
|
+
PRIMARY KEY (scope_kind, scope_key, reducer)
|
|
150
|
+
)`,
|
|
138
151
|
`CREATE TABLE IF NOT EXISTS builds (
|
|
139
152
|
slug TEXT PRIMARY KEY,
|
|
140
153
|
repo TEXT NOT NULL,
|
|
@@ -491,7 +504,11 @@ export class SqliteBuildStore implements BuildStore {
|
|
|
491
504
|
// per build, so the answer cannot drift from `reduceBuild`.
|
|
492
505
|
const rows = this.sqlite
|
|
493
506
|
.query(
|
|
494
|
-
`SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type
|
|
507
|
+
`SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
|
|
508
|
+
CASE WHEN e.type = 'pr-attachment.reclaimed'
|
|
509
|
+
THEN json_extract(e.payload, '$.hostedSeq') END AS hostedSeq,
|
|
510
|
+
CASE WHEN e.type IN ('execution.started', 'execution.ended')
|
|
511
|
+
THEN json_extract(e.payload, '$.instance') END AS instance
|
|
495
512
|
FROM builds b
|
|
496
513
|
LEFT JOIN events e
|
|
497
514
|
ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map(() => '?').join(', ')})
|
|
@@ -503,12 +520,20 @@ export class SqliteBuildStore implements BuildStore {
|
|
|
503
520
|
seq: number | null
|
|
504
521
|
ts: string | null
|
|
505
522
|
type: string | null
|
|
523
|
+
hostedSeq: number | null
|
|
524
|
+
instance: string | null
|
|
506
525
|
}[]
|
|
507
|
-
const eventsByBuild = new Map<string,
|
|
526
|
+
const eventsByBuild = new Map<string, DigestEventRow[]>()
|
|
508
527
|
for (const row of rows) {
|
|
509
528
|
if (row.seq === null || row.ts === null || row.type === null) continue
|
|
510
529
|
const events = eventsByBuild.get(row.slug) ?? []
|
|
511
|
-
events.push({
|
|
530
|
+
events.push({
|
|
531
|
+
type: row.type as AbEvent['type'],
|
|
532
|
+
seq: row.seq,
|
|
533
|
+
ts: row.ts,
|
|
534
|
+
...(row.hostedSeq !== null ? { hostedSeq: row.hostedSeq } : {}),
|
|
535
|
+
...(row.instance !== null ? { instance: row.instance } : {}),
|
|
536
|
+
})
|
|
512
537
|
eventsByBuild.set(row.slug, events)
|
|
513
538
|
}
|
|
514
539
|
const digests = new Map<string, BuildDigest>()
|
|
@@ -1038,6 +1063,88 @@ export class SqliteBuildStore implements BuildStore {
|
|
|
1038
1063
|
})
|
|
1039
1064
|
}
|
|
1040
1065
|
|
|
1066
|
+
async getRepoHighWater(repo: string): Promise<number> {
|
|
1067
|
+
this.requireRepo(repo)
|
|
1068
|
+
const row = this.db
|
|
1069
|
+
.select({ seq: sql<number | null>`max(${repoEvents.seq})` })
|
|
1070
|
+
.from(repoEvents)
|
|
1071
|
+
.where(eq(repoEvents.repo, repo))
|
|
1072
|
+
.get()
|
|
1073
|
+
return row?.seq ?? 0
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The scope's log tail, or `undefined` for a scope that does not exist. */
|
|
1077
|
+
private snapshotTail(scope: SnapshotScope): number | undefined {
|
|
1078
|
+
if (scope.kind === 'build') {
|
|
1079
|
+
const row = this.sqlite
|
|
1080
|
+
.query(
|
|
1081
|
+
'SELECT (SELECT COALESCE(MAX(seq), 0) FROM events WHERE build = ?1) AS tail FROM builds WHERE slug = ?1',
|
|
1082
|
+
)
|
|
1083
|
+
.get(scope.slug) as { tail: number } | null
|
|
1084
|
+
return row?.tail
|
|
1085
|
+
}
|
|
1086
|
+
const row = this.sqlite
|
|
1087
|
+
.query(
|
|
1088
|
+
'SELECT (SELECT COALESCE(MAX(seq), 0) FROM repo_events WHERE repo = ?1) AS tail FROM repo_streams WHERE repo = ?1',
|
|
1089
|
+
)
|
|
1090
|
+
.get(scope.repo) as { tail: number } | null
|
|
1091
|
+
return row?.tail
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async getReducerSnapshot(
|
|
1095
|
+
scope: SnapshotScope,
|
|
1096
|
+
reducer: string,
|
|
1097
|
+
version: number,
|
|
1098
|
+
): Promise<ReducerSnapshot | null> {
|
|
1099
|
+
const tail = this.snapshotTail(scope)
|
|
1100
|
+
if (tail === undefined) return null
|
|
1101
|
+
const row = this.sqlite
|
|
1102
|
+
.query(
|
|
1103
|
+
'SELECT version, cursor, state FROM reducer_snapshots WHERE scope_kind = ?1 AND scope_key = ?2 AND reducer = ?3',
|
|
1104
|
+
)
|
|
1105
|
+
.get(scope.kind, scope.kind === 'build' ? scope.slug : scope.repo, reducer) as {
|
|
1106
|
+
version: number
|
|
1107
|
+
cursor: number
|
|
1108
|
+
state: string
|
|
1109
|
+
} | null
|
|
1110
|
+
if (row === null || row.version !== version || row.cursor > tail) return null
|
|
1111
|
+
try {
|
|
1112
|
+
return { version: row.version, cursor: row.cursor, state: JSON.parse(row.state) }
|
|
1113
|
+
} catch {
|
|
1114
|
+
return null
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async putReducerSnapshot(
|
|
1119
|
+
scope: SnapshotScope,
|
|
1120
|
+
reducer: string,
|
|
1121
|
+
snapshot: ReducerSnapshot,
|
|
1122
|
+
): Promise<boolean> {
|
|
1123
|
+
const tail = this.snapshotTail(scope)
|
|
1124
|
+
if (tail === undefined || snapshot.cursor > tail) return false
|
|
1125
|
+
// One statement: the stored cursor never decreases, whatever interleaves.
|
|
1126
|
+
const result = this.sqlite
|
|
1127
|
+
.query(
|
|
1128
|
+
`INSERT INTO reducer_snapshots (scope_kind, scope_key, reducer, version, cursor, state, updated_at)
|
|
1129
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
1130
|
+
ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
|
|
1131
|
+
version = excluded.version, cursor = excluded.cursor,
|
|
1132
|
+
state = excluded.state, updated_at = excluded.updated_at
|
|
1133
|
+
WHERE excluded.cursor > reducer_snapshots.cursor
|
|
1134
|
+
OR (excluded.cursor = reducer_snapshots.cursor AND excluded.version > reducer_snapshots.version)`,
|
|
1135
|
+
)
|
|
1136
|
+
.run(
|
|
1137
|
+
scope.kind,
|
|
1138
|
+
scope.kind === 'build' ? scope.slug : scope.repo,
|
|
1139
|
+
reducer,
|
|
1140
|
+
snapshot.version,
|
|
1141
|
+
snapshot.cursor,
|
|
1142
|
+
JSON.stringify(snapshot.state ?? null),
|
|
1143
|
+
this.clock().toISOString(),
|
|
1144
|
+
)
|
|
1145
|
+
return result.changes > 0
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1041
1148
|
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
1042
1149
|
this.requireRepo(repo)
|
|
1043
1150
|
// Two queries, mirroring the oracle's derivation: the latest
|
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
} from '../events/sessions'
|
|
27
27
|
import { humanActor } from '../events/envelope'
|
|
28
28
|
import { createBuildScopedStore } from './build-scope'
|
|
29
|
+
import {
|
|
30
|
+
type ReducerSnapshot,
|
|
31
|
+
type SnapshotScope,
|
|
32
|
+
snapshotScopeKey,
|
|
33
|
+
snapshotSupersedes,
|
|
34
|
+
} from './snapshots'
|
|
29
35
|
import { validateCreatedEvent } from './new-build'
|
|
30
36
|
import { createSessionScopedStore } from './session-handle'
|
|
31
37
|
import { reduceBuildDigest } from './digest'
|
|
@@ -163,6 +169,8 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
163
169
|
* stream's close against its appends inside this process (AUT-348). */
|
|
164
170
|
private readonly streamLocks = new StreamLocks()
|
|
165
171
|
private readonly sessions = new Map<string, SessionState>()
|
|
172
|
+
/** Reducer snapshots (store/snapshots.ts), keyed by scope and reducer name. */
|
|
173
|
+
private readonly snapshots = new Map<string, ReducerSnapshot>()
|
|
166
174
|
/** `[repo, ticketId, kind, name]` → rows in revision order. A removal is a
|
|
167
175
|
* tombstone row (`removed`, empty manifest) taking the next revision. */
|
|
168
176
|
private readonly ticketAssets = new Map<string, { meta: TicketAssetMeta; removed: boolean }[]>()
|
|
@@ -318,7 +326,22 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
318
326
|
const digests = new Map<string, BuildDigest>()
|
|
319
327
|
for (const slug of slugs) {
|
|
320
328
|
const state = this.builds.get(slug)!
|
|
321
|
-
digests.set(slug, {
|
|
329
|
+
digests.set(slug, {
|
|
330
|
+
slug,
|
|
331
|
+
...reduceBuildDigest(
|
|
332
|
+
state.events.map((event) => ({
|
|
333
|
+
type: event.type,
|
|
334
|
+
seq: event.seq,
|
|
335
|
+
ts: event.ts,
|
|
336
|
+
...(event.type === 'pr-attachment.reclaimed'
|
|
337
|
+
? { hostedSeq: event.payload.hostedSeq }
|
|
338
|
+
: {}),
|
|
339
|
+
...(event.type === 'execution.started' || event.type === 'execution.ended'
|
|
340
|
+
? { instance: event.payload.instance }
|
|
341
|
+
: {}),
|
|
342
|
+
})),
|
|
343
|
+
),
|
|
344
|
+
})
|
|
322
345
|
}
|
|
323
346
|
return digests
|
|
324
347
|
}
|
|
@@ -678,6 +701,50 @@ export class MemoryBuildStore implements BuildStore {
|
|
|
678
701
|
})
|
|
679
702
|
}
|
|
680
703
|
|
|
704
|
+
async getRepoHighWater(repo: string): Promise<number> {
|
|
705
|
+
return this.repoState(repo).events.at(-1)?.seq ?? 0
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** The scope's log tail, or `undefined` for a scope that does not exist. */
|
|
709
|
+
private snapshotTail(scope: SnapshotScope): number | undefined {
|
|
710
|
+
const events =
|
|
711
|
+
scope.kind === 'build'
|
|
712
|
+
? this.builds.get(scope.slug)?.events
|
|
713
|
+
: this.repos.get(scope.repo)?.events
|
|
714
|
+
return events === undefined ? undefined : (events.at(-1)?.seq ?? 0)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async getReducerSnapshot(
|
|
718
|
+
scope: SnapshotScope,
|
|
719
|
+
reducer: string,
|
|
720
|
+
version: number,
|
|
721
|
+
): Promise<ReducerSnapshot | null> {
|
|
722
|
+
const tail = this.snapshotTail(scope)
|
|
723
|
+
if (tail === undefined) return null
|
|
724
|
+
const stored = this.snapshots.get(`${snapshotScopeKey(scope)}\u0000${reducer}`)
|
|
725
|
+
if (stored === undefined || stored.version !== version || stored.cursor > tail) return null
|
|
726
|
+
return { version: stored.version, cursor: stored.cursor, state: structuredClone(stored.state) }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async putReducerSnapshot(
|
|
730
|
+
scope: SnapshotScope,
|
|
731
|
+
reducer: string,
|
|
732
|
+
snapshot: ReducerSnapshot,
|
|
733
|
+
): Promise<boolean> {
|
|
734
|
+
const tail = this.snapshotTail(scope)
|
|
735
|
+
if (tail === undefined || snapshot.cursor > tail) return false
|
|
736
|
+
const key = `${snapshotScopeKey(scope)}\u0000${reducer}`
|
|
737
|
+
if (!snapshotSupersedes(snapshot, this.snapshots.get(key))) return false
|
|
738
|
+
// The JSON round trip is the stored form every adapter shares, so callers
|
|
739
|
+
// cannot alias what the store holds.
|
|
740
|
+
this.snapshots.set(key, {
|
|
741
|
+
version: snapshot.version,
|
|
742
|
+
cursor: snapshot.cursor,
|
|
743
|
+
state: JSON.parse(JSON.stringify(snapshot.state ?? null)),
|
|
744
|
+
})
|
|
745
|
+
return true
|
|
746
|
+
}
|
|
747
|
+
|
|
681
748
|
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
682
749
|
// The oracle runs on the in-memory journal itself, which keeps the pure
|
|
683
750
|
// derivation honest against real adapter data.
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* scope handles in `session-handle.ts` (hosted-only, AUT-339), which scope a
|
|
9
9
|
* store to a single operator session's record.
|
|
10
10
|
*/
|
|
11
|
+
import type { ReducerSnapshot, SnapshotScope } from './snapshots'
|
|
11
12
|
import type { EventType } from '../events/payloads'
|
|
12
13
|
import type { EventWrite } from '../events/catalog'
|
|
13
14
|
import type { RepositoryEventType, RepositoryEventWrite } from '../events/repository'
|
|
@@ -328,6 +329,28 @@ export function scopeLocalStoreToPhaseSession(
|
|
|
328
329
|
own('getRepoStateEvents', 'repo', repo)
|
|
329
330
|
return store.getRepoStateEvents(repo)
|
|
330
331
|
},
|
|
332
|
+
async getRepoHighWater(repo: string): Promise<number> {
|
|
333
|
+
own('getRepoHighWater', 'repo', repo)
|
|
334
|
+
return store.getRepoHighWater(repo)
|
|
335
|
+
},
|
|
336
|
+
async getReducerSnapshot(
|
|
337
|
+
target: SnapshotScope,
|
|
338
|
+
reducer: string,
|
|
339
|
+
version: number,
|
|
340
|
+
): Promise<ReducerSnapshot | null> {
|
|
341
|
+
if (target.kind === 'build') own('getReducerSnapshot', 'build', target.slug)
|
|
342
|
+
else own('getReducerSnapshot', 'repo', target.repo)
|
|
343
|
+
return store.getReducerSnapshot(target, reducer, version)
|
|
344
|
+
},
|
|
345
|
+
async putReducerSnapshot(
|
|
346
|
+
target: SnapshotScope,
|
|
347
|
+
reducer: string,
|
|
348
|
+
snapshot: ReducerSnapshot,
|
|
349
|
+
): Promise<boolean> {
|
|
350
|
+
if (target.kind === 'build') own('putReducerSnapshot', 'build', target.slug)
|
|
351
|
+
else own('putReducerSnapshot', 'repo', target.repo)
|
|
352
|
+
return store.putReducerSnapshot(target, reducer, snapshot)
|
|
353
|
+
},
|
|
331
354
|
async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
332
355
|
own('getRepoBuildDigests', 'repo', repo)
|
|
333
356
|
return store.getRepoBuildDigests(repo)
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { createBuildScopedStore } from '../build-scope'
|
|
47
47
|
import { createSessionScopedStore } from '../session-handle'
|
|
48
48
|
import { pollingSubscribe } from '../subscribe'
|
|
49
|
+
import type { ReducerSnapshot, SnapshotScope } from '../snapshots'
|
|
49
50
|
import type { BuildDigest } from '../types'
|
|
50
51
|
import type {
|
|
51
52
|
StreamChunk,
|
|
@@ -92,8 +93,11 @@ import {
|
|
|
92
93
|
eventEnvelopeWireSchema,
|
|
93
94
|
eventListSchema,
|
|
94
95
|
repositoryEventEnvelopeWireSchema,
|
|
96
|
+
repoHighWaterSchema,
|
|
95
97
|
repositoryEventListSchema,
|
|
96
98
|
okResponseSchema,
|
|
99
|
+
reducerSnapshotPutResponseSchema,
|
|
100
|
+
reducerSnapshotResponseSchema,
|
|
97
101
|
placeholderRev,
|
|
98
102
|
repoDepositsResponseSchema,
|
|
99
103
|
repositoryArtifactGetResponseSchema,
|
|
@@ -193,7 +197,7 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
private async raw(
|
|
196
|
-
method: 'GET' | 'POST',
|
|
200
|
+
method: 'GET' | 'POST' | 'PUT',
|
|
197
201
|
path: string,
|
|
198
202
|
body?: unknown,
|
|
199
203
|
signal?: AbortSignal,
|
|
@@ -535,6 +539,51 @@ export class RemoteBuildStore implements BuildStore {
|
|
|
535
539
|
)
|
|
536
540
|
}
|
|
537
541
|
|
|
542
|
+
private snapshotPath(scope: SnapshotScope, reducer: string): string {
|
|
543
|
+
const root = scope.kind === 'build' ? this.buildPath(scope.slug) : this.repoPath(scope.repo)
|
|
544
|
+
return `${root}/snapshots/${encodeURIComponent(reducer)}`
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async getReducerSnapshot(
|
|
548
|
+
scope: SnapshotScope,
|
|
549
|
+
reducer: string,
|
|
550
|
+
version: number,
|
|
551
|
+
): Promise<ReducerSnapshot | null> {
|
|
552
|
+
// Additive routes, no protocol bump. A store that predates them answers
|
|
553
|
+
// 404 or 405 for the route, which reads as "no snapshot": the caller
|
|
554
|
+
// replays the log. Auth and transport failures still surface.
|
|
555
|
+
const response = await this.raw(
|
|
556
|
+
'GET',
|
|
557
|
+
`${this.snapshotPath(scope, reducer)}?version=${version}`,
|
|
558
|
+
)
|
|
559
|
+
if (response.status === 404 || response.status === 405) return null
|
|
560
|
+
if (!response.ok) throw await this.toError(response)
|
|
561
|
+
return reducerSnapshotResponseSchema.parse(await response.json())
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async putReducerSnapshot(
|
|
565
|
+
scope: SnapshotScope,
|
|
566
|
+
reducer: string,
|
|
567
|
+
snapshot: ReducerSnapshot,
|
|
568
|
+
): Promise<boolean> {
|
|
569
|
+
const response = await this.raw('PUT', this.snapshotPath(scope, reducer), {
|
|
570
|
+
version: snapshot.version,
|
|
571
|
+
cursor: snapshot.cursor,
|
|
572
|
+
state: snapshot.state ?? null,
|
|
573
|
+
})
|
|
574
|
+
if (response.status === 404 || response.status === 405) return false
|
|
575
|
+
if (!response.ok) throw await this.toError(response)
|
|
576
|
+
return reducerSnapshotPutResponseSchema.parse(await response.json()).written
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async getRepoHighWater(repo: string): Promise<number> {
|
|
580
|
+
// Additive route beside `state-events`; the store service and client
|
|
581
|
+
// ship in version lockstep, so no protocol bump.
|
|
582
|
+
return this.requestJson('GET', `${this.repoPath(repo)}/high-water`, repoHighWaterSchema).then(
|
|
583
|
+
(body) => (body as { seq: number }).seq,
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
|
|
538
587
|
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
539
588
|
// The bounded repository-journal read (AUT-489): one additive route next
|
|
540
589
|
// to `GET events`, reusing the same wire shape. No protocol-version bump
|
|
@@ -20,3 +20,5 @@ export { EventValidationError } from '../../events/catalog'
|
|
|
20
20
|
export type { Via } from '../../events/envelope'
|
|
21
21
|
export type { SessionEvent, SessionEventWrite } from '../../events/sessions'
|
|
22
22
|
export type { RepositoryEvent, RepositoryEventWrite } from '../../events/repository'
|
|
23
|
+
export { SNAPSHOT_REDUCER_NAME } from '../snapshots'
|
|
24
|
+
export type { ReducerSnapshot, SnapshotScope } from '../snapshots'
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Arbitrary computation over the assigned revisions inside `makeEvent`
|
|
25
25
|
* (e.g. `rev + 1`, string interpolation) is unsupported by design.
|
|
26
26
|
*/
|
|
27
|
+
import { reducerSnapshotWireSchema } from '../snapshots'
|
|
27
28
|
import { z } from 'zod'
|
|
28
29
|
import { actorSchema } from '../../events/envelope'
|
|
29
30
|
import { ticketRefSchema } from '../../ontology'
|
|
@@ -120,6 +121,18 @@ export const repositoryEventEnvelopeWireSchema = z.object({
|
|
|
120
121
|
})
|
|
121
122
|
export const repositoryEventListSchema = z.array(repositoryEventEnvelopeWireSchema)
|
|
122
123
|
|
|
124
|
+
/** `GET repos/:repo/high-water`: the journal's greatest assigned seq. */
|
|
125
|
+
export const repoHighWaterSchema = z.object({ seq: z.number().int().nonnegative() })
|
|
126
|
+
|
|
127
|
+
/** `GET builds|repos/:id/snapshots/:reducer?version=N`: the snapshot or `null`. */
|
|
128
|
+
/** `PUT …/snapshots/:reducer` request body. */
|
|
129
|
+
export const reducerSnapshotPutBodySchema = reducerSnapshotWireSchema
|
|
130
|
+
|
|
131
|
+
export const reducerSnapshotResponseSchema = reducerSnapshotWireSchema.nullable()
|
|
132
|
+
|
|
133
|
+
/** `PUT builds|repos/:id/snapshots/:reducer`: whether the row was written. */
|
|
134
|
+
export const reducerSnapshotPutResponseSchema = z.object({ written: z.boolean() })
|
|
135
|
+
|
|
123
136
|
export const repositoryArtifactMetaWireSchema = z.object({
|
|
124
137
|
repo: z.string(),
|
|
125
138
|
kind: z.string(),
|
|
@@ -272,6 +285,8 @@ export const buildDigestWireSchema = z.object({
|
|
|
272
285
|
}),
|
|
273
286
|
),
|
|
274
287
|
merged: z.string().min(1).optional(),
|
|
288
|
+
reclaimPending: z.literal(true).optional(),
|
|
289
|
+
executionOpen: z.literal(true).optional(),
|
|
275
290
|
})
|
|
276
291
|
export const buildDigestListSchema = z.array(buildDigestWireSchema)
|
|
277
292
|
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
* re-ordered, and a replay from the start is untouched.
|
|
48
48
|
*/
|
|
49
49
|
import type { RepositoryEvent, RepositoryEventType } from '../events/repository'
|
|
50
|
+
import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
|
|
50
51
|
import {
|
|
51
52
|
dispatcherSettingEventPayloadSchemas,
|
|
52
53
|
dispatcherStatusEventPayloadSchemas,
|
|
@@ -120,6 +121,76 @@ export async function readRepoStateEventsWithAnchorRecheck(
|
|
|
120
121
|
return rechecked === undefined ? events : select(rechecked)
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/** Carried state for the bounded read: the latest run-started seq (the last
|
|
125
|
+
* in array order, as the whole-array pass overwrites it) and every event seen,
|
|
126
|
+
* in array order. Nothing is pruned: a later run-started may carry a lower seq
|
|
127
|
+
* than an earlier one and restore events below the earlier anchor, so only
|
|
128
|
+
* `finish` can apply the filter exactly. Pruning below a monotone anchor is
|
|
129
|
+
* valid only for seq-ordered input and belongs with the cached-state
|
|
130
|
+
* follow-up. */
|
|
131
|
+
export interface RepositoryStateEventsAcc {
|
|
132
|
+
anchor?: number
|
|
133
|
+
retained: RepositoryEvent[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Bump when `RepositoryStateEventsAcc` or its fold changes. */
|
|
137
|
+
export const REPOSITORY_STATE_EVENTS_REDUCER_VERSION = 1
|
|
138
|
+
|
|
139
|
+
const keeps = (event: RepositoryEvent, anchor: number | undefined): boolean =>
|
|
140
|
+
STATE_TYPES.has(event.type) || (anchor !== undefined && event.seq >= anchor)
|
|
141
|
+
|
|
142
|
+
export const repositoryStateEventsReducer: IncrementalReducer<
|
|
143
|
+
RepositoryStateEventsAcc,
|
|
144
|
+
RepositoryEvent,
|
|
145
|
+
RepositoryEvent[]
|
|
146
|
+
> = defineReducer<RepositoryStateEventsAcc, RepositoryEvent, RepositoryEvent[]>({
|
|
147
|
+
version: REPOSITORY_STATE_EVENTS_REDUCER_VERSION,
|
|
148
|
+
initial: () => ({ retained: [] }),
|
|
149
|
+
fold(acc, events) {
|
|
150
|
+
for (const event of events) {
|
|
151
|
+
if (event.type === RUN_STARTED) acc.anchor = event.seq
|
|
152
|
+
acc.retained.push(event)
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
finish: (acc) => acc.retained.filter((event) => keeps(event, acc.anchor)),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/** Carried state for the pruned journal view: the latest run-started seq and
|
|
159
|
+
* the subset the bounded read would return as of the events folded so far. Unlike
|
|
160
|
+
* `RepositoryStateEventsAcc` it prunes on every `dispatcher.run-started`
|
|
161
|
+
* (everything before the new anchor that is not durable leaves the tail), which
|
|
162
|
+
* is exact for seq-ordered input — the only input a real journal produces — and
|
|
163
|
+
* keeps the accumulator bounded, so it is the one a snapshot stores. */
|
|
164
|
+
export interface JournalViewAcc {
|
|
165
|
+
anchor?: number
|
|
166
|
+
retained: RepositoryEvent[]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Bump when `JournalViewAcc` or its fold changes. */
|
|
170
|
+
export const JOURNAL_VIEW_REDUCER_VERSION = 1
|
|
171
|
+
|
|
172
|
+
/** Fold seq-ordered events into a journal view in place. */
|
|
173
|
+
export function foldJournalView(acc: JournalViewAcc, events: readonly RepositoryEvent[]): void {
|
|
174
|
+
for (const event of events) {
|
|
175
|
+
if (event.type === RUN_STARTED) {
|
|
176
|
+
acc.anchor = event.seq
|
|
177
|
+
acc.retained = acc.retained.filter((held) => STATE_TYPES.has(held.type))
|
|
178
|
+
}
|
|
179
|
+
if (STATE_TYPES.has(event.type) || acc.anchor !== undefined) acc.retained.push(event)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const journalViewReducer: IncrementalReducer<
|
|
184
|
+
JournalViewAcc,
|
|
185
|
+
RepositoryEvent,
|
|
186
|
+
RepositoryEvent[]
|
|
187
|
+
> = defineReducer<JournalViewAcc, RepositoryEvent, RepositoryEvent[]>({
|
|
188
|
+
version: JOURNAL_VIEW_REDUCER_VERSION,
|
|
189
|
+
initial: () => ({ retained: [] }),
|
|
190
|
+
fold: foldJournalView,
|
|
191
|
+
finish: (acc) => [...acc.retained],
|
|
192
|
+
})
|
|
193
|
+
|
|
123
194
|
/** The normative subset derivation every adapter implements and the contract
|
|
124
195
|
* tests use as the oracle: durable types across the whole journal, plus —
|
|
125
196
|
* only when the journal has one — every event from the latest
|
|
@@ -130,11 +201,5 @@ export async function readRepoStateEventsWithAnchorRecheck(
|
|
|
130
201
|
export function projectRepositoryStateEvents(
|
|
131
202
|
events: readonly RepositoryEvent[],
|
|
132
203
|
): RepositoryEvent[] {
|
|
133
|
-
|
|
134
|
-
for (const event of events) {
|
|
135
|
-
if (event.type === RUN_STARTED) anchor = event.seq
|
|
136
|
-
}
|
|
137
|
-
return events.filter(
|
|
138
|
-
(event) => STATE_TYPES.has(event.type) || (anchor !== undefined && event.seq >= anchor),
|
|
139
|
-
)
|
|
204
|
+
return repositoryStateEventsReducer.reduce(events)
|
|
140
205
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReducerSnapshot, SnapshotScope } from './snapshots'
|
|
1
2
|
import type { EventType } from '../events/payloads'
|
|
2
3
|
import type { EventWrite } from '../events/catalog'
|
|
3
4
|
import type { RepositoryEventType, RepositoryEventWrite } from '../events/repository'
|
|
@@ -171,6 +172,35 @@ export function createSessionScopedStore(store: BuildStore, scope: string): Sess
|
|
|
171
172
|
getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
172
173
|
return Promise.reject(new SessionScopeError(scope, 'getRepoStateEvents', repo))
|
|
173
174
|
},
|
|
175
|
+
getRepoHighWater(repo: string): Promise<number> {
|
|
176
|
+
return Promise.reject(new SessionScopeError(scope, 'getRepoHighWater', repo))
|
|
177
|
+
},
|
|
178
|
+
getReducerSnapshot(
|
|
179
|
+
target: SnapshotScope,
|
|
180
|
+
_reducer: string,
|
|
181
|
+
_version: number,
|
|
182
|
+
): Promise<ReducerSnapshot | null> {
|
|
183
|
+
return Promise.reject(
|
|
184
|
+
new SessionScopeError(
|
|
185
|
+
scope,
|
|
186
|
+
'getReducerSnapshot',
|
|
187
|
+
target.kind === 'build' ? target.slug : target.repo,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
},
|
|
191
|
+
putReducerSnapshot(
|
|
192
|
+
target: SnapshotScope,
|
|
193
|
+
_reducer: string,
|
|
194
|
+
_snapshot: ReducerSnapshot,
|
|
195
|
+
): Promise<boolean> {
|
|
196
|
+
return Promise.reject(
|
|
197
|
+
new SessionScopeError(
|
|
198
|
+
scope,
|
|
199
|
+
'putReducerSnapshot',
|
|
200
|
+
target.kind === 'build' ? target.slug : target.repo,
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
},
|
|
174
204
|
getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
175
205
|
return Promise.reject(new SessionScopeError(scope, 'getRepoBuildDigests', repo))
|
|
176
206
|
},
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* history; unknown facts cannot exist.
|
|
7
7
|
*/
|
|
8
8
|
import type { SessionEvent } from '../events/sessions'
|
|
9
|
+
import { defineReducer, type IncrementalReducer } from '../kernel/incremental'
|
|
9
10
|
|
|
10
11
|
export type SessionStatus = 'idle' | 'running' | 'suspended' | 'awaiting-approval' | 'archived'
|
|
11
12
|
export type SessionSuspensionCause = 'budget' | 'approval'
|
|
@@ -62,17 +63,28 @@ interface TurnEntry extends SessionTurn {
|
|
|
62
63
|
const isTerminal = (state: SessionTurn['state']): boolean =>
|
|
63
64
|
state === 'completed' || state === 'failed'
|
|
64
65
|
|
|
65
|
-
/**
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
66
|
+
/** The carried accumulator: the reduced fields plus the per-turn suspension
|
|
67
|
+
* cause the public state drops. Once `archived`, the fold ignores every later
|
|
68
|
+
* event, so an advance after archive stays frozen. */
|
|
69
|
+
export interface SessionAcc {
|
|
70
|
+
turns: TurnEntry[]
|
|
71
|
+
archived: boolean
|
|
72
|
+
pendingApproval: SessionState['pendingApproval']
|
|
73
|
+
wakeGlobs: string[]
|
|
74
|
+
wakeCursors: Record<string, number>
|
|
75
|
+
journalWakeCursor: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Bump when `SessionAcc`'s shape or fold semantics change. */
|
|
79
|
+
export const SESSION_REDUCER_VERSION = 1
|
|
74
80
|
|
|
75
|
-
|
|
81
|
+
function foldSession(acc: SessionAcc, events: readonly SessionEvent[]): void {
|
|
82
|
+
const turns = acc.turns
|
|
83
|
+
let archived = acc.archived
|
|
84
|
+
let pendingApproval = acc.pendingApproval
|
|
85
|
+
let wakeGlobs = acc.wakeGlobs
|
|
86
|
+
const wakeCursors = acc.wakeCursors
|
|
87
|
+
let journalWakeCursor = acc.journalWakeCursor
|
|
76
88
|
|
|
77
89
|
for (const event of events) {
|
|
78
90
|
if (archived) break
|
|
@@ -166,7 +178,15 @@ export function reduceSession(events: SessionEvent[]): SessionState {
|
|
|
166
178
|
}
|
|
167
179
|
}
|
|
168
180
|
|
|
169
|
-
|
|
181
|
+
acc.archived = archived
|
|
182
|
+
acc.pendingApproval = pendingApproval
|
|
183
|
+
acc.wakeGlobs = wakeGlobs
|
|
184
|
+
acc.journalWakeCursor = journalWakeCursor
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function finishSession(acc: SessionAcc): SessionState {
|
|
188
|
+
const { turns, archived, pendingApproval, wakeGlobs, wakeCursors, journalWakeCursor } = acc
|
|
189
|
+
const openEntry = turns.findLast((turn) => !isTerminal(turn.state))
|
|
170
190
|
// Status precedence: archived > awaiting-approval > suspended > running >
|
|
171
191
|
// idle. An unanswered approval outranks its turn's suspension; the answer
|
|
172
192
|
// that clears it hands status back to the turn.
|
|
@@ -202,3 +222,24 @@ export function reduceSession(events: SessionEvent[]): SessionState {
|
|
|
202
222
|
turns: turns.map(({ suspensionCause: _suspensionCause, ...turn }) => turn),
|
|
203
223
|
}
|
|
204
224
|
}
|
|
225
|
+
|
|
226
|
+
export const sessionReducer: IncrementalReducer<SessionAcc, SessionEvent, SessionState> =
|
|
227
|
+
defineReducer({
|
|
228
|
+
version: SESSION_REDUCER_VERSION,
|
|
229
|
+
initial: (): SessionAcc => ({
|
|
230
|
+
turns: [],
|
|
231
|
+
archived: false,
|
|
232
|
+
pendingApproval: undefined,
|
|
233
|
+
wakeGlobs: [],
|
|
234
|
+
wakeCursors: {},
|
|
235
|
+
journalWakeCursor: 0,
|
|
236
|
+
}),
|
|
237
|
+
fold: foldSession,
|
|
238
|
+
finish: finishSession,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
/** Reduce a session's events into its derived state. `session.archived` is
|
|
242
|
+
* terminal: every fact after it is ignored — the archived state is frozen. */
|
|
243
|
+
export function reduceSession(events: SessionEvent[]): SessionState {
|
|
244
|
+
return sessionReducer.reduce(events)
|
|
245
|
+
}
|