@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -13,6 +13,9 @@
13
13
  import { describe, expect, test } from 'bun:test'
14
14
  import { EventValidationError, type EventWrite } from '../events/catalog'
15
15
  import { agentActor, DISPATCHER, humanActor, KERNEL, type Via } from '../events/envelope'
16
+ import { reduceBuild } from '../kernel/reducer'
17
+ import { reduceDispatchSettings } from '../kernel/dispatch-settings'
18
+ import { projectRepositoryStateEvents } from './repo-state-events'
16
19
  import type { RepositoryEventWrite } from '../events/repository'
17
20
  import type { SessionEventWrite } from '../events/sessions'
18
21
  import { manualClock } from '../testing/fixed'
@@ -320,6 +323,65 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
320
323
  })
321
324
  })
322
325
 
326
+ test('createBuild appends an initial build.created as seq 1, atomically with the record', async () => {
327
+ await withStore(factory, undefined, async (store) => {
328
+ const created = buildCreatedWrite()
329
+ await store.createBuild({ ...sampleBuildInput('with-created'), created })
330
+ const events = await store.getEvents('with-created')
331
+ expect(events.map((event) => [event.seq, event.type])).toEqual([[1, 'build.created']])
332
+ expect(events[0]?.payload).toEqual(created.payload)
333
+ expect(events[0]?.actor).toEqual(created.actor)
334
+ // The next append continues the stream.
335
+ const next = await store.append('with-created', {
336
+ actor: DISPATCHER,
337
+ type: 'build.created',
338
+ payload: created.payload,
339
+ })
340
+ expect(next.seq).toBe(2)
341
+ })
342
+ })
343
+
344
+ test('createBuild rejects an invalid or non-build.created initial event and creates nothing', async () => {
345
+ await withStore(factory, undefined, async (store) => {
346
+ await expect(
347
+ store.createBuild({
348
+ ...sampleBuildInput('bad-created'),
349
+ created: { actor: DISPATCHER, type: 'build.created', payload: { nope: 1 } } as never,
350
+ }),
351
+ ).rejects.toThrow()
352
+ await expect(
353
+ store.createBuild({
354
+ ...sampleBuildInput('wrong-type'),
355
+ created: { actor: DISPATCHER, type: 'build.completed', payload: {} } as never,
356
+ }),
357
+ ).rejects.toThrow()
358
+ expect(await store.getBuild('bad-created')).toBeNull()
359
+ expect(await store.getBuild('wrong-type')).toBeNull()
360
+ // The slug is still free, so a valid create succeeds.
361
+ await store.createBuild({
362
+ ...sampleBuildInput('bad-created'),
363
+ created: buildCreatedWrite(),
364
+ })
365
+ expect((await store.getEvents('bad-created')).length).toBe(1)
366
+ })
367
+ })
368
+
369
+ test('a duplicate slug with an initial event leaves the first build untouched', async () => {
370
+ await withStore(factory, undefined, async (store) => {
371
+ await store.createBuild({
372
+ ...sampleBuildInput('dupe-created'),
373
+ created: buildCreatedWrite(),
374
+ })
375
+ await expect(
376
+ store.createBuild({
377
+ ...sampleBuildInput('dupe-created'),
378
+ created: buildCreatedWrite(),
379
+ }),
380
+ ).rejects.toThrow()
381
+ expect((await store.getEvents('dupe-created')).length).toBe(1)
382
+ })
383
+ })
384
+
323
385
  test('getBuild returns null for an unknown slug', async () => {
324
386
  await withStore(factory, undefined, async (store) => {
325
387
  expect(await store.getBuild('never-created')).toBeNull()
@@ -336,6 +398,229 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
336
398
  })
337
399
  })
338
400
 
