@defrex/autobuild 0.13.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.
Files changed (94) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +19 -1
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +96 -70
  5. package/docs/hosted-service.md +2 -2
  6. package/docs/operator-api.md +1 -1
  7. package/docs/remote-store-protocol.md +84 -2
  8. package/package.json +4 -3
  9. package/packages/core/src/cli/attention.ts +18 -164
  10. package/packages/core/src/cli/build-control.ts +11 -15
  11. package/packages/core/src/cli/bulk-control.ts +3 -2
  12. package/packages/core/src/cli/context.ts +16 -78
  13. package/packages/core/src/cli/dashboard/model.ts +36 -22
  14. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  15. package/packages/core/src/cli/dispatch-process.ts +14 -7
  16. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  17. package/packages/core/src/cli/dispatch.ts +148 -79
  18. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  19. package/packages/core/src/cli/harvest.ts +71 -39
  20. package/packages/core/src/cli/historical-build-state.ts +27 -0
  21. package/packages/core/src/cli/main.ts +3 -3
  22. package/packages/core/src/cli/pr-attachments.ts +10 -13
  23. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  24. package/packages/core/src/cli/pr-summary.ts +43 -13
  25. package/packages/core/src/cli/publication-settlement.ts +10 -20
  26. package/packages/core/src/cli/repository-status.ts +38 -2
  27. package/packages/core/src/cli/status.ts +62 -32
  28. package/packages/core/src/cli/terminals.ts +25 -142
  29. package/packages/core/src/cli/ticket.ts +2 -2
  30. package/packages/core/src/cli/wait.ts +91 -84
  31. package/packages/core/src/cli/watch.ts +60 -97
  32. package/packages/core/src/config/pipeline-source.ts +4 -3
  33. package/packages/core/src/events/repository.ts +7 -3
  34. package/packages/core/src/integration/harness.ts +1 -2
  35. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2429 -9
  36. package/packages/core/src/kernel/auto-merge.ts +19 -3
  37. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  38. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  39. package/packages/core/src/kernel/harvest.ts +25 -23
  40. package/packages/core/src/kernel/log-index-value.ts +38 -0
  41. package/packages/core/src/kernel/read-facts.ts +637 -0
  42. package/packages/core/src/kernel/reducer-registry.ts +137 -3
  43. package/packages/core/src/kernel/repository-attention.ts +179 -0
  44. package/packages/core/src/kernel/service-facts.ts +135 -0
  45. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  46. package/packages/core/src/operator/control.ts +5 -6
  47. package/packages/core/src/operator/index.ts +2 -0
  48. package/packages/core/src/operator/query.ts +37 -20
  49. package/packages/core/src/operator/read-view.ts +4 -13
  50. package/packages/core/src/operator/requests.ts +2 -14
  51. package/packages/core/src/operator/sandbox.ts +10 -9
  52. package/packages/core/src/operator/tickets.ts +2 -2
  53. package/packages/core/src/plugin-sdk/index.ts +13 -0
  54. package/packages/core/src/processes/build-child.ts +6 -6
  55. package/packages/core/src/processes/build-runner.ts +62 -70
  56. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  57. package/packages/core/src/processes/dispatcher.ts +113 -173
  58. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  59. package/packages/core/src/processes/harvest-runner.ts +34 -70
  60. package/packages/core/src/processes/harvest.ts +38 -15
  61. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  62. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  63. package/packages/core/src/processes/publication-loss.ts +35 -21
  64. package/packages/core/src/processes/repo-view.ts +229 -1134
  65. package/packages/core/src/processes/tick-publication.ts +110 -0
  66. package/packages/core/src/store/adapter.ts +23 -0
  67. package/packages/core/src/store/build-scope.ts +17 -0
  68. package/packages/core/src/store/contract.ts +251 -0
  69. package/packages/core/src/store/local/schema.ts +1 -0
  70. package/packages/core/src/store/local/store.ts +179 -54
  71. package/packages/core/src/store/memory.ts +119 -29
  72. package/packages/core/src/store/phase-session.ts +17 -0
  73. package/packages/core/src/store/reduced-reads.ts +239 -0
  74. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  75. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  76. package/packages/core/src/store/reducer-values.ts +145 -0
  77. package/packages/core/src/store/remote/client.ts +41 -0
  78. package/packages/core/src/store/remote/compatibility.ts +2 -0
  79. package/packages/core/src/store/remote/index.ts +19 -0
  80. package/packages/core/src/store/remote/protocol.ts +16 -0
  81. package/packages/core/src/store/remote/version.ts +3 -1
  82. package/packages/core/src/store/repo-state-events.ts +1 -1
  83. package/packages/core/src/store/repo-tick.ts +72 -0
  84. package/packages/core/src/store/session-handle.ts +19 -0
  85. package/packages/core/src/store/snapshots.ts +10 -0
  86. package/packages/core/src/store/ticket-assets.ts +4 -4
  87. package/packages/core/src/store/types.ts +39 -2
  88. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  89. package/packages/core/src/testing/index.ts +8 -0
  90. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  91. package/skills/guide/SKILL.md +6 -1
  92. package/skills/guide/references/operator-api.md +1 -1
  93. package/skills/guide/references/remote-store-protocol.md +84 -2
  94. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,13 @@
