@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- 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(),
|