401
+ describe('build digests (repo-scoped batch read, AUT-487)', () => {
402
+ test('an unknown repo and a journal-less repo answer build-less maps without writes', async () => {
403
+ await withStore(factory, undefined, async (store) => {
404
+ expect(await store.getRepoBuildDigests('acme/never-seen')).toEqual(new Map())
405
+ // The operation is keyed to build records, not the repository
406
+ // journal record: builds created without `ensureRepo` still digest
407
+ // (the remote route answers before the repo-existence gate).
408
+ await store.createBuild(sampleBuildInput('dg-no-journal', { repo: 'acme/no-journal' }))
409
+ const digests = await store.getRepoBuildDigests('acme/no-journal')
410
+ expect([...digests.keys()]).toEqual(['dg-no-journal'])
411
+ expect(digests.get('dg-no-journal')).toEqual({
412
+ slug: 'dg-no-journal',
413
+ observations: [],
414
+ })
415
+ // Read-only: no journal record was implicitly created.
416
+ expect(await store.getRepo('acme/no-journal')).toBeNull()
417
+ expect(await store.getRepo('acme/never-seen')).toBeNull()
418
+ })
419
+ })
420
+ test('one entry per repo build — including a build with no digest-relevant events — and only that repo', async () => {
421
+ await withStore(factory, undefined, async (store) => {
422
+ await store.createBuild(sampleBuildInput('dg-a', { repo: 'acme/dga' }))
423
+ await store.createBuild(sampleBuildInput('dg-other', { repo: 'acme/dgb' }))
424
+ // A freshly created queued build whose log holds none of the
425
+ // digest-relevant event types keeps its contractual entry — a join
426
+ // that started from the events side would silently drop it.
427
+ const digests = await store.getRepoBuildDigests('acme/dga')
428
+ expect([...digests.keys()]).toEqual(['dg-a'])
429
+ expect(digests.get('dg-a')).toEqual({ slug: 'dg-a', observations: [] })
430
+ const other = await store.getRepoBuildDigests('acme/dgb')
431
+ expect([...other.keys()]).toEqual(['dg-other'])
432
+ })
433
+ })
434
+
435
+ test('the digest equals the full-log ground truth over every lifecycle shape', async () => {
436
+ await withStore(factory, undefined, async (store) => {
437
+ const repo = 'acme/dg-life'
438
+ // Open build: an observation, no terminal fact.
439
+ await store.createBuild(sampleBuildInput('dg-open', { repo }))
440
+ await store.append('dg-open', buildCreatedWrite())
441
+ await store.append('dg-open', sampleEventWrite('open observation'))
442
+
443
+ // Done build: observation after completion stays an occurrence.
444
+ await store.createBuild(sampleBuildInput('dg-done', { repo }))
445
+ await store.append('dg-done', buildCreatedWrite())
446
+ await store.append('dg-done', {
447
+ actor: DISPATCHER,
448
+ type: 'build.completed',
449
+ payload: { outcome: 'merged' },
450
+ })
451
+ await store.append('dg-done', sampleEventWrite('after completion'))
452
+
453
+ // Cleaning build (mid-abort): renders a row, no terminal completion.
454
+ await store.createBuild(sampleBuildInput('dg-cleaning', { repo }))
455
+ await store.append('dg-cleaning', buildCreatedWrite())
456
+ await store.append('dg-cleaning', {
457
+ actor: KERNEL,
458
+ type: 'build.aborted',
459
+ payload: {},
460
+ })
461
+ await store.append('dg-cleaning', sampleEventWrite('during cleanup'))
462
+
463
+ // Abort saga terminated in completion: latest terminal fact wins.
464
+ await store.createBuild(sampleBuildInput('dg-abandoned', { repo }))
465
+ await store.append('dg-abandoned', buildCreatedWrite())
466
+ await store.append('dg-abandoned', {
467
+ actor: KERNEL,
468
+ type: 'build.aborted',
469
+ payload: {},
470
+ })
471
+ await store.append('dg-abandoned', {
472
+ actor: DISPATCHER,
473
+ type: 'build.completed',
474
+ payload: { outcome: 'abandoned' },
475
+ })
476
+
477
+ // The reducer's exact terminal rule, pinned: done → aborted → done.
478
+ await store.createBuild(sampleBuildInput('dg-overwrite', { repo }))
479
+ await store.append('dg-overwrite', buildCreatedWrite())
480
+ await store.append('dg-overwrite', {
481
+ actor: DISPATCHER,
482
+ type: 'build.completed',
483
+ payload: { outcome: 'merged' },
484
+ })
485
+ await store.append('dg-overwrite', {
486
+ actor: KERNEL,
487
+ type: 'build.aborted',
488
+ payload: {},
489
+ })
490
+ await store.append('dg-overwrite', {
491
+ actor: DISPATCHER,
492
+ type: 'build.completed',
493
+ payload: { outcome: 'merged' },
494
+ })
495
+
496
+ const digests = await store.getRepoBuildDigests(repo)
497
+ const expectedStatuses: Record<string, string> = {}
498
+ const expectedObservations: Record<string, { seq: number; ts: string }[]> = {}
499
+ const expectedMerged: Record<string, string | undefined> = {}
500
+ for (const record of await store.listBuilds()) {
501
+ if (record.repo !== repo) continue
502
+ const events = await store.getEvents(record.slug)
503
+ expectedStatuses[record.slug] = reduceBuild(events).status
504
+ expectedObservations[record.slug] = events
505
+ .filter((event) => event.type === 'observation.recorded')
506
+ .map((event) => ({ seq: event.seq, ts: event.ts }))
507
+ const merges = events
508
+ .filter((event) => event.type === 'pr.merged')
509
+ .map((event) => event.ts)
510
+ expectedMerged[record.slug] = merges[merges.length - 1]
511
+ }
512
+ expect([...digests.keys()].sort()).toEqual(Object.keys(expectedStatuses).sort())
513
+ for (const [slug, digest] of digests) {
514
+ // Terminal presence and kind follow the reducer's status exactly.
515
+ const status = expectedStatuses[slug]!
516
+ expect(digest.terminal).toBe(
517
+ status === 'done' ? 'done' : status === 'aborted' ? 'aborted' : undefined,
518
+ )
519
+ expect(digest.observations).toEqual(expectedObservations[slug] ?? [])
520
+ // The merge fact is the log's latest `pr.merged` ts, absent when
521
+ // the log has none.
522
+ if (expectedMerged[slug] === undefined) expect(digest.merged).toBeUndefined()
523
+ else expect(digest.merged).toBe(expectedMerged[slug])
524
+ }
525
+ // Explicit shape pins (independent of the loop above); the ts
526
+ // values come from the same ground-truth events.
527
+ const tsOf = (slug: string, seq: number): string =>
528
+ expectedObservations[slug]!.find((item) => item.seq === seq)!.ts
529
+ expect(digests.get('dg-open')).toEqual({
530
+ slug: 'dg-open',
531
+ observations: [{ seq: 2, ts: tsOf('dg-open', 2) }],
532
+ })
533
+ expect(digests.get('dg-done')).toEqual({
534
+ slug: 'dg-done',
535
+ terminal: 'done',
536
+ observations: [{ seq: 3, ts: tsOf('dg-done', 3) }],
537
+ })
538
+ expect(digests.get('dg-cleaning')).toEqual({
539
+ slug: 'dg-cleaning',
540
+ terminal: 'aborted',
541
+ observations: [{ seq: 3, ts: tsOf('dg-cleaning', 3) }],
542
+ })
543
+ expect(digests.get('dg-abandoned')).toEqual({
544
+ slug: 'dg-abandoned',
545
+ terminal: 'done',
546
+ observations: [],
547
+ })
548
+ expect(digests.get('dg-overwrite')).toEqual({
549
+ slug: 'dg-overwrite',
550
+ terminal: 'done',
551
+ observations: [],
552
+ })
553
+ })
554
+ })
555
+
556
+ test('digests reflect appends through every write path without a refresh step', async () => {
557
+ await withStore(factory, undefined, async (store) => {
558
+ const repo = 'acme/dg-paths'
559
+ await store.createBuild(sampleBuildInput('dg-paths', { repo }))
560
+ expect((await store.getRepoBuildDigests(repo)).get('dg-paths')?.observations).toEqual([])
561
+
562
+ await store.append('dg-paths', sampleEventWrite('first'))
563
+ let digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
564
+ expect(digest?.observations).toEqual([
565
+ { seq: 1, ts: (await store.getEvents('dg-paths'))[0]!.ts },
566
+ ])
567
+ expect(digest?.terminal).toBeUndefined()
568
+
569
+ const conditional = await store.appendIfCurrent('dg-paths', 1, sampleEventWrite('second'))
570
+ expect(conditional).not.toBeNull()
571
+ digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
572
+ expect(digest?.observations.map((item) => item.seq)).toEqual([1, 2])
573
+
574
+ await store.appendWithArtifacts('dg-paths', [{ kind: 'dg-notes', content: 'note' }], () =>
575
+ sampleEventWrite('third'),
576
+ )
577
+ digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
578
+ expect(digest?.observations).toEqual([
579
+ { seq: 1, ts: (await store.getEvents('dg-paths'))[0]!.ts },
580
+ { seq: 2, ts: (await store.getEvents('dg-paths'))[1]!.ts },
581
+ { seq: 3, ts: (await store.getEvents('dg-paths'))[2]!.ts },
582
+ ])
583
+ expect(digest?.merged).toBeUndefined()
584
+
585
+ // The merge fact reflects appends through every write path too,
586
+ // keeping the latest `pr.merged` ts.
587
+ const merge = async (sha: string) =>
588
+ (
589
+ await store.append('dg-paths', {
590
+ actor: DISPATCHER,
591
+ type: 'pr.merged',
592
+ payload: { sha },
593
+ })
594
+ ).ts
595
+ const firstMergeTs = await merge('abc111')
596
+ digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
597
+ expect(digest?.merged).toBe(firstMergeTs)
598
+
599
+ const secondMergeTs = await merge('abc222')
600
+ digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
601
+ expect(digest?.merged).toBe(secondMergeTs)
602
+ expect(secondMergeTs >= firstMergeTs).toBe(true)
603
+
604
+ await store.append('dg-paths', {
605
+ actor: DISPATCHER,
606
+ type: 'build.completed',
607
+ payload: { outcome: 'merged' },
608
+ })
609
+ digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
610
+ expect(digest).toEqual({
611
+ slug: 'dg-paths',
612
+ terminal: 'done',
613
+ merged: secondMergeTs,
614
+ observations: [
615
+ { seq: 1, ts: (await store.getEvents('dg-paths'))[0]!.ts },
616
+ { seq: 2, ts: (await store.getEvents('dg-paths'))[1]!.ts },
617
+ { seq: 3, ts: (await store.getEvents('dg-paths'))[2]!.ts },
618
+ ],
619
+ })
620
+ })
621
+ })
622
+ })
623
+
339
624
  describe('repository journal (workflow and control paper trail)', () => {
340
625
  test('ensureRepo is idempotent and repo event seq is independent', async () => {
341
626
  const clock = manualClock(CONTRACT_T0)
@@ -518,6 +803,168 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
518
803
  })
519
804
  })
