@defrex/autobuild 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +138 -16
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +155 -73
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +8 -1
- package/docs/remote-store-protocol.md +94 -2
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +23 -16
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +23 -79
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +304 -82
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +84 -42
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +23 -5
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- 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 +166 -36
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- 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/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +38 -4
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +139 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +132 -22
- package/packages/core/src/plugin-sdk/index.ts +19 -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/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +223 -205
- 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-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +58 -74
- package/packages/core/src/processes/harvest.ts +72 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1099
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +283 -1
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +180 -55
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- 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 +40 -3
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +97 -12
- package/skills/guide/references/operator-api.md +8 -1
- package/skills/guide/references/remote-store-protocol.md +94 -2
- 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
|
@@ -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,16 @@
|
|
|
1
|
+
import { readPinnedAssets, readBuildFacts } from './reduced-reads'
|
|
1
2
|
/**
|
|
2
3
|
* Ticket assets (SPEC §7.1): named, versioned bundles of files attached to a
|
|
3
|
-
* ticket and kept in
|
|
4
|
-
*
|
|
4
|
+
* ticket and kept in its service (hosted) or BuildStore (other sources), keyed by `(repo, ticketId, kind, name)`.
|
|
5
|
+
* Sources also get a human-readable note through
|
|
5
6
|
* `TicketSource.comment()` (SPEC §13).
|
|
6
7
|
*
|
|
7
8
|
* This module is the shared, adapter-independent half: the types, the limits,
|
|
8
9
|
* and the one validator every adapter calls before any write.
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
import { readAssetSnapshot, prepareAssetSnapshots } from './ticket-asset-snapshots'
|
|
13
|
+
import type { TicketSource, TicketAssetStore } from '../ports/types'
|
|
11
14
|
import type { AbEvent } from '../events/catalog'
|
|
12
15
|
import type { PinnedAsset } from '../events/payloads'
|
|
13
16
|
import { defineReducer } from '../kernel/incremental'
|
|
@@ -56,7 +59,8 @@ export interface TicketAssetMeta {
|
|
|
56
59
|
ticketId: string
|
|
57
60
|
kind: string
|
|
58
61
|
name: string
|
|
59
|
-
/**
|
|
62
|
+
/** Increasing per `(repo, ticketId, kind, name)` (normally from zero; native
|
|
63
|
+
* file revisions reserve a separate range for legacy compatibility). A removal consumes a number
|
|
60
64
|
* too (a tombstone), so revisions may skip. */
|
|
61
65
|
revision: number
|
|
62
66
|
layout: TicketAssetLayout
|
|
@@ -380,7 +384,27 @@ export async function samplePinnedAssets(
|
|
|
380
384
|
return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
|
|
381
385
|
}
|
|
382
386
|
|
|
383
|
-
export
|
|
387
|
+
export async function preparePinnedAssets(
|
|
388
|
+
source: TicketSource,
|
|
389
|
+
store: BuildStore,
|
|
390
|
+
repo: string,
|
|
391
|
+
ticketId: string,
|
|
392
|
+
): Promise<PinnedAsset[]> {
|
|
393
|
+
if (!source.assets) {
|
|
394
|
+
if (source.name === 'hosted') throw new Error('hosted ticket source has no asset capability')
|
|
395
|
+
return samplePinnedAssets(store, repo, ticketId)
|
|
396
|
+
}
|
|
397
|
+
const ticket = await source.get(ticketId)
|
|
398
|
+
return prepareAssetSnapshots(
|
|
399
|
+
composeTicketAssets(source, store),
|
|
400
|
+
store,
|
|
401
|
+
repo,
|
|
402
|
+
ticketId,
|
|
403
|
+
ticket?.ref.id ?? ticketId,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export const PINNED_ASSETS_REDUCER_VERSION = 2
|
|
384
408
|
|
|
385
409
|
/** The asset sets recorded by every `build.created`/`spec.revised` pin event
|
|
386
410
|
* that carries one, in array order. Each pin event replaces the set, so the
|
|
@@ -443,7 +467,7 @@ export function findPinnedRevision(
|
|
|
443
467
|
* needed, so a finished build still answers. Null when the build never pinned
|
|
444
468
|
* `kind/name` (or the revision). Rejects an unknown build. */
|
|
445
469
|
export async function resolvePinnedAsset(
|
|
446
|
-
store: Pick<BuildStore, 'getBuild' | '
|
|
470
|
+
store: Pick<BuildStore, 'getBuild' | 'getReducerValue' | 'getTicketAsset' | 'getRepoArtifact'>,
|
|
447
471
|
slug: string,
|
|
448
472
|
kind: string,
|
|
449
473
|
name: string,
|
|
@@ -452,7 +476,61 @@ export async function resolvePinnedAsset(
|
|
|
452
476
|
const record = await store.getBuild(slug)
|
|
453
477
|
if (record === null) throw new Error(`unknown build "${slug}"`)
|
|
454
478
|
if (record.ticket === undefined) return null
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
479
|
+
const sets = (await readPinnedAssets(store, slug)).value.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 = (await readBuildFacts(store, slug)).value.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
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReducerValueRequest, ReducerValue } from './reducer-values'
|
|
1
2
|
/**
|
|
2
3
|
* The BuildStore seam (SPEC §7): the durable home of everything a build
|
|
3
4
|
* produces — events, artifacts, transcripts — one logical place whether
|
|
@@ -127,10 +128,31 @@ export interface BuildDigest {
|
|
|
127
128
|
executionOpen?: true
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
export type RepoTickCompletion =
|
|
132
|
+
| {
|
|
133
|
+
outcome: 'completed'
|
|
134
|
+
counters: RepositoryEventEnvelope<'dispatcher.tick-completed'>['payload']['counters']
|
|
135
|
+
}
|
|
136
|
+
| { outcome: 'failed'; error: string }
|
|
137
|
+
|
|
138
|
+
/** Bounded operational telemetry; never workflow or queue authority. */
|
|
139
|
+
export interface RepoTickObservation {
|
|
140
|
+
/** The run that last began or finished a tick. */
|
|
141
|
+
run: string
|
|
142
|
+
open?: { startedAt: string }
|
|
143
|
+
/** Retained across begin, even when the next run has a different holder. */
|
|
144
|
+
finished?: RepoTickCompletion & { run: string; startedAt: string; completedAt: string }
|
|
145
|
+
}
|
|
146
|
+
export interface RepoTickFinish {
|
|
147
|
+
observation: RepoTickObservation
|
|
148
|
+
events: RepositoryEvent[]
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
export interface RepositoryRecord {
|
|
131
152
|
repo: string
|
|
132
153
|
createdAt: string
|
|
133
154
|
updatedAt: string
|
|
155
|
+
tick?: RepoTickObservation
|
|
134
156
|
/** Repository workflow lease (harvest single-flight), separate from builds. */
|
|
135
157
|
lease?: { holder: string; expiresAt: string }
|
|
136
158
|
heartbeatAt?: string
|
|
@@ -384,12 +406,16 @@ export interface BuildStore {
|
|
|
384
406
|
* journal's trailing events. Unknown repo rejects like `getRepoEvents`;
|
|
385
407
|
* never an `ensureRepo` side effect. */
|
|
386
408
|
getRepoHighWater(repo: string): Promise<number>
|
|
409
|
+
/** A named JSON answer reduced from the authoritative log. Checkpoints are
|
|
410
|
+
* maintained privately by the adapter and cannot be supplied by callers. */
|
|
411
|
+
getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue>
|
|
387
412
|
/** The stored reducer snapshot for a scope (a build log or a repository
|
|
388
413
|
* journal), a cache beside the log and never an authority. `null` when
|
|
389
414
|
* absent, when the stored version differs from `version`, when the stored
|
|
390
415
|
* cursor is ahead of the scope's log tail, or when the scope is unknown (no
|
|
391
416
|
* `ensureRepo` side effect, no rejection). A store without snapshot storage
|
|
392
|
-
* answers `null`.
|
|
417
|
+
* answers `null`. Reserved `value.*` names reject: their checkpoints are
|
|
418
|
+
* internal to getReducerValue, not public snapshot resources. */
|
|
393
419
|
getReducerSnapshot(
|
|
394
420
|
scope: SnapshotScope,
|
|
395
421
|
reducer: string,
|
|
@@ -401,7 +427,8 @@ export interface BuildStore {
|
|
|
401
427
|
* past it), or when the cursor is equal and `version` is higher. Everything
|
|
402
428
|
* else, and any cursor beyond the log tail, is a no-op. Atomic, so
|
|
403
429
|
* concurrent writers cannot undo each other's progress. Resolves `true`
|
|
404
|
-
* when the row was written.
|
|
430
|
+
* when the row was written. Reserved `value.*` names reject before storage
|
|
431
|
+
* access; only getReducerValue may write these checkpoints. */
|
|
405
432
|
putReducerSnapshot(
|
|
406
433
|
scope: SnapshotScope,
|
|
407
434
|
reducer: string,
|
|
@@ -412,7 +439,7 @@ export interface BuildStore {
|
|
|
412
439
|
listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]>
|
|
413
440
|
// ── Ticket assets (SPEC §7.1 — files attached to a ticket) ─────────────
|
|
414
441
|
// Keyed by (repo, ticketId, kind, name); revisions are 0-based. The store
|
|
415
|
-
// owns
|
|
442
|
+
// owns these bytes; hosted current assets use the ticket service store (SPEC §13).
|
|
416
443
|
/** The effective ceilings this store accepts for `repo` (a remote
|
|
417
444
|
* deployment may advertise lower ones than the defaults). */
|
|
418
445
|
ticketAssetLimits(repo: string): Promise<TicketAssetLimits>
|
|
@@ -456,6 +483,16 @@ export interface BuildStore {
|
|
|
456
483
|
kind: string,
|
|
457
484
|
name: string,
|
|
458
485
|
): Promise<TicketAssetMeta | null>
|
|
486
|
+
/** Fenced by the unexpired repository lease. Null means authority was lost.
|
|
487
|
+
* Finish validates all boundaries before atomically depositing them and telemetry.
|
|
488
|
+
* Neither method renews the lease. */
|
|
489
|
+
beginRepoTick(repo: string, holder: string): Promise<string | null>
|
|
490
|
+
finishRepoTick(
|
|
491
|
+
repo: string,
|
|
492
|
+
holder: string,
|
|
493
|
+
completion: RepoTickCompletion,
|
|
494
|
+
events: RepositoryEventWrite[],
|
|
495
|
+
): Promise<RepoTickFinish | null>
|
|
459
496
|
claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean>
|
|
460
497
|
heartbeatRepo(repo: string, holder: string): Promise<boolean>
|
|
461
498
|
releaseRepoLease(repo: string, holder: string): Promise<void>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { expect } from 'bun:test'
|
|
2
|
+
import { DispatchFrontend } from '../cli/dispatch-frontend'
|
|
3
|
+
import type { DispatchChildResult } from '../cli/dispatch-process'
|
|
4
|
+
import { createTerminalModeController } from '../cli/terminal-restore'
|
|
5
|
+
import { DISPATCHER, humanActor } from '../events/envelope'
|
|
6
|
+
import type { BuildStore } from '../store/types'
|
|
7
|
+
|
|
8
|
+
const tickCounters = {
|
|
9
|
+
merged: 0,
|
|
10
|
+
closed: 0,
|
|
11
|
+
conflicted: 0,
|
|
12
|
+
abandoned: 0,
|
|
13
|
+
discarded: 0,
|
|
14
|
+
janitorFailed: 0,
|
|
15
|
+
recovered: 0,
|
|
16
|
+
dispatchFailed: 0,
|
|
17
|
+
resumed: 0,
|
|
18
|
+
swept: 0,
|
|
19
|
+
dispatched: 0,
|
|
20
|
+
authored: 0,
|
|
21
|
+
bounced: 0,
|
|
22
|
+
claimRaces: 0,
|
|
23
|
+
invalidTickets: 0,
|
|
24
|
+
dependencyBlocked: 0,
|
|
25
|
+
harvestStarted: 0,
|
|
26
|
+
harvestResumed: 0,
|
|
27
|
+
harvestCompleted: 0,
|
|
28
|
+
harvestEscalated: 0,
|
|
29
|
+
harvestFailed: 0,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Run the actual frontend lifecycle with a deterministic child and manual
|
|
33
|
+
* refreshes. Instrument the backing boundary, including HTTP server reads. */
|
|
34
|
+
export async function dispatchReadBudget(store: BuildStore, backing: BuildStore): Promise<void> {
|
|
35
|
+
const repo = '/dashboard-read-budget'
|
|
36
|
+
await backing.ensureRepo(repo)
|
|
37
|
+
const metrics = {
|
|
38
|
+
rows: 0,
|
|
39
|
+
events: 0,
|
|
40
|
+
state: 0,
|
|
41
|
+
snapshots: 0,
|
|
42
|
+
digests: 0,
|
|
43
|
+
records: 0,
|
|
44
|
+
high: 0,
|
|
45
|
+
puts: 0,
|
|
46
|
+
}
|
|
47
|
+
const putSnapshot = backing.putReducerSnapshot.bind(backing)
|
|
48
|
+
backing.putReducerSnapshot = async (...args) => {
|
|
49
|
+
metrics.puts++
|
|
50
|
+
return putSnapshot(...args)
|
|
51
|
+
}
|
|
52
|
+
const getEvents = backing.getRepoEvents.bind(backing)
|
|
53
|
+
const getState = backing.getRepoStateEvents.bind(backing)
|
|
54
|
+
const getSnapshot = backing.getReducerSnapshot.bind(backing)
|
|
55
|
+
const getDigests = backing.getRepoBuildDigests.bind(backing)
|
|
56
|
+
const getRepo = backing.getRepo.bind(backing)
|
|
57
|
+
const getHigh = backing.getRepoHighWater.bind(backing)
|
|
58
|
+
backing.getRepoEvents = async (repo, since) => {
|
|
59
|
+
const events = await getEvents(repo, since)
|
|
60
|
+
metrics.events++
|
|
61
|
+
metrics.rows += events.length
|
|
62
|
+
return events
|
|
63
|
+
}
|
|
64
|
+
backing.getRepoStateEvents = async (repo) => {
|
|
65
|
+
metrics.state++
|
|
66
|
+
return getState(repo)
|
|
67
|
+
}
|
|
68
|
+
backing.getReducerSnapshot = async (...args) => {
|
|
69
|
+
metrics.snapshots++
|
|
70
|
+
return getSnapshot(...args)
|
|
71
|
+
}
|
|
72
|
+
backing.getRepoBuildDigests = async (repo) => {
|
|
73
|
+
metrics.digests++
|
|
74
|
+
return getDigests(repo)
|
|
75
|
+
}
|
|
76
|
+
backing.getRepo = async (repo) => {
|
|
77
|
+
metrics.records++
|
|
78
|
+
return getRepo(repo)
|
|
79
|
+
}
|
|
80
|
+
backing.getRepoHighWater = async (repo) => {
|
|
81
|
+
metrics.high++
|
|
82
|
+
return getHigh(repo)
|
|
83
|
+
}
|
|
84
|
+
let now = 0
|
|
85
|
+
let finish!: (result: DispatchChildResult) => void
|
|
86
|
+
let started!: () => void
|
|
87
|
+
let childRun = ''
|
|
88
|
+
const startup = new Promise<void>((resolve) => {
|
|
89
|
+
started = resolve
|
|
90
|
+
})
|
|
91
|
+
const frontend = new DispatchFrontend({
|
|
92
|
+
repo,
|
|
93
|
+
storeRef: 'test',
|
|
94
|
+
store,
|
|
95
|
+
env: {},
|
|
96
|
+
once: true,
|
|
97
|
+
clock: () => new Date(now),
|
|
98
|
+
input: { start: () => () => {} },
|
|
99
|
+
terminal: {
|
|
100
|
+
columns: 100,
|
|
101
|
+
rows: 24,
|
|
102
|
+
interactive: true,
|
|
103
|
+
write: () => {},
|
|
104
|
+
modes: createTerminalModeController(
|
|
105
|
+
() => {},
|
|
106
|
+
() => {},
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
launchChild: ({ run }) => {
|
|
110
|
+
childRun = run
|
|
111
|
+
const completed = new Promise<DispatchChildResult>((resolve) => {
|
|
112
|
+
finish = resolve
|
|
113
|
+
})
|
|
114
|
+
void backing
|
|
115
|
+
.appendRepoWithArtifacts(
|
|
116
|
+
repo,
|
|
117
|
+
[
|
|
118
|
+
{
|
|
119
|
+
kind: 'config',
|
|
120
|
+
content: JSON.stringify({
|
|
121
|
+
capacity: 4,
|
|
122
|
+
roles: { default: { runtime: 'claude' } },
|
|
123
|
+
tickets: { source: 'file', readyState: 'ready' },
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
(artifacts) => ({
|
|
128
|
+
actor: DISPATCHER,
|
|
129
|
+
type: 'dispatcher.run-started',
|
|
130
|
+
payload: {
|
|
131
|
+
run,
|
|
132
|
+
pid: 1,
|
|
133
|
+
roleWarnings: [],
|
|
134
|
+
effectiveConfig: { kind: artifacts[0]!.kind, rev: artifacts[0]!.revision },
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
.then(started)
|
|
139
|
+
return { completed, stop: async () => {} }
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
const running = frontend.run()
|
|
143
|
+
await startup
|
|
144
|
+
// Exercise exactly the controller operation used by timers and actions,
|
|
145
|
+
// without a wall-clock timer determining the batch boundaries.
|
|
146
|
+
const refresh = () => (frontend as unknown as { renderOnce(): Promise<void> }).renderOnce()
|
|
147
|
+
await refresh()
|
|
148
|
+
const initialSize = JSON.stringify(
|
|
149
|
+
(await getSnapshot({ kind: 'repo', repo }, 'dispatchFrontend', 2))?.state,
|
|
150
|
+
).length
|
|
151
|
+
let recordReads: number | undefined
|
|
152
|
+
try {
|
|
153
|
+
for (let batch = 0; batch < 24; batch++) {
|
|
154
|
+
for (let i = 0; i < 128; i++) {
|
|
155
|
+
await backing.appendRepo(repo, {
|
|
156
|
+
actor: DISPATCHER,
|
|
157
|
+
type: 'dispatcher.tick-completed',
|
|
158
|
+
payload: {
|
|
159
|
+
run: childRun,
|
|
160
|
+
queued: 0,
|
|
161
|
+
counters: tickCounters,
|
|
162
|
+
janitorDiagnostics: [],
|
|
163
|
+
ticketDiagnostics: [],
|
|
164
|
+
creationDiagnostics: [],
|
|
165
|
+
dependencyDiagnostics: [],
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const before = { ...metrics }
|
|
170
|
+
now += 6000
|
|
171
|
+
await refresh()
|
|
172
|
+
expect(metrics.puts - before.puts).toBe(1)
|
|
173
|
+
expect(metrics.rows - before.rows).toBe(128)
|
|
174
|
+
expect(metrics.events - before.events).toBe(1)
|
|
175
|
+
expect(metrics.state - before.state).toBe(0)
|
|
176
|
+
expect(metrics.snapshots - before.snapshots).toBe(0)
|
|
177
|
+
expect(metrics.digests - before.digests).toBe(1)
|
|
178
|
+
expect(metrics.high - before.high).toBe(0)
|
|
179
|
+
recordReads ??= metrics.records - before.records
|
|
180
|
+
expect(metrics.records - before.records).toBe(recordReads)
|
|
181
|
+
const size = JSON.stringify(
|
|
182
|
+
(await getSnapshot({ kind: 'repo', repo }, 'dispatchFrontend', 2))?.state,
|
|
183
|
+
).length
|
|
184
|
+
// First advancement adds the child's identity; ticks add nothing.
|
|
185
|
+
expect(size).toBeLessThan(initialSize + 1000)
|
|
186
|
+
const rows = metrics.rows
|
|
187
|
+
await refresh()
|
|
188
|
+
expect(metrics.rows).toBe(rows)
|
|
189
|
+
}
|
|
190
|
+
// Advance the clock across rejected equal-cursor saves. A standby can
|
|
191
|
+
// remain quiet indefinitely; each 500 ms frame must not upload its cache.
|
|
192
|
+
const beforeQuiet = { ...metrics }
|
|
193
|
+
for (let poll = 0; poll < 40; poll++) {
|
|
194
|
+
now += 500
|
|
195
|
+
await refresh()
|
|
196
|
+
}
|
|
197
|
+
expect(metrics.puts - beforeQuiet.puts).toBe(4)
|
|
198
|
+
expect(metrics.rows).toBe(beforeQuiet.rows)
|
|
199
|
+
} finally {
|
|
200
|
+
finish({ outcome: 'normal', exitCode: 0 })
|
|
201
|
+
await running
|
|
202
|
+
}
|
|
203
|
+
await backing.appendRepo(repo, {
|
|
204
|
+
actor: humanActor('test'),
|
|
205
|
+
type: 'dispatcher.pause-set',
|
|
206
|
+
payload: { enabled: true },
|
|
207
|
+
})
|
|
208
|
+
// A new invocation restores persisted state without any bounded/full seed.
|
|
209
|
+
const { DispatchRepositoryFeed } = await import('../cli/dispatch-repository-feed')
|
|
210
|
+
const feed = new DispatchRepositoryFeed(store, repo, () => new Date(now))
|
|
211
|
+
const before = { ...metrics }
|
|
212
|
+
expect((await feed.refresh()).length).toBe(1)
|
|
213
|
+
expect(metrics.rows - before.rows).toBe(1)
|
|
214
|
+
expect(metrics.state - before.state).toBe(0)
|
|
215
|
+
}
|
|
@@ -64,3 +64,11 @@ export { resolveHarvestCliEnv } from '../cli/env'
|
|
|
64
64
|
export { buildHarvestContext, submitHarvestProposals, submitHarvestVerdict } from '../cli/harvest'
|
|
65
65
|
export { abConnect, abDisconnect } from '../cli/connect'
|
|
66
66
|
export { ProjectCredentials } from '../cli/project-credentials'
|
|
67
|
+
|
|
68
|
+
export { dispatchReadBudget } from './dispatch-read-budget'
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
reducedReadBudget,
|
|
72
|
+
countHistoricalRows,
|
|
73
|
+
type ReducedReadCosts,
|
|
74
|
+
} from './reduced-read-budget'
|