@defrex/autobuild 0.12.0 → 0.13.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 (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -327,12 +327,17 @@ export async function reconcileHarvestLedger(
327
327
  )
328
328
  if (entries.length === 0) return []
329
329
 
330
- const ids = entries.map((entry) => entry.ticket.id)
330
+ const ids = entries
331
+ .filter((entry) => entry.ticket.source === tickets.name)
332
+ .map((entry) => entry.ticket.id)
331
333
  const states = await tickets.dependencyStates(ids)
334
+ const byId = new Map(states.map((state) => [state.id, state]))
332
335
  const out: HarvestLedgerTicket[] = []
333
336
  for (let index = 0; index < entries.length; index += 1) {
334
337
  const entry = entries[index]!
335
- const dependency = states[index] ?? {
338
+ const dependency = (entry.ticket.source === tickets.name
339
+ ? byId.get(entry.ticket.id)
340
+ : undefined) ?? {
336
341
  id: entry.ticket.id,
337
342
  exists: false,
338
343
  resolved: false,
@@ -341,7 +346,7 @@ export async function reconcileHarvestLedger(
341
346
  const current = dependency.exists ? await tickets.get(entry.ticket.id) : null
342
347
  out.push({
343
348
  proposalKey: entry.proposalKey,
344
- ticket: structuredClone(entry.ticket),
349
+ ticket: structuredClone(current?.ref ?? entry.ticket),
345
350
  exists: dependency.exists,
346
351
  resolved: dependency.resolved,
347
352
  ...(current?.title !== undefined ? { title: current.title } : {}),
@@ -371,15 +376,25 @@ export async function reconcileOriginatingTickets(
371
376
  const states = matchingIds.length > 0 ? await tickets.dependencyStates(matchingIds) : []
372
377
  const byId = new Map(states.map((state) => [state.id, state]))
373
378
 
374
- return refs.map((ticket) => {
375
- const sourceMatches = ticket.source === tickets.name
376
- const state = sourceMatches ? byId.get(ticket.id) : undefined
377
- return {
378
- ticket,
379
- sourceMatches,
380
- exists: state?.exists ?? false,
381
- resolved: state?.resolved ?? false,
382
- }
379
+ const identity = new Set<string>()
380
+ const projected = await Promise.all(
381
+ refs.map(async (ticket) => {
382
+ const sourceMatches = ticket.source === tickets.name
383
+ const state = sourceMatches ? byId.get(ticket.id) : undefined
384
+ const current = sourceMatches && state?.exists ? await tickets.get(ticket.id) : null
385
+ return {
386
+ ticket: current?.ref ?? ticket,
387
+ sourceMatches,
388
+ exists: state?.exists ?? false,
389
+ resolved: state?.resolved ?? false,
390
+ }
391
+ }),
392
+ )
393
+ return projected.filter((entry) => {
394
+ const key = `${entry.ticket.source}:${entry.ticket.id}`
395
+ if (identity.has(key)) return false
396
+ identity.add(key)
397
+ return true
383
398
  })
384
399
  }
385
400
 
@@ -536,9 +551,14 @@ export async function resolveHarvestCreateBlockers(
536
551
  const state = byId.get(id)
537
552
  return state?.exists === true && !state.resolved
538
553
  })
554
+ const canonical = new Map<string, string>()
555
+ for (const id of candidates) {
556
+ if (byId.get(id)?.exists) canonical.set(id, (await tickets.get(id))?.ref.id ?? id)
557
+ }
558
+ const project = (ids: string[]) => [...new Set(ids.map((id) => canonical.get(id) ?? id))]
539
559
  return {
540
- blockedBy: [...new Set([...declared, ...derived])],
541
- provenance: { declared, derived },
560
+ blockedBy: project([...declared, ...derived]),
561
+ provenance: { declared: project(declared), derived: project(derived) },
542
562
  }
543
563
  }
544
564
 
@@ -12,6 +12,7 @@ export const POLICY_PHASE_FAILURE_RESET_SCOPES = {
12
12
  'verify-failure-limit': 'all-phase-rounds',
13
13
  'reconcile-no-progress': 'none',
14
14
  'dispatch-failure-limit': 'none',
15
+ 'execution-no-progress': 'none',
15
16
  'setup-failure-limit': 'none',
16
17
  'infrastructure-failure-limit': 'none',
17
18
  'phase-attempt-limit': 'matching-round',
@@ -1,3 +1,4 @@
1
+ import { executionNoProgressReducer } from './execution-no-progress'
1
2
  /**
2
3
  * The dispatcher's in-process repository view (AUT-647): a `BuildStore`
3
4
  * decorator that builds the state a tick needs once, at the first refresh, and
@@ -154,6 +155,7 @@ const EXTRA_REDUCERS = {
154
155
  finish: buildDigestReducer.finish,
155
156
  },
156
157
  logIndex: logIndexReducer,
158
+ executionNoProgress: executionNoProgressReducer,
157
159
  openExecution: openExecutionReducer,
158
160
  openBuildWorkspace: openBuildWorkspaceReducer,
159
161
  publicationState: publicationStateReducer,
@@ -186,6 +188,7 @@ const WORK_BUNDLE_REDUCER = { version: WORK_BUNDLE_VERSION }
186
188
  /** The facts a quiet-path stage needs about one build, without its event array. */
187
189
  export interface BuildFacts {
188
190
  state: BuildState
191
+ executionNoProgress: ReturnType<typeof executionNoProgressReducer.finish>
189
192
  log: LogIndex
190
193
  open: OpenExecution | null
191
194
  /** The open workspace (the latest provisioned, not yet released). */
@@ -279,6 +282,37 @@ function retainedShape(retained: unknown[], cursor: number): boolean {
279
282
  /** Structural checks on the small accumulators a later fold dereferences. */
280
283
  function extraAccsShape(extra: ExtraAccs): boolean {
281
284
  const field = (acc: unknown, key: string): unknown => (isObject(acc) ? acc[key] : undefined)
285
+ const emptyCount = field(extra.executionNoProgress, 'count')
286
+ const emptyOpen = field(extra.executionNoProgress, 'open')
287
+ if (
288
+ typeof emptyCount !== 'number' ||
289
+ !Number.isSafeInteger(emptyCount) ||
290
+ emptyCount < 0 ||
291
+ !Array.isArray(emptyOpen) ||
292
+ !emptyOpen.every((id) => typeof id === 'string')
293
+ )
294
+ return false
295
+ const emptyLatest = field(extra.executionNoProgress, 'latest')
296
+ if (
297
+ emptyLatest !== null &&
298
+ !(
299
+ isObject(emptyLatest) &&
300
+ typeof emptyLatest.instance === 'string' &&
301
+ typeof emptyLatest.seq === 'number'
302
+ )
303
+ )
304
+ return false
305
+ const active = field(extra.executionNoProgress, 'active')
306
+ if (
307
+ active !== null &&
308
+ !(
309
+ isObject(active) &&
310
+ typeof active.instance === 'string' &&
311
+ typeof active.seq === 'number' &&
312
+ typeof active.eligible === 'boolean'
313
+ )
314
+ )
315
+ return false
282
316
  const open = field(extra.openExecution, 'open')
283
317
  if (open !== null && !(isObject(open) && typeof open.instance === 'string')) return false
284
318
  const workspace = field(extra.openBuildWorkspace, 'open')
@@ -914,6 +948,7 @@ export class RepoViewStore implements BuildStore {
914
948
  const extra = log.extra!
915
949
  return {
916
950
  state: buildReducer.finish(log.acc!),
951
+ executionNoProgress: executionNoProgressReducer.finish(extra.executionNoProgress as never),
917
952
  log: logIndexReducer.finish(extra.logIndex as never),
918
953
  open: openExecutionReducer.finish(extra.openExecution as never),
919
954
  workspace: openBuildWorkspaceReducer.finish(extra.openBuildWorkspace as never),
@@ -1,3 +1,5 @@
1
+ import { prepareAssetSnapshots } from './ticket-asset-snapshots'
2
+ import { MemoryBuildStore } from './memory'
1
3
  /**
2
4
  * Reusable contract suites for the BuildStore seam (SPEC §7). The in-memory
3
5
  * adapter's behavior *is* the contract; every adapter — SQLite (§7.2.1),
@@ -2514,7 +2516,7 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2514
2516
  async function pinnedBuild(
2515
2517
  store: BuildStore,
2516
2518
  slug: string,
2517
- assets: ReturnType<typeof pinOf>[],
2519
+ assets: import('../events/payloads').PinnedAsset[],
2518
2520
  ): Promise<void> {
2519
2521
  const created = buildCreatedWrite()
2520
2522
  await store.createBuild({
@@ -2523,6 +2525,35 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
2523
2525
  })
2524
2526
  }
2525
2527
 
2528
+ test('snapshot pins read independent bytes, historical revisions and scoped access', async () => {
2529
+ await withStore(factory, undefined, async (store) => {
2530
+ const source = new MemoryBuildStore()
2531
+ await source.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'remote one'))
2532
+ await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'local conflict'))
2533
+ const pins = await prepareAssetSnapshots(source, store, REPO, 'TICK-1')
2534
+ await pinnedBuild(store, 'snapshot-pin', pins)
2535
+ await source.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'remote two'))
2536
+ const newer = await prepareAssetSnapshots(source, store, REPO, 'TICK-1')
2537
+ await store.append('snapshot-pin', {
2538
+ actor: KERNEL,
2539
+ type: 'spec.revised',
2540
+ payload: { artifact: { kind: 'spec', rev: 1 }, escalation: 1, assets: newer },
2541
+ })
2542
+ await source.removeTicketAsset(REPO, 'TICK-1', 'design', 'a.txt')
2543
+ expect(text(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt'))).toBe(
2544
+ 'remote two',
2545
+ )
2546
+ expect(text(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt', 0))).toBe(
2547
+ 'remote one',
2548
+ )
2549
+ expect(await store.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt', 2)).toBeNull()
2550
+ const scoped = store.scopeBuild('snapshot-pin')
2551
+ expect(text(await scoped.getPinnedTicketAsset('snapshot-pin', 'design', 'a.txt'))).toBe(
2552
+ 'remote two',
2553
+ )
2554
+ })
2555
+ })
2556
+
2526
2557
  test('reads the pinned revision after the ticket replaces and then removes the asset', async () => {
2527
2558
  await withStore(factory, undefined, async (store) => {
2528
2559
  const v0 = await store.putTicketAsset(REPO, 'TICK-1', file('a.txt', 'one'))
@@ -2036,7 +2036,7 @@ export class SqliteBuildStore implements BuildStore {
2036
2036
  return { stale: false, record: this.toStreamRecord(this.requireStream(streamId)) }
2037
2037
  },
2038
2038
  )
2039
- if (!committed.stale) return committed.record
2039
+ if (committed.stale === false) return committed.record
2040
2040
  // A foreign appender landed inside the prepare window; loop and
2041
2041
  // re-prepare from the newer chunk set so its acknowledged chunk is
2042
2042
  // not omitted from the finalized artifact.
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { gzipSync, gunzipSync } from 'node:zlib'
3
+ import { z } from 'zod'
4
+ import type { TicketAssetStore } from '../ports/types'
5
+ import type { PinnedAsset } from '../events/payloads'
6
+ import { contentHash, type BuildStore } from './types'
7
+ import {
8
+ pinnedAssetOf,
9
+ summarizeTicketAsset,
10
+ validateTicketAssetInput,
11
+ validateTicketAssetStructure,
12
+ type TicketAsset,
13
+ } from './ticket-assets'
14
+
15
+ const CHUNK_BYTES = 256 * 1024
16
+ const refSchema = z.strictObject({
17
+ kind: z.string().regex(/^ticket-snapshot-[a-f0-9-]+$/),
18
+ revision: z.number().int().nonnegative(),
19
+ })
20
+ const manifestSchema = z.strictObject({
21
+ version: z.literal(1),
22
+ meta: z.strictObject({
23
+ repo: z.string(),
24
+ ticketId: z.string(),
25
+ kind: z.string(),
26
+ name: z.string(),
27
+ revision: z.number().int().nonnegative(),
28
+ layout: z.enum(['file', 'tree']),
29
+ size: z.number().int().nonnegative(),
30
+ createdAt: z.string(),
31
+ entries: z.array(
32
+ z.discriminatedUnion('type', [
33
+ z.strictObject({ type: z.literal('dir'), path: z.string() }),
34
+ z.strictObject({
35
+ type: z.literal('file'),
36
+ path: z.string(),
37
+ size: z.number().int().nonnegative(),
38
+ blobRef: z.string(),
39
+ }),
40
+ ]),
41
+ ),
42
+ }),
43
+ files: z.array(
44
+ z.strictObject({
45
+ path: z.string(),
46
+ chunks: z.array(
47
+ refSchema.extend({ hash: z.string(), size: z.number().int().min(1).max(CHUNK_BYTES) }),
48
+ ),
49
+ }),
50
+ ),
51
+ })
52
+
53
+ function verifyMetadata(
54
+ meta: TicketAsset['meta'],
55
+ repo: string,
56
+ ticketId: string,
57
+ pin: PinnedAsset,
58
+ ): void {
59
+ const summary = pinnedAssetOf(summarizeTicketAsset(meta))
60
+ if (
61
+ meta.repo !== repo ||
62
+ meta.ticketId !== ticketId ||
63
+ Object.entries(summary).some(([key, value]) => pin[key as keyof typeof summary] !== value) ||
64
+ validateTicketAssetStructure(meta) !== meta.size
65
+ )
66
+ throw new Error('inconsistent ticket asset snapshot metadata')
67
+ }
68
+
69
+ function verify(asset: TicketAsset, repo: string, ticketId: string, pin: PinnedAsset): void {
70
+ const { meta, entries } = asset
71
+ verifyMetadata(meta, repo, ticketId, pin)
72
+ if (
73
+ validateTicketAssetInput({ ...meta, entries }) !== meta.size ||
74
+ entries.length !== meta.entries.length
75
+ ) {
76
+ throw new Error('inconsistent ticket asset snapshot metadata')
77
+ }
78
+ for (let i = 0; i < entries.length; i++) {
79
+ const entry = entries[i]!
80
+ const stored = meta.entries[i]!
81
+ if (
82
+ entry.path !== stored.path ||
83
+ entry.type !== stored.type ||
84
+ (entry.type === 'file' &&
85
+ stored.type === 'file' &&
86
+ (entry.content.length !== stored.size || contentHash(entry.content) !== stored.blobRef))
87
+ ) {
88
+ throw new Error('inconsistent ticket asset snapshot content')
89
+ }
90
+ }
91
+ }
92
+
93
+ /** Copy exact sampled revisions before publishing any pins. No current ticket rows
94
+ * are written to the destination; their revision sequences remain independent. */
95
+ export async function prepareAssetSnapshots(
96
+ source: Pick<TicketAssetStore, 'listTicketAssets' | 'getTicketAsset'>,
97
+ store: Pick<BuildStore, 'putRepoArtifact' | 'ensureRepo'>,
98
+ repo: string,
99
+ ticketId: string,
100
+ canonicalTicketId: string = ticketId,
101
+ ): Promise<PinnedAsset[]> {
102
+ const pins = (await source.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
103
+ if (pins.length > 0) await store.ensureRepo(repo)
104
+ for (const pin of pins) {
105
+ const asset = await source.getTicketAsset(repo, ticketId, pin.kind, pin.name, pin.revision)
106
+ if (!asset) throw new Error('sampled ticket asset revision is missing')
107
+ manifestSchema.shape.meta.parse(asset.meta)
108
+ verify(asset, repo, canonicalTicketId, pin)
109
+ const files = []
110
+ for (const entry of asset.entries) {
111
+ if (entry.type !== 'file') continue
112
+ const chunks = []
113
+ for (let start = 0; start < entry.content.length; start += CHUNK_BYTES) {
114
+ const bytes = entry.content.slice(start, start + CHUNK_BYTES)
115
+ const meta = await store.putRepoArtifact(repo, {
116
+ kind: `ticket-snapshot-${randomUUID()}`,
117
+ content: bytes,
118
+ })
119
+ chunks.push({
120
+ kind: meta.kind,
121
+ revision: meta.revision,
122
+ size: bytes.length,
123
+ hash: contentHash(bytes),
124
+ })
125
+ }
126
+ files.push({ path: entry.path, chunks })
127
+ }
128
+ const meta = await store.putRepoArtifact(repo, {
129
+ kind: `ticket-snapshot-${randomUUID()}`,
130
+ // Compression also bounds manifests with many maximum-length escaped paths.
131
+ content: gzipSync(JSON.stringify({ version: 1, meta: { ...asset.meta, ticketId }, files })),
132
+ })
133
+ pin.snapshot = { kind: meta.kind, revision: meta.revision }
134
+ }
135
+ return pins
136
+ }
137
+
138
+ export async function readAssetSnapshot(
139
+ store: Pick<BuildStore, 'getRepoArtifact'>,
140
+ repo: string,
141
+ ticketId: string,
142
+ pin: PinnedAsset,
143
+ historicalTicketId?: string,
144
+ ): Promise<TicketAsset> {
145
+ if (!pin.snapshot) throw new Error('missing snapshot reference')
146
+ const artifact = await store.getRepoArtifact(repo, pin.snapshot.kind, pin.snapshot.revision)
147
+ if (!artifact) throw new Error('ticket asset snapshot manifest is missing')
148
+ const manifest = manifestSchema.parse(
149
+ JSON.parse(gunzipSync(artifact.content, { maxOutputLength: 4 * 1024 * 1024 }).toString('utf8')),
150
+ )
151
+ // BuildStore projections can display a readable ID while the immutable
152
+ // build.created event and its older snapshots retain the storage alias.
153
+ const snapshotTicketId =
154
+ manifest.meta.ticketId === historicalTicketId ? historicalTicketId : ticketId
155
+ verifyMetadata(manifest.meta, repo, snapshotTicketId, pin)
156
+ const entries: TicketAsset['entries'] = []
157
+ const files = new Map(manifest.files.map((file) => [file.path, file]))
158
+ if (
159
+ files.size !== manifest.files.length ||
160
+ files.size !== manifest.meta.entries.filter((entry) => entry.type === 'file').length
161
+ ) {
162
+ throw new Error('inconsistent ticket asset snapshot files')
163
+ }
164
+ for (const entry of manifest.meta.entries) {
165
+ if (entry.type === 'dir') {
166
+ entries.push(entry)
167
+ continue
168
+ }
169
+ const file = files.get(entry.path)
170
+ if (!file || file.chunks.reduce((size, chunk) => size + chunk.size, 0) !== entry.size) {
171
+ throw new Error('inconsistent ticket asset snapshot chunks')
172
+ }
173
+ const content = new Uint8Array(entry.size)
174
+ let offset = 0
175
+ for (const chunk of file.chunks) {
176
+ const artifact = await store.getRepoArtifact(repo, chunk.kind, chunk.revision)
177
+ if (
178
+ !artifact ||
179
+ artifact.content.length !== chunk.size ||
180
+ contentHash(artifact.content) !== chunk.hash
181
+ ) {
182
+ throw new Error('ticket asset snapshot chunk is missing or corrupt')
183
+ }
184
+ content.set(artifact.content, offset)
185
+ offset += chunk.size
186
+ }
187
+ entries.push({ type: 'file', path: entry.path, content })
188
+ }
189
+ const asset = { meta: manifest.meta, entries }
190
+ verify(asset, repo, snapshotTicketId, pin)
191
+ return asset
192
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * Ticket assets (SPEC §7.1): named, versioned bundles of files attached to a
3
- * ticket and kept in the BuildStore, keyed by `(repo, ticketId, kind, name)`.
4
- * The ticket source never stores the contents; it only gets a note through
3
+ * ticket and kept in its service (hosted) or BuildStore (other sources), keyed by `(repo, ticketId, kind, name)`.
4
+ * Sources also get a human-readable note through
5
5
  * `TicketSource.comment()` (SPEC §13).
6
6
  *
7
7
  * This module is the shared, adapter-independent half: the types, the limits,
8
8
  * and the one validator every adapter calls before any write.
9
9
  */
10
10
 
11
+ import { readAssetSnapshot, prepareAssetSnapshots } from './ticket-asset-snapshots'
12
+ import type { TicketSource, TicketAssetStore } from '../ports/types'
11
13
  import type { AbEvent } from '../events/catalog'
12
14
  import type { PinnedAsset } from '../events/payloads'
13
15
  import { defineReducer } from '../kernel/incremental'
@@ -56,7 +58,8 @@ export interface TicketAssetMeta {
56
58
  ticketId: string
57
59
  kind: string
58
60
  name: string
59
- /** 0-based per `(repo, ticketId, kind, name)`. A removal consumes a number
61
+ /** Increasing per `(repo, ticketId, kind, name)` (normally from zero; native
62
+ * file revisions reserve a separate range for legacy compatibility). A removal consumes a number
60
63
  * too (a tombstone), so revisions may skip. */
61
64
  revision: number
62
65
  layout: TicketAssetLayout
@@ -380,7 +383,27 @@ export async function samplePinnedAssets(
380
383
  return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
381
384
  }
382
385
 
383
- export const PINNED_ASSETS_REDUCER_VERSION = 1
386
+ export async function preparePinnedAssets(
387
+ source: TicketSource,
388
+ store: BuildStore,
389
+ repo: string,
390
+ ticketId: string,
391
+ ): Promise<PinnedAsset[]> {
392
+ if (!source.assets) {
393
+ if (source.name === 'hosted') throw new Error('hosted ticket source has no asset capability')
394
+ return samplePinnedAssets(store, repo, ticketId)
395
+ }
396
+ const ticket = await source.get(ticketId)
397
+ return prepareAssetSnapshots(
398
+ composeTicketAssets(source, store),
399
+ store,
400
+ repo,
401
+ ticketId,
402
+ ticket?.ref.id ?? ticketId,
403
+ )
404
+ }
405
+
406
+ export const PINNED_ASSETS_REDUCER_VERSION = 2
384
407
 
385
408
  /** The asset sets recorded by every `build.created`/`spec.revised` pin event
386
409
  * that carries one, in array order. Each pin event replaces the set, so the
@@ -443,7 +466,7 @@ export function findPinnedRevision(
443
466
  * needed, so a finished build still answers. Null when the build never pinned
444
467
  * `kind/name` (or the revision). Rejects an unknown build. */
445
468
  export async function resolvePinnedAsset(
446
- store: Pick<BuildStore, 'getBuild' | 'getEvents' | 'getTicketAsset'>,
469
+ store: Pick<BuildStore, 'getBuild' | 'getEvents' | 'getTicketAsset' | 'getRepoArtifact'>,
447
470
  slug: string,
448
471
  kind: string,
449
472
  name: string,
@@ -452,7 +475,62 @@ export async function resolvePinnedAsset(
452
475
  const record = await store.getBuild(slug)
453
476
  if (record === null) throw new Error(`unknown build "${slug}"`)
454
477
  if (record.ticket === undefined) return null
455
- const revision = findPinnedRevision(await store.getEvents(slug), kind, name, rev)
456
- if (revision === undefined) return null
457
- return store.getTicketAsset(record.repo, record.ticket.id, kind, name, revision)
478
+ const events = await store.getEvents(slug)
479
+ const sets = pinnedAssetsReducer.reduce(events).pins
480
+ const pin =
481
+ rev === undefined
482
+ ? sets.at(-1)?.find((a) => a.kind === kind && a.name === name)
483
+ : sets.flat().find((a) => a.kind === kind && a.name === name && a.revision === rev)
484
+ if (!pin) return null
485
+ if (pin.snapshot) {
486
+ const created = events.find((event) => event.type === 'build.created')
487
+ const asset = await readAssetSnapshot(
488
+ store,
489
+ record.repo,
490
+ record.ticket.id,
491
+ pin,
492
+ created?.type === 'build.created' ? created.payload.ticket?.id : undefined,
493
+ )
494
+ return { ...asset, meta: { ...asset.meta, ticketId: record.ticket.id } }
495
+ }
496
+ return store.getTicketAsset(record.repo, record.ticket.id, kind, name, pin.revision)
497
+ }
498
+
499
+ /** Native writes; merged reads preserve legacy file attachments and recorded revisions. */
500
+ export function composeTicketAssets(
501
+ source: TicketSource,
502
+ legacy: TicketAssetStore,
503
+ ): TicketAssetStore {
504
+ const native = source.assets
505
+ if (!native) return legacy
506
+ if (!native.hasTicketAssetHistory) return native
507
+ const owns = (repo: string, id: string, kind: string, name: string) =>
508
+ native.hasTicketAssetHistory!(repo, id, kind, name)
509
+ return {
510
+ ticketAssetLimits: native.ticketAssetLimits.bind(native),
511
+ putTicketAsset: native.putTicketAsset.bind(native),
512
+ async getTicketAsset(repo, id, kind, name, rev) {
513
+ if (rev !== undefined)
514
+ return (
515
+ (await native.getTicketAsset(repo, id, kind, name, rev)) ??
516
+ legacy.getTicketAsset(repo, id, kind, name, rev)
517
+ )
518
+ return (await owns(repo, id, kind, name))
519
+ ? native.getTicketAsset(repo, id, kind, name)
520
+ : legacy.getTicketAsset(repo, id, kind, name)
521
+ },
522
+ async listTicketAssets(repo, id) {
523
+ const current = await native.listTicketAssets(repo, id)
524
+ for (const item of await legacy.listTicketAssets(repo, id)) {
525
+ if (!(await owns(repo, id, item.kind, item.name))) current.push(item)
526
+ }
527
+ return current
528
+ },
529
+ async removeTicketAsset(repo, id, kind, name) {
530
+ const previous = await this.getTicketAsset(repo, id, kind, name)
531
+ if (!previous) return null
532
+ await native.removeTicketAsset(repo, id, kind, name)
533
+ return previous?.meta ?? null
534
+ },
535
+ }
458
536
  }
@@ -412,7 +412,7 @@ export interface BuildStore {
412
412
  listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]>
413
413
  // ── Ticket assets (SPEC §7.1 — files attached to a ticket) ─────────────
414
414
  // Keyed by (repo, ticketId, kind, name); revisions are 0-based. The store
415
- // owns the bytes, the ticket source only ever gets a note (SPEC §13).
415
+ // owns these bytes; hosted current assets use the ticket service store (SPEC §13).
416
416
  /** The effective ceilings this store accepts for `repo` (a remote
417
417
  * deployment may advertise lower ones than the defaults). */
418
418
  ticketAssetLimits(repo: string): Promise<TicketAssetLimits>