1
+ import { readPersistedBuildDigests } from '../reduced-reads'
2
+ import { assertPublicSnapshotReducer } from '../snapshots'
3
+ import {
4
+ readReducerValue,
5
+ resolveReducerValue,
6
+ type ReducerValueRequest,
7
+ type ReducerValue,
8
+ } from '../reducer-values'
9
+ import { validateTickFinish } from '../repo-tick'
10
+ import type { RepoTickCompletion, RepoTickFinish, RepoTickObservation } from '../types'
1
11
  /**
2
12
  * The local BuildStore (SPEC §7.2.1): SQLite + blob directory beneath an
3
13
  * explicitly resolved local state root. Repository/default selection belongs
@@ -43,7 +53,6 @@ import { createBuildScopedStore } from '../build-scope'
43
53
  import type { ReducerSnapshot, SnapshotScope } from '../snapshots'
44
54
  import { validateCreatedEvent } from '../new-build'
45
55
  import { createSessionScopedStore } from '../session-handle'
46
- import { DIGEST_EVENT_TYPES, type DigestEventRow, reduceBuildDigest } from '../digest'
47
56
  import {
48
57
  REPOSITORY_STATE_EVENT_TYPES,
49
58
  REPOSITORY_LATEST_EVENT_TYPES,
@@ -191,7 +200,8 @@ export const BOOTSTRAP_DDL = [
191
200
  lease_holder TEXT,
192
201
  lease_expires_at TEXT,
193
202
  lease_ttl_ms INTEGER,
194
- heartbeat_at TEXT
203
+ heartbeat_at TEXT,
204
+ tick TEXT
195
205
  )`,
196
206
  `CREATE TABLE IF NOT EXISTS repo_events (
197
207
  repo TEXT NOT NULL,
@@ -337,6 +347,11 @@ export class SqliteBuildStore implements BuildStore {
337
347
  // Stores created before `repo_origin` existed keep working: add the column
338
348
  // idempotently when a pre-existing table lacks it (the bootstrap DDL above
339
349
  // covers fresh stores). No migration framework — one guarded ALTER at open.
350
+ const repoColumns = this.sqlite.query("PRAGMA table_info('repo_streams')").all() as {
351
+ name: string
352
+ }[]
353
+ if (!repoColumns.some((column) => column.name === 'tick'))
354
+ this.sqlite.exec('ALTER TABLE repo_streams ADD COLUMN tick TEXT')
340
355
  const columns = this.sqlite.query("PRAGMA table_info('builds')").all() as Array<{
341
356
  name: string
342
357
  }>
@@ -435,6 +450,7 @@ export class SqliteBuildStore implements BuildStore {
435
450
 
436
451
  private toRepoRecord(row: RepoRow): RepositoryRecord {
437
452
  return {
453
+ ...(row.tick ? { tick: structuredClone(row.tick) } : {}),
438
454
  repo: row.repo,
439
455
  createdAt: row.createdAt,
440
456
  updatedAt: row.updatedAt,
@@ -496,56 +512,16 @@ export class SqliteBuildStore implements BuildStore {
496
512
  }
497
513
 
498
514
  async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
499
- // From the builds side (AUT-487): the type filter lives in the ON clause
500
- // so a build whose log holds none of the digest-relevant event
501
- // types still yields its row — a join that starts from `events` would
502
- // silently drop such builds and break the operation's completeness
503
- // contract. Only the digest-relevant types are fetched, then one shared
504
- // derivation
505
- // per build, so the answer cannot drift from `reduceBuild`.
506
- const rows = this.sqlite
507
- .query(
508
- `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
509
- CASE WHEN e.type = 'pr-attachment.reclaimed'
510
- THEN json_extract(e.payload, '$.hostedSeq') END AS hostedSeq,
511
- CASE WHEN e.type IN ('execution.started', 'execution.ended')
512
- THEN json_extract(e.payload, '$.instance') END AS instance
513
- FROM builds b
514
- LEFT JOIN events e
515
- ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map(() => '?').join(', ')})
516
- WHERE b.repo = ?
517
- ORDER BY b.slug, e.seq`,
518
- )
519
- .all(...DIGEST_EVENT_TYPES, repo) as {
520
- slug: string
521
- seq: number | null
522
- ts: string | null
523
- type: string | null
524
- hostedSeq: number | null
525
- instance: string | null
526
- }[]
527
- const eventsByBuild = new Map<string, DigestEventRow[]>()
528
- for (const row of rows) {
529
- if (row.seq === null || row.ts === null || row.type === null) continue
530
- const events = eventsByBuild.get(row.slug) ?? []
531
- events.push({
532
- type: row.type as AbEvent['type'],
533
- seq: row.seq,
534
- ts: row.ts,
535
- ...(row.hostedSeq !== null ? { hostedSeq: row.hostedSeq } : {}),
536
- ...(row.instance !== null ? { instance: row.instance } : {}),
537
- })
538
- eventsByBuild.set(row.slug, events)
539
- }
540
- const digests = new Map<string, BuildDigest>()
541
- for (const row of rows) {
542
- if (digests.has(row.slug)) continue
543
- digests.set(row.slug, {
544
- slug: row.slug,
545
- ...reduceBuildDigest(eventsByBuild.get(row.slug) ?? []),
546
- })
547
- }
548
- return digests
515
+ return readPersistedBuildDigests(
516
+ this,
517
+ repo,
518
+ this.db
519
+ .select()
520
+ .from(builds)
521
+ .where(eq(builds.repo, repo))
522
+ .all()
523
+ .map((row) => this.toRecord(row)),
524
+ )
549
525
  }
550
526
 
551
527
  /** Current build-stream tail inside an open transaction. */
