@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.
- package/README.md +1 -1
- package/SPEC.md +119 -15
- package/docs/architecture.md +59 -3
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +7 -0
- package/docs/remote-store-protocol.md +10 -0
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +3 -3
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +156 -3
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +20 -2
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +164 -34
- package/packages/core/src/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/reducer-registry.ts +2 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +5 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +130 -20
- package/packages/core/src/plugin-sdk/index.ts +6 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- package/packages/core/src/processes/dispatcher.ts +110 -32
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-runner.ts +24 -4
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/skills/guide/SKILL.md +91 -11
- package/skills/guide/references/operator-api.md +7 -0
- package/skills/guide/references/remote-store-protocol.md +10 -0
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- 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
|
|
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 =
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
ticket
|
|
379
|
-
sourceMatches
|
|
380
|
-
|
|
381
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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>
|