520
805
 
806
+ describe('getRepoStateEvents (bounded journal read, AUT-489)', () => {
807
+ /** One no-op hosted dispatcher invocation: the ~4 facts an invocation
808
+ * appends whether or not it did anything. */
809
+ function noopInvocation(run: string): RepositoryEventWrite[] {
810
+ return [
811
+ {
812
+ actor: DISPATCHER,
813
+ type: 'dispatcher.run-started',
814
+ payload: {
815
+ run,
816
+ pid: 1,
817
+ effectiveConfig: { kind: 'effective-config', rev: 0 },
818
+ roleWarnings: [],
819
+ },
820
+ },
821
+ { actor: DISPATCHER, type: 'dispatcher.tick-started', payload: { run } },
822
+ {
823
+ actor: DISPATCHER,
824
+ type: 'dispatcher.tick-completed',
825
+ payload: {
826
+ run,
827
+ queued: 0,
828
+ counters: noopTickCounters(),
829
+ janitorDiagnostics: [],
830
+ ticketDiagnostics: [],
831
+ dependencyDiagnostics: [],
832
+ },
833
+ },
834
+ {
835
+ actor: DISPATCHER,
836
+ type: 'dispatcher.run-stopped',
837
+ payload: { run, outcome: 'normal' },
838
+ },
839
+ ]
840
+ }
841
+
842
+ function noopTickCounters() {
843
+ return {
844
+ merged: 0,
845
+ closed: 0,
846
+ conflicted: 0,
847
+ abandoned: 0,
848
+ discarded: 0,
849
+ janitorFailed: 0,
850
+ recovered: 0,
851
+ dispatchFailed: 0,
852
+ resumed: 0,
853
+ swept: 0,
854
+ dispatched: 0,
855
+ authored: 0,
856
+ bounced: 0,
857
+ claimRaces: 0,
858
+ invalidTickets: 0,
859
+ dependencyBlocked: 0,
860
+ harvestStarted: 0,
861
+ harvestResumed: 0,
862
+ harvestCompleted: 0,
863
+ harvestEscalated: 0,
864
+ harvestFailed: 0,
865
+ }
866
+ }
867
+
868
+ test('unknown repo rejects like getRepoEvents', async () => {
869
+ await withStore(factory, undefined, async (store) => {
870
+ await expect(store.getRepoStateEvents('acme/never-seen')).rejects.toThrow(/unknown repo/)
871
+ })
872
+ })
873
+
874
+ test('an empty journal answers []', async () => {
875
+ await withStore(factory, undefined, async (store) => {
876
+ await store.ensureRepo('acme/empty')
877
+ expect(await store.getRepoStateEvents('acme/empty')).toEqual([])
878
+ })
879
+ })
880
+
881
+ test('a journal with durable events but no run-started anchor answers durable types only', async () => {
882
+ await withStore(factory, undefined, async (store) => {
883
+ await store.ensureRepo('acme/no-anchor')
884
+ for (const write of [
885
+ { actor: humanActor('op'), type: 'dispatcher.intake-set', payload: { enabled: false } },
886
+ { actor: KERNEL, type: 'harvest.started', payload: harvestStartedWrite('h1').payload },
887
+ // Run-scoped fact with no anchor anywhere in the journal: if the
888
+ // read degenerated into a whole-journal select, this would leak
889
+ // into the result.
890
+ { actor: DISPATCHER, type: 'dispatcher.tick-started', payload: { run: 'r1' } },
891
+ ] as RepositoryEventWrite[]) {
892
+ await store.appendRepo('acme/no-anchor', write)
893
+ }
894
+ const subset = await store.getRepoStateEvents('acme/no-anchor')
895
+ expect(subset.map((event) => event.type)).toEqual([
896
+ 'dispatcher.intake-set',
897
+ 'harvest.started',
898
+ ])
899
+ })
900
+ })
901
+
902
+ test('a seeded journal answers exactly the oracle over the full replay', async () => {
903
+ await withStore(factory, undefined, async (store) => {
904
+ await store.ensureRepo('acme/state')
905
+ for (const write of [
906
+ ...noopInvocation('r1'),
907
+ { actor: humanActor('op'), type: 'dispatcher.intake-set', payload: { enabled: false } },
908
+ { actor: KERNEL, type: 'harvest.started', payload: harvestStartedWrite('h1').payload },
909
+ {
910
+ actor: humanActor('op'),
911
+ type: 'orchestrator.sandbox.provisioned',
912
+ payload: { operator: 'op', environmentId: 'env', provider: 'p', workspacePath: '/w' },
913
+ },
914
+ ...noopInvocation('r2'),
915
+ {
916
+ actor: humanActor('op'),
917
+ type: 'dispatcher.operator-reported',
918
+ payload: { run: 'r2', level: 'warning', message: 'm' },
919
+ },
920
+ ] as RepositoryEventWrite[]) {
921
+ await store.appendRepo('acme/state', write)
922
+ }
923
+ const subset = await store.getRepoStateEvents('acme/state')
924
+ const full = await store.getRepoEvents('acme/state')
925
+ expect(subset).toEqual(projectRepositoryStateEvents(full))
926
+ // The oracle is also the reducer-equivalence guarantee: latest-run
927
+ // facts survive (criterion 3 — a failed/warning invocation stays
928
+ // visible), and durable facts from before the anchor survive.
929
+ expect(subset.map((event) => event.type)).toContain('dispatcher.operator-reported')
930
+ expect(subset.map((event) => event.type)).toContain('harvest.started')
931
+ expect(subset.filter((event) => event.type === 'dispatcher.run-started')).toHaveLength(1)
932
+ })
933
+ })
934
+
935
+ test('the subset is bounded by the invocation count, not by journal length', async () => {
936
+ await withStore(factory, undefined, async (store) => {
937
+ await store.ensureRepo('acme/bounded')
938
+ for (let index = 0; index < 25; index += 1) {
939
+ for (const write of noopInvocation(`r_${index}`)) {
940
+ await store.appendRepo('acme/bounded', write)
941
+ }
942
+ }
943
+ const subset = await store.getRepoStateEvents('acme/bounded')
944
+ // Exactly the latest invocation's four facts: nothing durable was
945
+ // ever written, so the durable part of the subset is empty.
946
+ expect(subset).toHaveLength(4)
947
+ })
948
+ })
949
+
950
+ test('settings written after the latest anchor are present', async () => {
951
+ await withStore(factory, undefined, async (store) => {
952
+ await store.ensureRepo('acme/settings-tail')
953
+ for (const write of noopInvocation('r1')) {
954
+ await store.appendRepo('acme/settings-tail', write)
955
+ }
956
+ await store.appendRepo('acme/settings-tail', {
957
+ actor: humanActor('op'),
958
+ type: 'dispatcher.pause-set',
959
+ payload: { enabled: true },
960
+ })
961
+ const subset = await store.getRepoStateEvents('acme/settings-tail')
962
+ expect(subset.map((event) => event.type)).toContain('dispatcher.pause-set')
963
+ expect(reduceDispatchSettings(subset).paused).toBe(true)
964
+ })
965
+ })
966
+ })
967
+
521
968
  describe('append', () => {
522
969
  test('assigns seq 1..n per build, independently across builds (§15.1)', async () => {
523
970
  await withStore(factory, undefined, async (store) => {
@@ -1361,6 +1808,352 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
1361
1808
  })
1362
1809
  })
1363
1810
 
1811
+ describe('ticket assets (SPEC §7.1)', () => {
1812
+ const REPO = 'https://github.com/acme/rate-limiter'
1813
+ const enc = (text: string) => new TextEncoder().encode(text)
1814
+ const fileAsset = (name: string, content: Uint8Array, kind = 'design') => ({
1815
+ kind,
1816
+ name,
1817
+ layout: 'file' as const,
1818
+ entries: [{ type: 'file' as const, path: name, content }],
1819
+ })
1820
+ const treeAsset = (
1821
+ name: string,
1822
+ entries: { type: 'file'; path: string; content: Uint8Array }[] | never[] = [],
1823
+ extra: { type: 'dir'; path: string }[] = [],
1824
+ kind = 'design',
1825
+ ) => ({
1826
+ kind,
1827
+ name,
1828
+ layout: 'tree' as const,
1829
+ entries: [...entries, ...extra],
1830
+ })
1831
+
1832
+ test('stores a single file and returns its exact bytes, binary and zero-byte included', async () => {
1833
+ await withStore(factory, undefined, async (store) => {
1834
+ const binary = Uint8Array.from([0, 255, 1, 254, 128, 0])
1835
+ const meta = await store.putTicketAsset(REPO, 'T-1', fileAsset('shot.png', binary))
1836
+ expect(meta).toMatchObject({
1837
+ repo: REPO,
1838
+ ticketId: 'T-1',
1839
+ kind: 'design',
1840
+ name: 'shot.png',
1841
+ revision: 0,
1842
+ layout: 'file',
1843
+ size: 6,
1844
+ })
1845
+ expect(meta.entries).toEqual([
1846
+ { type: 'file', path: 'shot.png', size: 6, blobRef: contentHash(binary) },
1847
+ ])
1848
+ const got = await store.getTicketAsset(REPO, 'T-1', 'design', 'shot.png')
1849
+ expect(got?.meta).toEqual(meta)
1850
+ expect(got?.entries).toEqual([{ type: 'file', path: 'shot.png', content: binary }])
1851
+
1852
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('empty.bin', new Uint8Array(0)))
1853
+ const empty = await store.getTicketAsset(REPO, 'T-1', 'design', 'empty.bin')
1854
+ expect(empty?.entries).toEqual([
1855
+ { type: 'file', path: 'empty.bin', content: new Uint8Array(0) },
1856
+ ])
1857
+ expect(empty?.meta.size).toBe(0)
1858
+ })
1859
+ })
1860
+
1861
+ test('stores trees: nested files, an empty tree, and nested empty directories', async () => {
1862
+ await withStore(factory, undefined, async (store) => {
1863
+ await store.putTicketAsset(
1864
+ REPO,
1865
+ 'T-1',
1866
+ treeAsset(
1867
+ 'site',
1868
+ [
1869
+ { type: 'file', path: 'index.html', content: enc('<h1>hi</h1>') },
1870
+ { type: 'file', path: 'img/a.png', content: Uint8Array.from([1, 2, 3]) },
1871
+ ],
1872
+ [{ type: 'dir', path: 'img/empty' }],
1873
+ ),
1874
+ )
1875
+ await store.putTicketAsset(REPO, 'T-1', treeAsset('hollow'))
1876
+ const site = await store.getTicketAsset(REPO, 'T-1', 'design', 'site')
1877
+ expect(site?.meta.layout).toBe('tree')
1878
+ expect(site?.meta.size).toBe(14)
1879
+ expect(site?.meta.entries.map((e) => `${e.type}:${e.path}`)).toEqual([
1880
+ 'file:index.html',
1881
+ 'file:img/a.png',
1882
+ 'dir:img/empty',
1883
+ ])
1884
+ expect(site?.entries).toEqual([
1885
+ { type: 'file', path: 'index.html', content: enc('<h1>hi</h1>') },
1886
+ { type: 'file', path: 'img/a.png', content: Uint8Array.from([1, 2, 3]) },
1887
+ { type: 'dir', path: 'img/empty' },
1888
+ ])
1889
+ const hollow = await store.getTicketAsset(REPO, 'T-1', 'design', 'hollow')
1890
+ expect(hollow?.meta).toMatchObject({ layout: 'tree', size: 0, entries: [] })
1891
+ expect(hollow?.entries).toEqual([])
1892
+
1893
+ const list = await store.listTicketAssets(REPO, 'T-1')
1894
+ expect(list.map((a) => [a.name, a.fileCount, a.dirCount, a.size])).toEqual([
1895
+ ['hollow', 0, 0, 0],
1896
+ ['site', 2, 1, 14],
1897
+ ])
1898
+ expect(list.every((a) => !('entries' in a))).toBe(true)
1899
+ })
1900
+ })
1901
+
1902
+ test('re-attaching the same kind and name creates a new revision; old ones stay', async () => {
1903
+ await withStore(factory, undefined, async (store) => {
1904
+ expect(
1905
+ (await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('one')))).revision,
1906
+ ).toBe(0)
1907
+ expect(
1908
+ (await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('two!')))).revision,
1909
+ ).toBe(1)
1910
+ const latest = await store.getTicketAsset(REPO, 'T-1', 'design', 'a')
1911
+ expect(latest?.meta.revision).toBe(1)
1912
+ expect(latest?.entries[0]).toMatchObject({ content: enc('two!') })
1913
+ const first = await store.getTicketAsset(REPO, 'T-1', 'design', 'a', 0)
1914
+ expect(first?.entries[0]).toMatchObject({ content: enc('one') })
1915
+ expect(await store.getTicketAsset(REPO, 'T-1', 'design', 'a', 2)).toBeNull()
1916
+
1917
+ const current = await store.listTicketAssets(REPO, 'T-1')
1918
+ expect(current.map((a) => [a.name, a.revision, a.size])).toEqual([['a', 1, 4]])
1919
+ const all = await store.listTicketAssets(REPO, 'T-1', { revisions: true })
1920
+ expect(all.map((a) => a.revision)).toEqual([0, 1])
1921
+ })
1922
+ })
1923
+
1924
+ test('lists are isolated by repo, ticket id, kind and name', async () => {
1925
+ await withStore(factory, undefined, async (store) => {
1926
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('x')))
1927
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('y'), 'ref'))
1928
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('b', enc('z')))
1929
+ await store.putTicketAsset(REPO, 'T-2', fileAsset('a', enc('other')))
1930
+ await store.putTicketAsset('/other/repo', 'T-1', fileAsset('a', enc('other')))
1931
+ expect(
1932
+ (await store.listTicketAssets(REPO, 'T-1')).map((a) => `${a.kind}/${a.name}`),
1933
+ ).toEqual(['design/a', 'design/b', 'ref/a'])
1934
+ expect((await store.listTicketAssets(REPO, 'T-2')).map((a) => a.name)).toEqual(['a'])
1935
+ expect(await store.listTicketAssets(REPO, 'T-3')).toEqual([])
1936
+ expect(await store.getTicketAsset(REPO, 'T-1', 'ref', 'b')).toBeNull()
1937
+ expect(await store.getTicketAsset('/other/repo', 'T-2', 'design', 'a')).toBeNull()
1938
+ // Revision counters are per (repo, ticket, kind, name).
1939
+ expect((await store.putTicketAsset(REPO, 'T-2', fileAsset('b', enc('1')))).revision).toBe(
1940
+ 0,
1941
+ )
1942
+ })
1943
+ })
1944
+
1945
+ test('removal hides the asset but earlier revisions stay readable by explicit rev', async () => {
1946
+ await withStore(factory, undefined, async (store) => {
1947
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('one')))
1948
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('two')))
1949
+ const removed = await store.removeTicketAsset(REPO, 'T-1', 'design', 'a')
1950
+ expect(removed).toMatchObject({ kind: 'design', name: 'a', revision: 1 })
1951
+ expect(await store.getTicketAsset(REPO, 'T-1', 'design', 'a')).toBeNull()
1952
+ expect(await store.listTicketAssets(REPO, 'T-1')).toEqual([])
1953
+ const pinned = await store.getTicketAsset(REPO, 'T-1', 'design', 'a', 0)
1954
+ expect(pinned?.entries[0]).toMatchObject({ content: enc('one') })
1955
+ const pinnedLatest = await store.getTicketAsset(REPO, 'T-1', 'design', 'a', 1)
1956
+ expect(pinnedLatest?.entries[0]).toMatchObject({ content: enc('two') })
1957
+ // Tombstones are not revisions to list.
1958
+ expect(
1959
+ (await store.listTicketAssets(REPO, 'T-1', { revisions: true })).map((a) => a.revision),
1960
+ ).toEqual([0, 1])
1961
+ // Removing again, or removing an unknown asset, is a null no-op.
1962
+ expect(await store.removeTicketAsset(REPO, 'T-1', 'design', 'a')).toBeNull()
1963
+ expect(await store.removeTicketAsset(REPO, 'T-1', 'design', 'ghost')).toBeNull()
1964
+
1965
+ // Re-attaching takes the next revision; the tombstone consumed 2.
1966
+ const again = await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('three')))
1967
+ expect(again.revision).toBe(3)
1968
+ expect((await store.listTicketAssets(REPO, 'T-1')).map((a) => a.revision)).toEqual([3])
1969
+ expect((await store.getTicketAsset(REPO, 'T-1', 'design', 'a'))?.meta.revision).toBe(3)
1970
+ expect(await store.getTicketAsset(REPO, 'T-1', 'design', 'a', 2)).toBeNull()
1971
+ })
1972
+ })
1973
+
1974
+ test('advertises limits and refuses an over-limit put, naming the limit, storing nothing', async () => {
1975
+ await withStore(factory, undefined, async (store) => {
1976
+ const limits = await store.ticketAssetLimits(REPO)
1977
+ expect(limits.maxEntries).toBe(1000)
1978
+ expect(limits.maxBytes).toBeGreaterThan(0)
1979
+ expect(limits.maxBytes).toBeLessThanOrEqual(25 * 1024 * 1024)
1980
+ const tooBig = fileAsset('big.bin', new Uint8Array(limits.maxBytes + 1))
1981
+ await expect(store.putTicketAsset(REPO, 'T-1', tooBig)).rejects.toThrow(
1982
+ /ticket asset exceeds .*\d+-byte.*limit/,
1983
+ )
1984
+ expect(await store.listTicketAssets(REPO, 'T-1', { revisions: true })).toEqual([])
1985
+ expect(await store.getTicketAsset(REPO, 'T-1', 'design', 'big.bin')).toBeNull()
1986
+ })
1987
+ })
1988
+
1989
+ test('rejects invalid names, paths and structures, storing nothing', async () => {
1990
+ await withStore(factory, undefined, async (store) => {
1991
+ const f = (path: string) => ({ type: 'file' as const, path, content: enc('x') })
1992
+ const bad = [
1993
+ { ...fileAsset('a', enc('x')), name: 'has space' },
1994
+ { ...fileAsset('a', enc('x')), kind: '' },
1995
+ treeAsset('t', [f('../escape')]),
1996
+ treeAsset('t', [f('/abs')]),
1997
+ treeAsset('t', [f('a//b')]),
1998
+ treeAsset('t', [f('a'), f('a')]),
1999
+ treeAsset('t', [f('a'), f('a/b')]),
2000
+ treeAsset('t', [f('a')], [{ type: 'dir', path: 'a' }]),
2001
+ { ...fileAsset('a', enc('x')), entries: [] },
2002
+ {
2003
+ kind: 'design',
2004
+ name: 'a',
2005
+ layout: 'file' as const,
2006
+ entries: [f('a'), { type: 'dir' as const, path: 'd' }],
2007
+ },
2008
+ ]
2009
+ for (const asset of bad) {
2010
+ await expect(store.putTicketAsset(REPO, 'T-1', asset)).rejects.toThrow()
2011
+ }
2012
+ expect(await store.listTicketAssets(REPO, 'T-1', { revisions: true })).toEqual([])
2013
+ })
2014
+ })
2015
+
2016
+ test('a failed put does not consume a revision', async () => {
2017
+ await withStore(factory, undefined, async (store) => {
2018
+ await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('ok')))
2019
+ await expect(
2020
+ store.putTicketAsset(REPO, 'T-1', { ...fileAsset('a', enc('x')), name: 'a b' }),
2021
+ ).rejects.toThrow()
2022
+ expect(
2023
+ (await store.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('ok2')))).revision,
2024
+ ).toBe(1)
2025
+ })
2026
+ })
2027
+
2028
+ test('scoped handles reject ticket-asset operations', async () => {
2029
+ await withStore(factory, undefined, async (store) => {
2030
+ await store.createBuild(sampleBuildInput('ta-scope'))
2031
+ const scoped = store.scopeBuild('ta-scope')
2032
+ await expect(
2033
+ scoped.putTicketAsset(REPO, 'T-1', fileAsset('a', enc('x'))),
2034
+ ).rejects.toThrow(/build-scoped store/)
2035
+ await expect(scoped.getTicketAsset(REPO, 'T-1', 'design', 'a')).rejects.toThrow(
2036
+ /build-scoped store/,
2037
+ )
2038
+ await expect(scoped.listTicketAssets(REPO, 'T-1')).rejects.toThrow(/build-scoped store/)
2039
+ await expect(scoped.removeTicketAsset(REPO, 'T-1', 'design', 'a')).rejects.toThrow(
2040
+ /build-scoped store/,
2041
+ )
2042
+ await expect(scoped.ticketAssetLimits(REPO)).rejects.toThrow(/build-scoped store/)
2043
+ })
2044
+ })
2045
+ })
2046
+
2047
+ describe('pinned ticket assets (SPEC §6.3)', () => {
2048
+ const REPO = 'https://github.com/acme/rate-limiter'
2049
+ const enc = (text: string) => new TextEncoder().encode(text)
2050
+ const file = (name: string, text: string, kind = 'design') => ({
2051
+ kind,
2052
+ name,
2053
+ layout: 'file' as const,
2054
+ entries: [{ type: 'file' as const, path: name, content: enc(text) }],
2055
+ })
2056
+ const pinOf = (meta: { kind: string; name: string; revision: number; size: number }) => ({
2057
+ kind: meta.kind,
2058
+ name: meta.name,
2059
+ revision: meta.revision,
2060
+ layout: 'file' as const,
2061
+ size: meta.size,
2062
+ fileCount: 1,
2063
+ dirCount: 0,
2064
+ })
2065
+ const text = (asset: { entries: { type: string; content?: Uint8Array }[] } | null) =>
2066
+ asset === null ? null : new TextDecoder().decode(asset.entries[0]?.content)
2067
+
2068
+ async function pinnedBuild(
2069
+ store: BuildStore,
2070
+ slug: string,
2071
+ assets: ReturnType<typeof pinOf>[],
2072
+ ): Promise<void> {
2073
+ const created = buildCreatedWrite()
2074
+ await store.createBuild({
2075
+ ...sampleBuildInput(slug),
2076
+ created: { ...created, payload: { ...created.payload, repo: REPO, assets } },
2077
+ })
2078
+ }
2079
+
2080
+ test('reads the pinned revision after the ticket replaces and then removes the asset', async () => {
2081
+ await withStore(factory, undefined, async (store) => {
2082
+ const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
2083
+ await pinnedBuild(store, 'pin-a', [pinOf(v0)])
2084
+ await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'two'))
2085
+ expect(text(await store.getPinnedTicketAsset('pin-a', 'design', 'a.txt'))).toBe('one')
2086
+ await store.removeTicketAsset(REPO, 'TICK-1', 'design', 'a.txt')
2087
+ expect(await store.getTicketAsset(REPO, 'TICK-1', 'design', 'a.txt')).toBeNull()
2088
+ const pinned = await store.getPinnedTicketAsset('pin-a', 'design', 'a.txt')
2089
+ expect(text(pinned)).toBe('one')
2090
+ expect(pinned?.meta.revision).toBe(0)
2091
+ })
2092
+ })
2093
+
2094
+ test('answers null for what the build never pinned, and for an unpinned revision', async () => {
2095
+ await withStore(factory, undefined, async (store) => {
2096
+ const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
2097
+ await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'two'))
2098
+ await store.putTicketAsset(REPO, 'TICK-1', file('late.txt', 'late'))
2099
+ await pinnedBuild(store, 'pin-b', [pinOf(v0)])
2100
+ expect(await store.getPinnedTicketAsset('pin-b', 'design', 'late.txt')).toBeNull()
2101
+ expect(await store.getPinnedTicketAsset('pin-b', 'other-kind', 'a.txt')).toBeNull()
2102
+ expect(await store.getPinnedTicketAsset('pin-b', 'design', 'a.txt', 1)).toBeNull()
2103
+ expect(text(await store.getPinnedTicketAsset('pin-b', 'design', 'a.txt', 0))).toBe('one')
2104
+ })
2105
+ })
2106
+
2107
+ test('a build with no pin and an unknown build', async () => {
2108
+ await withStore(factory, undefined, async (store) => {
2109
+ await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
2110
+ await store.createBuild({ ...sampleBuildInput('pin-none'), created: buildCreatedWrite() })
2111
+ expect(await store.getPinnedTicketAsset('pin-none', 'design', 'a.txt')).toBeNull()
2112
+ await expect(store.getPinnedTicketAsset('never', 'design', 'a.txt')).rejects.toThrow()
2113
+ })
2114
+ })
2115
+
2116
+ test('a spec.revised re-pin moves the latest pinned revision; earlier pins stay readable by rev', async () => {
2117
+ await withStore(factory, undefined, async (store) => {
2118
+ const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
2119
+ await pinnedBuild(store, 'pin-c', [pinOf(v0)])
2120
+ const v1 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'two'))
2121
+ await store.append('pin-c', {
2122
+ actor: KERNEL,
2123
+ type: 'spec.revised',
2124
+ payload: { artifact: { kind: 'spec', rev: 1 }, escalation: 1, assets: [pinOf(v1)] },
2125
+ })
2126
+ expect(text(await store.getPinnedTicketAsset('pin-c', 'design', 'a.txt'))).toBe('two')
2127
+ expect(text(await store.getPinnedTicketAsset('pin-c', 'design', 'a.txt', 0))).toBe('one')
2128
+ // Re-pinning to nothing leaves no latest pin; revisions pinned earlier stay readable.
2129
+ await store.append('pin-c', {
2130
+ actor: KERNEL,
2131
+ type: 'spec.revised',
2132
+ payload: { artifact: { kind: 'spec', rev: 2 }, escalation: 1, assets: [] },
2133
+ })
2134
+ expect(await store.getPinnedTicketAsset('pin-c', 'design', 'a.txt')).toBeNull()
2135
+ expect(text(await store.getPinnedTicketAsset('pin-c', 'design', 'a.txt', 1))).toBe('two')
2136
+ })
2137
+ })
2138
+
2139
+ test('a build-scoped handle reads only its own pins', async () => {
2140
+ await withStore(factory, undefined, async (store) => {
2141
+ const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
2142
+ await pinnedBuild(store, 'pin-own', [pinOf(v0)])
2143
+ await pinnedBuild(store, 'pin-other', [pinOf(v0)])
2144
+ const scoped = store.scopeBuild('pin-own')
2145
+ expect(text(await scoped.getPinnedTicketAsset('pin-own', 'design', 'a.txt'))).toBe('one')
2146
+ await expect(scoped.getPinnedTicketAsset('pin-other', 'design', 'a.txt')).rejects.toThrow(
2147
+ /build-scoped store/,
2148
+ )
2149
+ // The other ticket-asset reads stay rejected.
2150
+ await expect(scoped.getTicketAsset(REPO, 'TICK-1', 'design', 'a.txt')).rejects.toThrow(
2151
+ /build-scoped store/,
2152
+ )
2153
+ })
2154
+ })
2155
+ })
2156
+
1364
2157
  describe('build-scoped handles', () => {
1365
2158
  test('permit own-build stream, artifact, lease, and subscription operations', async () => {
1366
2159
  await withStore(factory, undefined, async (store) => {
@@ -1399,6 +2192,12 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
1399
2192
  await expect(scoped.getRepoEvents('acme/rate-limiter')).rejects.toThrow(
1400
2193
  /build-scoped store/,
1401
2194
  )
2195
+ await expect(scoped.getRepoStateEvents('acme/rate-limiter')).rejects.toThrow(
2196
+ /build-scoped store/,
2197
+ )
2198
+ await expect(scoped.getRepoBuildDigests('acme/rate-limiter')).rejects.toThrow(
2199
+ /build-scoped store/,
2200
+ )
1402
2201
  await expect(scoped.close()).rejects.toThrow(/build-scoped store/)
1403
2202
  })
1404
2203
  })
@@ -2112,6 +2911,73 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2112
2911
  })