@@ -922,9 +898,12 @@ export class SqliteBuildStore implements BuildStore {
922
898
  return row ? this.toRepoRecord(row) : null
923
899
  }
924
900
 
925
- private appendRepoInTx(repo: string, validated: RepositoryEventWrite): RepositoryEventEnvelope {
901
+ private appendRepoInTx(
902
+ repo: string,
903
+ validated: RepositoryEventWrite,
904
+ ts = this.now(),
905
+ ): RepositoryEventEnvelope {
926
906
  this.requireRepo(repo)
927
- const ts = this.now()
928
907
  const row = this.db
929
908
  .select({ max: sql<number | null>`max(${repoEvents.seq})` })
930
909
  .from(repoEvents)
@@ -1092,11 +1071,91 @@ export class SqliteBuildStore implements BuildStore {
1092
1071
  return row?.tail
1093
1072
  }
1094
1073
 
1074
+ async getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue> {
1075
+ // Reject invalid requests before touching storage.
1076
+ resolveReducerValue(scope, request)
1077
+ return readReducerValue(scope, request, {
1078
+ highWater: async () => {
1079
+ const tail = this.snapshotTail(scope)
1080
+ if (tail === undefined) throw new Error('unknown reducer scope')
1081
+ return tail
1082
+ },
1083
+ events: async (since, through) => {
1084
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1085
+ const rows = this.sqlite
1086
+ .query(
1087
+ scope.kind === 'build'
1088
+ ? 'SELECT * FROM events WHERE build=?1 AND seq>?2 AND seq<=?3 ORDER BY seq'
1089
+ : 'SELECT * FROM repo_events WHERE repo=?1 AND seq>?2 AND seq<=?3 ORDER BY seq',
1090
+ )
1091
+ .all(key, since, through) as {
1092
+ build?: string
1093
+ repo?: string
1094
+ seq: number
1095
+ ts: string
1096
+ actor: string
1097
+ type: string
1098
+ payload: string
1099
+ }[]
1100
+ return rows.map((row) => ({
1101
+ ...row,
1102
+ actor: JSON.parse(row.actor),
1103
+ payload: JSON.parse(row.payload),
1104
+ })) as (AbEvent | RepositoryEvent)[]
1105
+ },
1106
+ put: (key, snapshot) => this.putSnapshot(scope, key, snapshot),
1107
+ read: async (key) => {
1108
+ const row = this.sqlite
1109
+ .query(
1110
+ 'SELECT version, cursor, state FROM reducer_snapshots WHERE scope_kind=?1 AND scope_key=?2 AND reducer=?3',
1111
+ )
1112
+ .get(scope.kind, scope.kind === 'build' ? scope.slug : scope.repo, key) as {
1113
+ version: number
1114
+ cursor: number
1115
+ state: string
1116
+ } | null
1117
+ if (!row) return null
1118
+ let state: unknown = null
1119
+ try {
1120
+ state = JSON.parse(row.state)
1121
+ } catch {
1122
+ /* malformed cache */
1123
+ }
1124
+ return { snapshot: { version: row.version, cursor: row.cursor, state }, token: row.state }
1125
+ },
1126
+ repair: async (key, observed, snapshot) => {
1127
+ return this.sqlite.transaction(() => {
1128
+ const tail = this.snapshotTail(scope)
1129
+ if (tail === undefined || snapshot.cursor > tail) return false
1130
+ return (
1131
+ this.sqlite
1132
+ .query(
1133
+ `UPDATE reducer_snapshots SET version=?1, cursor=?2, state=?3, updated_at=?4 WHERE scope_kind=?5 AND scope_key=?6 AND reducer=?7 AND version=?8 AND cursor=?9 AND state=?10`,
1134
+ )
1135
+ .run(
1136
+ snapshot.version,
1137
+ snapshot.cursor,
1138
+ JSON.stringify(snapshot.state),
1139
+ this.clock().toISOString(),
1140
+ scope.kind,
1141
+ scope.kind === 'build' ? scope.slug : scope.repo,
1142
+ key,
1143
+ observed.snapshot.version,
1144
+ observed.snapshot.cursor,
1145
+ observed.token,
1146
+ ).changes > 0
1147
+ )
1148
+ })()
1149
+ },
1150
+ })
1151
+ }
1152
+
1095
1153
  async getReducerSnapshot(
1096
1154
  scope: SnapshotScope,
1097
1155
  reducer: string,
1098
1156
  version: number,
1099
1157
  ): Promise<ReducerSnapshot | null> {
1158
+ assertPublicSnapshotReducer(reducer)
1100
1159
  const tail = this.snapshotTail(scope)
1101
1160
  if (tail === undefined) return null
1102
1161
  const row = this.sqlite
@@ -1120,6 +1179,15 @@ export class SqliteBuildStore implements BuildStore {
1120
1179
  scope: SnapshotScope,
1121
1180
  reducer: string,
1122
1181
  snapshot: ReducerSnapshot,
1182
+ ): Promise<boolean> {
1183
+ assertPublicSnapshotReducer(reducer)
1184
+ return this.putSnapshot(scope, reducer, snapshot)
1185
+ }
1186
+
1187
+ private async putSnapshot(
1188
+ scope: SnapshotScope,
1189
+ reducer: string,
1190
+ snapshot: ReducerSnapshot,
1123
1191
  ): Promise<boolean> {
1124
1192
  const tail = this.snapshotTail(scope)
1125
1193
  if (tail === undefined || snapshot.cursor > tail) return false
@@ -1420,6 +1488,63 @@ export class SqliteBuildStore implements BuildStore {
1420
1488
  })
1421
1489
  }
1422
1490
 
1491
+ async beginRepoTick(repo: string, holder: string): Promise<string | null> {
1492
+ return this.writeTx(() => {
1493
+ const row = this.requireRepo(repo)
1494
+ if (
1495
+ row.leaseHolder !== holder ||
1496
+ !row.leaseExpiresAt ||
1497
+ Date.parse(row.leaseExpiresAt) <= this.clock().getTime()
1498
+ )
1499
+ return null
1500
+ const startedAt = this.now()
1501
+ const tick: RepoTickObservation = {
1502
+ run: holder,
1503
+ open: { startedAt },
1504
+ ...(row.tick?.finished ? { finished: row.tick.finished } : {}),
1505
+ }
1506
+ this.db
1507
+ .update(repoStreams)
1508
+ .set({ tick, updatedAt: startedAt })
1509
+ .where(eq(repoStreams.repo, repo))
1510
+ .run()
1511
+ return startedAt
1512
+ })
1513
+ }
1514
+
1515
+ async finishRepoTick(
1516
+ repo: string,
1517
+ holder: string,
1518
+ completion: RepoTickCompletion,
1519
+ events: RepositoryEventWrite[],
1520
+ ): Promise<RepoTickFinish | null> {
1521
+ const validated = validateTickFinish(holder, completion, events)
1522
+ return this.writeTx(() => {
1523
+ const row = this.requireRepo(repo)
1524
+ if (
1525
+ row.leaseHolder !== holder ||
1526
+ !row.leaseExpiresAt ||
1527
+ Date.parse(row.leaseExpiresAt) <= this.clock().getTime()
1528
+ )
1529
+ return null
1530
+ if (row.tick?.run !== holder || !row.tick.open) throw new Error('no open repository tick')
1531
+ const completedAt = this.now()
1532
+ const envelopes = validated.map((event) =>
1533
+ this.appendRepoInTx(repo, event, completedAt),
1534
+ ) as RepositoryEvent[]
1535
+ const observation: RepoTickObservation = {
1536
+ run: holder,
1537
+ finished: { ...completion, run: holder, startedAt: row.tick.open.startedAt, completedAt },
1538
+ }
1539
+ this.db
1540
+ .update(repoStreams)
1541
+ .set({ tick: observation, updatedAt: completedAt })
1542
+ .where(eq(repoStreams.repo, repo))
1543
+ .run()
1544
+ return { observation, events: envelopes }
1545
+ })
1546
+ }
1547
+
1423
1548
  async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
1424
1549
  return this.writeTx(() => {
1425
1550
  const row = this.requireRepo(repo)
@@ -1,3 +1,13 @@
1
+ import { readPersistedBuildDigests } from './reduced-reads'
2
+ import { assertPublicSnapshotReducer } from './snapshots'
3
+ import {
4
+ readReducerValue,
5
+ resolveReducerValue,
6
+ type ReducerValueRequest,
7
+ type ReducerValue,
8
+ } from './reducer-values'
9
+ import { validateTickFinish } from './repo-tick'
10
+ import type { RepoTickCompletion, RepoTickFinish, RepoTickObservation } from './types'
1
11
  /**
2
12
  * The in-memory BuildStore — the reference adapter. Its behavior *is* the
3
13
  * contract: the suite in `store/contract.ts` is written against this and
@@ -34,7 +44,6 @@ import {
34
44
  } from './snapshots'
35
45
  import { validateCreatedEvent } from './new-build'
36
46
  import { createSessionScopedStore } from './session-handle'
37
- import { reduceBuildDigest } from './digest'
38
47
  import { projectRepositoryStateEvents } from './repo-state-events'
39
48
  import {
40
49
  DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
@@ -121,6 +130,7 @@ interface Lease {
121
130
  }
122
131
 
123
132
  interface RepoState {
133
+ tick?: RepoTickObservation
124
134
  record: {
125
135
  repo: string
126
136
  createdAt: string
@@ -316,34 +326,7 @@ export class MemoryBuildStore implements BuildStore {
316
326
  }
317
327
 
318
328
  async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
319
- // Insertion order is creation order (the listSessions tiebreak note); the
320
- // digest map is returned in slug order so every adapter answers the same
321
- // shape for the same store contents.
322
- const slugs = [...this.builds.entries()]
323
- .filter(([, state]) => state.record.repo === repo)
324
- .map(([slug]) => slug)
325
- .sort()
326
- const digests = new Map<string, BuildDigest>()
327
- for (const slug of slugs) {
328
- const state = this.builds.get(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
- })
345
- }
346
- return digests
329
+ return readPersistedBuildDigests(this, repo)
347
330
  }
348
331
 
349
332
  async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
@@ -563,6 +546,7 @@ export class MemoryBuildStore implements BuildStore {
563
546
 
564
547
  private repoSnapshot(state: RepoState): RepositoryRecord {
565
548
  return {
549
+ ...(state.tick ? { tick: structuredClone(state.tick) } : {}),
566
550
  repo: state.record.repo,
567
551
  createdAt: state.record.createdAt,
568
552
  updatedAt: state.record.updatedAt,
@@ -714,11 +698,54 @@ export class MemoryBuildStore implements BuildStore {
714
698
  return events === undefined ? undefined : (events.at(-1)?.seq ?? 0)
715
699
  }
716
700
 
701
+ async getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue> {
702
+ // Reject invalid requests before touching storage.
703
+ resolveReducerValue(scope, request)
704
+ return readReducerValue(scope, request, {
705
+ highWater: async () => {
706
+ const tail = this.snapshotTail(scope)
707
+ if (tail === undefined) throw new Error('unknown reducer scope')
708
+ return tail
709
+ },
710
+ events: async (since, through) =>
711
+ structuredClone(
712
+ (scope.kind === 'build'
713
+ ? this.state(scope.slug).events
714
+ : this.repoState(scope.repo).events
715
+ ).slice(since, through),
716
+ ) as (AbEvent | RepositoryEvent)[],
717
+ put: (key, snapshot) => this.putSnapshot(scope, key, snapshot),
718
+ read: async (key) => {
719
+ const snapshot = this.snapshots.get(`${snapshotScopeKey(scope)}\u0000${key}`)
720
+ return snapshot === undefined
721
+ ? null
722
+ : { snapshot: structuredClone(snapshot), token: JSON.stringify(snapshot) }
723
+ },
724
+ repair: async (key, observed, snapshot) => {
725
+ const cacheKey = `${snapshotScopeKey(scope)}\u0000${key}`
726
+ const current = this.snapshots.get(cacheKey)
727
+ const tail = this.snapshotTail(scope)
728
+ if (
729
+ !current ||
730
+ JSON.stringify(current) !== observed.token ||
731
+ tail === undefined ||
732
+ snapshot.cursor > tail
733
+ )
734
+ return false
735
+ if (observed.snapshot.cursor > snapshot.cursor && tail >= observed.snapshot.cursor)
736
+ return false
737
+ this.snapshots.set(cacheKey, JSON.parse(JSON.stringify(snapshot)))
738
+ return true
739
+ },
740
+ })
741
+ }
742
+
717
743
  async getReducerSnapshot(
718
744
  scope: SnapshotScope,
719
745
  reducer: string,
720
746
  version: number,
721
747
  ): Promise<ReducerSnapshot | null> {
748
+ assertPublicSnapshotReducer(reducer)
722
749
  const tail = this.snapshotTail(scope)
723
750
  if (tail === undefined) return null
724
751
  const stored = this.snapshots.get(`${snapshotScopeKey(scope)}\u0000${reducer}`)
@@ -730,6 +757,15 @@ export class MemoryBuildStore implements BuildStore {
730
757
  scope: SnapshotScope,
731
758
  reducer: string,
732
759
  snapshot: ReducerSnapshot,
760
+ ): Promise<boolean> {
761
+ assertPublicSnapshotReducer(reducer)
762
+ return this.putSnapshot(scope, reducer, snapshot)
763
+ }
764
+
765
+ private async putSnapshot(
766
+ scope: SnapshotScope,
767
+ reducer: string,
768
+ snapshot: ReducerSnapshot,
733
769
  ): Promise<boolean> {
734
770
  const tail = this.snapshotTail(scope)
735
771
  if (tail === undefined || snapshot.cursor > tail) return false
@@ -896,6 +932,60 @@ export class MemoryBuildStore implements BuildStore {
896
932
  return structuredClone(latest.meta)
897
933
  }
898
934
 
935
+ async beginRepoTick(repo: string, holder: string): Promise<string | null> {
936
+ const state = this.repoState(repo)
937
+ if (
938
+ !state.lease ||
939
+ state.lease.holder !== holder ||
940
+ state.lease.expiresAt <= this.clock().getTime()
941
+ )
942
+ return null
943
+ const startedAt = this.now()
944
+ state.tick = {
945
+ run: holder,
946
+ open: { startedAt },
947
+ ...(state.tick?.finished ? { finished: state.tick.finished } : {}),
948
+ }
949
+ state.record.updatedAt = startedAt
950
+ return startedAt
951
+ }
952
+
953
+ async finishRepoTick(
954
+ repo: string,
955
+ holder: string,
956
+ completion: RepoTickCompletion,
957
+ events: RepositoryEventWrite[],
958
+ ): Promise<RepoTickFinish | null> {
959
+ const validated = validateTickFinish(holder, completion, events)
960
+ const state = this.repoState(repo)
961
+ if (
962
+ !state.lease ||
963
+ state.lease.holder !== holder ||
964
+ state.lease.expiresAt <= this.clock().getTime()
965
+ )
966
+ return null
967
+ if (state.tick?.run !== holder || !state.tick.open) throw new Error('no open repository tick')
968
+ const completedAt = this.now()
969
+ const envelopes = validated.map((event, index) => ({
970
+ ...event,
971
+ repo,
972
+ seq: state.events.length + index + 1,
973
+ ts: completedAt,
974
+ })) as RepositoryEvent[]
975
+ state.tick = {
976
+ run: holder,
977
+ finished: {
978
+ ...structuredClone(completion),
979
+ run: holder,
980
+ startedAt: state.tick.open.startedAt,
981
+ completedAt,
982
+ },
983
+ }
984
+ state.events.push(...structuredClone(envelopes))
985
+ state.record.updatedAt = completedAt
986
+ return structuredClone({ observation: state.tick, events: envelopes })
987
+ }
988
+
899
989
  async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
900
990
  const state = this.repoState(repo)
901
991
  const now = this.clock().getTime()
@@ -1,3 +1,4 @@
1
+ import type { ReducerValueRequest, ReducerValue } from './reducer-values'
1
2
  /**
2
3
  * Local ambient phase-session wrapper for a BuildStore.
3
4
  *
@@ -333,6 +334,14 @@ export function scopeLocalStoreToPhaseSession(
333
334
  own('getRepoHighWater', 'repo', repo)
334
335
  return store.getRepoHighWater(repo)
335
336
  },
337
+ async getReducerValue(
338
+ target: SnapshotScope,
339
+ request: ReducerValueRequest,
340
+ ): Promise<ReducerValue> {
341
+ if (target.kind === 'build') own('getReducerValue', 'build', target.slug)
342
+ else own('getReducerValue', 'repo', target.repo)
343
+ return store.getReducerValue(target, request)
344
+ },
336
345
  async getReducerSnapshot(
337
346
  target: SnapshotScope,
338
347
  reducer: string,
@@ -423,6 +432,14 @@ export function scopeLocalStoreToPhaseSession(
423
432
  own('claimRepoLease', 'repo', repo)
424
433
  return store.claimRepoLease(repo, holder, ttlMs)
425
434
  },
435
+ async beginRepoTick(repo: string) {
436
+ own('beginRepoTick', 'repo', repo)
437
+ throw new Error('phase sessions cannot begin repository ticks')
438
+ },
439
+ async finishRepoTick(repo: string) {
440
+ own('finishRepoTick', 'repo', repo)
441
+ throw new Error('phase sessions cannot finish repository ticks')
442
+ },
426
443
  async heartbeatRepo(repo: string, holder: string): Promise<boolean> {
427
444
  own('heartbeatRepo', 'repo', repo)
428
445
  return store.heartbeatRepo(repo, holder)