2113
2912
  })
2114
2913
 
2914
+ test('appendSessionEventIfCurrent (atomic session compare-and-append): win, stale miss leaves the log untouched, concurrent race yields exactly one winner', async () => {
2915
+ await withStore(factory, undefined, async (store) => {
2916
+ const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
2917
+ // session.created is seq 1, so the current tail is 1.
2918
+ const won = await store.appendSessionEventIfCurrent(
2919
+ session.id,
2920
+ 1,
2921
+ messagePostedWrite('winner'),
2922
+ )
2923
+ expect(won?.seq).toBe(2)
2924
+ expect((await store.getSessionEvents(session.id)).map((e) => e.seq)).toEqual([1, 2])
2925
+
2926
+ // A stale expected sequence appends nothing and leaves the record
2927
+ // untouched.
2928
+ const before = await store.getSession(session.id)
2929
+ expect(
2930
+ await store.appendSessionEventIfCurrent(session.id, 1, messagePostedWrite('stale')),
2931
+ ).toBeNull()
2932
+ expect(await store.getSession(session.id)).toEqual(before)
2933
+ expect((await store.getSessionEvents(session.id)).map((e) => e.seq)).toEqual([1, 2])
2934
+
2935
+ // Two contenders for one sequence produce exactly one winner.
2936
+ const results = await Promise.all([
2937
+ store.appendSessionEventIfCurrent(session.id, 2, messagePostedWrite('a')),
2938
+ store.appendSessionEventIfCurrent(session.id, 2, messagePostedWrite('b')),
2939
+ ])
2940
+ expect(results.filter((result) => result !== null)).toHaveLength(1)
2941
+ expect(results.filter((result) => result === null)).toHaveLength(1)
2942
+ const next = await store.appendSessionEventIfCurrent(
2943
+ session.id,
2944
+ 3,
2945
+ messagePostedWrite('next'),
2946
+ )
2947
+ expect(next?.seq).toBe(4)
2948
+
2949
+ // Invalid candidates and unknown sessions reject without writes.
2950
+ const invalid = {
2951
+ actor: humanActor('op'),
2952
+ type: 'no.such-type',
2953
+ payload: {},
2954
+ } as unknown as SessionEventWrite
2955
+ expect(
2956
+ await store.appendSessionEventIfCurrent(session.id, 4, invalid).then(
2957
+ () => 'no-error',
2958
+ (error: unknown) => error,
2959
+ ),
2960
+ ).toBeInstanceOf(EventValidationError)
2961
+ const negativeSeq = await store
2962
+ .appendSessionEventIfCurrent(session.id, -1, messagePostedWrite())
2963
+ .then(
2964
+ () => 'no-error',
2965
+ (error: unknown) => error,
2966
+ )
2967
+ expect(negativeSeq).toBeInstanceOf(Error)
2968
+ const unknownSession = await store
2969
+ .appendSessionEventIfCurrent('os_ghost', 0, messagePostedWrite())
2970
+ .then(
2971
+ () => 'no-error',
2972
+ (error: unknown) => error,
2973
+ )
2974
+ expect(unknownSession).toBeInstanceOf(Error)
2975
+ expect((await store.getSessionEvents(session.id)).map((e) => e.seq)).toEqual([
2976
+ 1, 2, 3, 4,
2977
+ ])
2978
+ })
2979
+ })
2980
+
2115
2981
  test('bounded-wait session event read: early return on append, waits out the bound when empty, clamps above 30', async () => {
2116
2982
  await withStore(factory, undefined, async (store) => {
2117
2983
  const session = await store.createSession({ repo: 'acme/a', operator: 'op' })
@@ -2243,6 +3109,8 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2243
3109
  () => scoped.ensureRepo('acme/a'),
2244
3110
  () => scoped.appendRepo('acme/a', harvestStartedWrite()),
2245
3111
  () => scoped.getRepoEvents('acme/a'),
3112
+ () => scoped.getRepoStateEvents('acme/a'),
3113
+ () => scoped.getRepoBuildDigests('acme/a'),
2246
3114
  () => scoped.createStream({ kind: 'build', build: 'scope-build' }, 'x'),
2247
3115
  () => scoped.createStream({ kind: 'repo', repo: 'acme/a' }, 'x'),
2248
3116
  () => scoped.close(),