@defrex/autobuild 0.8.0 → 0.10.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 +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +204 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/skills/tickets/SKILL.md +57 -5
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* synchronous (SQLite) and asynchronous (PostgreSQL) backends is worth more
|
|
11
11
|
* than wake-on-write precision. The loop serves two cadences: held *stream*
|
|
12
12
|
* reads are presentation content and poll at the ~25 ms `STREAM_WAIT_POLL_MS`;
|
|
13
|
-
* held *event* reads (build, repository, session — AUT-334/381/383) take the
|
|
13
|
+
* held *event* reads (build, repository, session — AUT-334/381/383/394) take the
|
|
14
14
|
* one-second `EVENT_WAIT_POLL_MS` budget when the adapter passes it per call
|
|
15
15
|
* (see `EVENT_WAIT_POLL_MS` for which adapters do). Closed streams never wait.
|
|
16
16
|
*
|
|
@@ -28,15 +28,14 @@ export const STREAM_WAIT_POLL_MS = 25
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* The one-second held-event poll budget (the hosted per-query budget,
|
|
31
|
-
* AUT-334/381/383): held event reads — build, repository, and session —
|
|
31
|
+
* AUT-334/381/383/394): held event reads — build, repository, and session —
|
|
32
32
|
* re-query at most once per second, so an append is observed at the next
|
|
33
33
|
* poll, typically within about one second. Held *stream* reads are excluded:
|
|
34
34
|
* they are presentation content and stay at the ~25 ms `STREAM_WAIT_POLL_MS`
|
|
35
35
|
* cadence. This deliberately diverges from `STREAM_WAIT_POLL_MS`; adapters
|
|
36
36
|
* pass it as `pollMs` on their event-family calls to `readEventsWithWait` —
|
|
37
|
-
* the PostgreSQL and
|
|
38
|
-
*
|
|
39
|
-
* its held event reads fall back to the stream default until it does.
|
|
37
|
+
* the PostgreSQL, memory, and SQLite stores do on all three families
|
|
38
|
+
* (AUT-334/381/383/394).
|
|
40
39
|
*
|
|
41
40
|
* The PostgreSQL adapter treats the bound as nominal, not hard: a worst-case
|
|
42
41
|
* ≤1 s guarantee would need Postgres LISTEN/NOTIFY wake-on-append — one
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
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
|
|
5
|
+
* `TicketSource.comment()` (SPEC §13).
|
|
6
|
+
*
|
|
7
|
+
* This module is the shared, adapter-independent half: the types, the limits,
|
|
8
|
+
* and the one validator every adapter calls before any write.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AbEvent } from '../events/catalog'
|
|
12
|
+
import type { PinnedAsset } from '../events/payloads'
|
|
13
|
+
import { defineReducer } from '../kernel/incremental'
|
|
14
|
+
import { contentHash, type BlobStore, type BuildStore } from './types'
|
|
15
|
+
|
|
16
|
+
export type { PinnedAsset }
|
|
17
|
+
|
|
18
|
+
/** Total file bytes per revision (25 MiB). */
|
|
19
|
+
export const TICKET_ASSET_MAX_BYTES = 25 * 1024 * 1024
|
|
20
|
+
/** Files plus directories per revision. */
|
|
21
|
+
export const TICKET_ASSET_MAX_ENTRIES = 1000
|
|
22
|
+
/** UTF-8 bytes per entry path. */
|
|
23
|
+
export const TICKET_ASSET_MAX_PATH_BYTES = 256
|
|
24
|
+
/** UTF-8 bytes per path segment. */
|
|
25
|
+
export const TICKET_ASSET_MAX_SEGMENT_BYTES = 128
|
|
26
|
+
/** Kind and name length. */
|
|
27
|
+
export const TICKET_ASSET_MAX_LABEL_LENGTH = 64
|
|
28
|
+
|
|
29
|
+
/** Worst-case JSON envelope around the entries of one put request. */
|
|
30
|
+
export const WIRE_ENVELOPE_BYTES = 1024
|
|
31
|
+
/** Worst-case JSON per entry besides base64 content: a maximum-length path
|
|
32
|
+
* plus keys, quotes, commas, the type tag, and base64 padding. */
|
|
33
|
+
export const WIRE_ENTRY_BYTES = TICKET_ASSET_MAX_PATH_BYTES + 64
|
|
34
|
+
|
|
35
|
+
export type TicketAssetLayout = 'file' | 'tree'
|
|
36
|
+
|
|
37
|
+
/** An entry is a file or an explicit directory. Paths are relative and
|
|
38
|
+
* '/'-separated (`index.html`, `img/a.png`, `img/empty`). */
|
|
39
|
+
export type TicketAssetEntryInput =
|
|
40
|
+
| { type: 'file'; path: string; content: Uint8Array }
|
|
41
|
+
| { type: 'dir'; path: string }
|
|
42
|
+
|
|
43
|
+
export interface TicketAssetInput {
|
|
44
|
+
kind: string
|
|
45
|
+
name: string
|
|
46
|
+
layout: TicketAssetLayout
|
|
47
|
+
entries: TicketAssetEntryInput[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TicketAssetEntry =
|
|
51
|
+
| { type: 'file'; path: string; size: number; blobRef: string }
|
|
52
|
+
| { type: 'dir'; path: string }
|
|
53
|
+
|
|
54
|
+
export interface TicketAssetMeta {
|
|
55
|
+
repo: string
|
|
56
|
+
ticketId: string
|
|
57
|
+
kind: string
|
|
58
|
+
name: string
|
|
59
|
+
/** 0-based per `(repo, ticketId, kind, name)`. A removal consumes a number
|
|
60
|
+
* too (a tombstone), so revisions may skip. */
|
|
61
|
+
revision: number
|
|
62
|
+
layout: TicketAssetLayout
|
|
63
|
+
/** Total file bytes. */
|
|
64
|
+
size: number
|
|
65
|
+
entries: TicketAssetEntry[]
|
|
66
|
+
createdAt: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What `listTicketAssets` returns on every adapter: the metadata without
|
|
70
|
+
* the manifest. */
|
|
71
|
+
export interface TicketAssetSummary {
|
|
72
|
+
repo: string
|
|
73
|
+
ticketId: string
|
|
74
|
+
kind: string
|
|
75
|
+
name: string
|
|
76
|
+
revision: number
|
|
77
|
+
layout: TicketAssetLayout
|
|
78
|
+
size: number
|
|
79
|
+
fileCount: number
|
|
80
|
+
dirCount: number
|
|
81
|
+
createdAt: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type TicketAssetContentEntry =
|
|
85
|
+
| { type: 'file'; path: string; content: Uint8Array }
|
|
86
|
+
| { type: 'dir'; path: string }
|
|
87
|
+
|
|
88
|
+
export interface TicketAsset {
|
|
89
|
+
meta: TicketAssetMeta
|
|
90
|
+
entries: TicketAssetContentEntry[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TicketAssetLimits {
|
|
94
|
+
maxBytes: number
|
|
95
|
+
maxEntries: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const DEFAULT_TICKET_ASSET_LIMITS: TicketAssetLimits = {
|
|
99
|
+
maxBytes: TICKET_ASSET_MAX_BYTES,
|
|
100
|
+
maxEntries: TICKET_ASSET_MAX_ENTRIES,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class TicketAssetValidationError extends Error {
|
|
104
|
+
constructor(message: string) {
|
|
105
|
+
super(message)
|
|
106
|
+
this.name = 'TicketAssetValidationError'
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function summarizeTicketAsset(meta: TicketAssetMeta): TicketAssetSummary {
|
|
111
|
+
let fileCount = 0
|
|
112
|
+
let dirCount = 0
|
|
113
|
+
for (const entry of meta.entries) {
|
|
114
|
+
if (entry.type === 'file') fileCount += 1
|
|
115
|
+
else dirCount += 1
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
repo: meta.repo,
|
|
119
|
+
ticketId: meta.ticketId,
|
|
120
|
+
kind: meta.kind,
|
|
121
|
+
name: meta.name,
|
|
122
|
+
revision: meta.revision,
|
|
123
|
+
layout: meta.layout,
|
|
124
|
+
size: meta.size,
|
|
125
|
+
fileCount,
|
|
126
|
+
dirCount,
|
|
127
|
+
createdAt: meta.createdAt,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatBytes(bytes: number): string {
|
|
132
|
+
const mib = bytes / (1024 * 1024)
|
|
133
|
+
if (bytes >= 1024 * 1024 && Number.isInteger(mib)) return `${mib} MiB`
|
|
134
|
+
if (bytes >= 1024 * 1024) return `${mib.toFixed(1)} MiB`
|
|
135
|
+
if (bytes >= 1024 && bytes % 1024 === 0) return `${bytes / 1024} KiB`
|
|
136
|
+
return `${bytes} bytes`
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The limit-naming error for an over-size revision. `size` is the total file
|
|
140
|
+
* bytes the caller tried to store. */
|
|
141
|
+
export function ticketAssetSizeError(size: number, limit: number): TicketAssetValidationError {
|
|
142
|
+
return new TicketAssetValidationError(
|
|
143
|
+
`ticket asset exceeds the ${limit}-byte (${formatBytes(limit)}) limit: ${size} bytes`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function ticketAssetEntriesError(count: number, limit: number): TicketAssetValidationError {
|
|
148
|
+
return new TicketAssetValidationError(
|
|
149
|
+
`ticket asset exceeds the ${limit}-entry limit: ${count} entries (files plus directories)`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The effective limits of a store whose requests and responses can carry at
|
|
154
|
+
* most `maxRequestBytes`. Paths and entry counts are bounded, so the worst-case
|
|
155
|
+
* wire overhead is a constant; base64 inflates content by 4/3. A safe lower
|
|
156
|
+
* bound: the client's exact-size check is the final authority. */
|
|
157
|
+
export function effectiveTicketAssetLimits(maxRequestBytes?: number): TicketAssetLimits {
|
|
158
|
+
if (maxRequestBytes === undefined) return { ...DEFAULT_TICKET_ASSET_LIMITS }
|
|
159
|
+
const overhead = WIRE_ENVELOPE_BYTES + TICKET_ASSET_MAX_ENTRIES * WIRE_ENTRY_BYTES
|
|
160
|
+
return {
|
|
161
|
+
maxBytes: Math.min(
|
|
162
|
+
TICKET_ASSET_MAX_BYTES,
|
|
163
|
+
Math.max(0, Math.floor(((maxRequestBytes - overhead) * 3) / 4)),
|
|
164
|
+
),
|
|
165
|
+
maxEntries: TICKET_ASSET_MAX_ENTRIES,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const LABEL_PATTERN = /^[A-Za-z0-9._-]+$/
|
|
170
|
+
|
|
171
|
+
export function validateTicketAssetLabel(field: 'kind' | 'name', value: string): void {
|
|
172
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
173
|
+
throw new TicketAssetValidationError(`ticket asset ${field} is required`)
|
|
174
|
+
}
|
|
175
|
+
if (value.length > TICKET_ASSET_MAX_LABEL_LENGTH) {
|
|
176
|
+
throw new TicketAssetValidationError(
|
|
177
|
+
`ticket asset ${field} must be at most ${TICKET_ASSET_MAX_LABEL_LENGTH} characters`,
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
if (!LABEL_PATTERN.test(value) || value === '.' || value === '..') {
|
|
181
|
+
throw new TicketAssetValidationError(
|
|
182
|
+
`ticket asset ${field} "${value}" may contain only letters, digits, '.', '_' and '-'`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const encoder = new TextEncoder()
|
|
188
|
+
|
|
189
|
+
/** One entry path: relative, '/'-separated, no empty/`.`/`..` segments, no
|
|
190
|
+
* NUL, bounded. */
|
|
191
|
+
export function validateTicketAssetPath(path: string): void {
|
|
192
|
+
if (typeof path !== 'string' || path === '') {
|
|
193
|
+
throw new TicketAssetValidationError('ticket asset entry path is required')
|
|
194
|
+
}
|
|
195
|
+
if (path.includes('\0')) {
|
|
196
|
+
throw new TicketAssetValidationError(`ticket asset entry path "${path}" contains a NUL byte`)
|
|
197
|
+
}
|
|
198
|
+
if (path.includes('\\')) {
|
|
199
|
+
throw new TicketAssetValidationError(
|
|
200
|
+
`ticket asset entry path "${path}" must use '/' separators`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
if (encoder.encode(path).length > TICKET_ASSET_MAX_PATH_BYTES) {
|
|
204
|
+
throw new TicketAssetValidationError(
|
|
205
|
+
`ticket asset entry path exceeds ${TICKET_ASSET_MAX_PATH_BYTES} bytes: "${path.slice(0, 40)}…"`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
if (path.startsWith('/')) {
|
|
209
|
+
throw new TicketAssetValidationError(`ticket asset entry path "${path}" must be relative`)
|
|
210
|
+
}
|
|
211
|
+
for (const segment of path.split('/')) {
|
|
212
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
213
|
+
throw new TicketAssetValidationError(
|
|
214
|
+
`ticket asset entry path "${path}" has an empty, '.' or '..' segment`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
if (encoder.encode(segment).length > TICKET_ASSET_MAX_SEGMENT_BYTES) {
|
|
218
|
+
throw new TicketAssetValidationError(
|
|
219
|
+
`ticket asset entry path segment exceeds ${TICKET_ASSET_MAX_SEGMENT_BYTES} bytes in "${path.slice(0, 40)}…"`,
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Shape and structure of a manifest, independent of content: shared by put
|
|
226
|
+
* validation and the remote server's check of an incoming request. Returns
|
|
227
|
+
* the total file bytes. */
|
|
228
|
+
export function validateTicketAssetStructure(
|
|
229
|
+
input: {
|
|
230
|
+
kind: string
|
|
231
|
+
name: string
|
|
232
|
+
layout: TicketAssetLayout
|
|
233
|
+
entries: ({ type: 'file'; path: string; size: number } | { type: 'dir'; path: string })[]
|
|
234
|
+
},
|
|
235
|
+
limits: TicketAssetLimits = DEFAULT_TICKET_ASSET_LIMITS,
|
|
236
|
+
): number {
|
|
237
|
+
validateTicketAssetLabel('kind', input.kind)
|
|
238
|
+
validateTicketAssetLabel('name', input.name)
|
|
239
|
+
if (input.layout !== 'file' && input.layout !== 'tree') {
|
|
240
|
+
throw new TicketAssetValidationError(`ticket asset layout must be 'file' or 'tree'`)
|
|
241
|
+
}
|
|
242
|
+
const files = input.entries.filter((entry) => entry.type === 'file')
|
|
243
|
+
if (input.layout === 'file') {
|
|
244
|
+
if (input.entries.length !== 1 || files.length !== 1) {
|
|
245
|
+
throw new TicketAssetValidationError(
|
|
246
|
+
`a 'file' ticket asset has exactly one file entry and no directories`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
if (files[0]?.path.includes('/')) {
|
|
250
|
+
throw new TicketAssetValidationError(`a 'file' ticket asset's path is its filename`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (input.entries.length > limits.maxEntries) {
|
|
254
|
+
throw ticketAssetEntriesError(input.entries.length, limits.maxEntries)
|
|
255
|
+
}
|
|
256
|
+
const kinds = new Map<string, 'file' | 'dir'>()
|
|
257
|
+
let size = 0
|
|
258
|
+
for (const entry of input.entries) {
|
|
259
|
+
if (entry.type !== 'file' && entry.type !== 'dir') {
|
|
260
|
+
throw new TicketAssetValidationError(`ticket asset entry type must be 'file' or 'dir'`)
|
|
261
|
+
}
|
|
262
|
+
validateTicketAssetPath(entry.path)
|
|
263
|
+
if (kinds.has(entry.path)) {
|
|
264
|
+
throw new TicketAssetValidationError(`duplicate ticket asset entry path "${entry.path}"`)
|
|
265
|
+
}
|
|
266
|
+
kinds.set(entry.path, entry.type)
|
|
267
|
+
if (entry.type === 'file') size += entry.size
|
|
268
|
+
}
|
|
269
|
+
for (const [path] of kinds) {
|
|
270
|
+
const segments = path.split('/')
|
|
271
|
+
for (let end = 1; end < segments.length; end += 1) {
|
|
272
|
+
const ancestor = segments.slice(0, end).join('/')
|
|
273
|
+
if (kinds.get(ancestor) === 'file') {
|
|
274
|
+
throw new TicketAssetValidationError(
|
|
275
|
+
`ticket asset entry "${path}" has a file as its ancestor directory "${ancestor}"`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (size > limits.maxBytes) throw ticketAssetSizeError(size, limits.maxBytes)
|
|
281
|
+
return size
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The one validator every adapter calls before any write. Throws
|
|
285
|
+
* `TicketAssetValidationError`; returns the total file bytes. */
|
|
286
|
+
export function validateTicketAssetInput(
|
|
287
|
+
input: TicketAssetInput,
|
|
288
|
+
limits: TicketAssetLimits = DEFAULT_TICKET_ASSET_LIMITS,
|
|
289
|
+
): number {
|
|
290
|
+
return validateTicketAssetStructure(
|
|
291
|
+
{
|
|
292
|
+
kind: input.kind,
|
|
293
|
+
name: input.name,
|
|
294
|
+
layout: input.layout,
|
|
295
|
+
entries: input.entries.map((entry) =>
|
|
296
|
+
entry.type === 'file'
|
|
297
|
+
? { type: 'file', path: entry.path, size: entry.content.byteLength }
|
|
298
|
+
: { type: 'dir', path: entry.path },
|
|
299
|
+
),
|
|
300
|
+
},
|
|
301
|
+
limits,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Write each file's bytes to the blob store (content-addressed) and build the
|
|
306
|
+
* manifest. Call only after `validateTicketAssetInput`. */
|
|
307
|
+
export async function storeTicketAssetBlobs(
|
|
308
|
+
blobs: BlobStore,
|
|
309
|
+
entries: TicketAssetEntryInput[],
|
|
310
|
+
): Promise<TicketAssetEntry[]> {
|
|
311
|
+
const manifest: TicketAssetEntry[] = []
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
if (entry.type === 'dir') {
|
|
314
|
+
manifest.push({ type: 'dir', path: entry.path })
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
const blobRef = contentHash(entry.content)
|
|
318
|
+
await blobs.put(blobRef, entry.content)
|
|
319
|
+
manifest.push({ type: 'file', path: entry.path, size: entry.content.byteLength, blobRef })
|
|
320
|
+
}
|
|
321
|
+
return manifest
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Read the bytes of one manifest file; a missing blob is store corruption. */
|
|
325
|
+
export async function readTicketAssetBlob(
|
|
326
|
+
blobs: BlobStore,
|
|
327
|
+
meta: TicketAssetMeta,
|
|
328
|
+
entry: { path: string; blobRef: string },
|
|
329
|
+
): Promise<Uint8Array> {
|
|
330
|
+
const bytes = await blobs.get(entry.blobRef)
|
|
331
|
+
if (!bytes) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`ticket asset ${meta.kind}/${meta.name} revision ${meta.revision}: blob for "${entry.path}" is missing`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
return bytes
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Load a manifest's full content (every adapter's `getTicketAsset` tail). */
|
|
340
|
+
export async function loadTicketAsset(
|
|
341
|
+
blobs: BlobStore,
|
|
342
|
+
meta: TicketAssetMeta,
|
|
343
|
+
): Promise<TicketAsset> {
|
|
344
|
+
const entries: TicketAssetContentEntry[] = []
|
|
345
|
+
for (const entry of meta.entries) {
|
|
346
|
+
entries.push(
|
|
347
|
+
entry.type === 'dir'
|
|
348
|
+
? { type: 'dir', path: entry.path }
|
|
349
|
+
: {
|
|
350
|
+
type: 'file',
|
|
351
|
+
path: entry.path,
|
|
352
|
+
content: await readTicketAssetBlob(blobs, meta, entry),
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
return { meta: structuredClone(meta), entries }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** What a build pins of one asset revision: the summary minus the ticket
|
|
360
|
+
* coordinates and timestamp, which the build record already carries. */
|
|
361
|
+
export function pinnedAssetOf(summary: TicketAssetSummary): PinnedAsset {
|
|
362
|
+
return {
|
|
363
|
+
kind: summary.kind,
|
|
364
|
+
name: summary.name,
|
|
365
|
+
revision: summary.revision,
|
|
366
|
+
layout: summary.layout,
|
|
367
|
+
size: summary.size,
|
|
368
|
+
fileCount: summary.fileCount,
|
|
369
|
+
dirCount: summary.dirCount,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The set of ticket assets to freeze for a claim or a spec re-pin: every
|
|
374
|
+
* current (live) asset of the ticket, in `listTicketAssets` order. */
|
|
375
|
+
export async function samplePinnedAssets(
|
|
376
|
+
store: Pick<BuildStore, 'listTicketAssets'>,
|
|
377
|
+
repo: string,
|
|
378
|
+
ticketId: string,
|
|
379
|
+
): Promise<PinnedAsset[]> {
|
|
380
|
+
return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const PINNED_ASSETS_REDUCER_VERSION = 1
|
|
384
|
+
|
|
385
|
+
/** The asset sets recorded by every `build.created`/`spec.revised` pin event
|
|
386
|
+
* that carries one, in array order. Each pin event replaces the set, so the
|
|
387
|
+
* newest decides the live pin; all of them answer "was this revision ever
|
|
388
|
+
* named". `pinnedRevision` is the query over the finished state. */
|
|
389
|
+
export const pinnedAssetsReducer = defineReducer<
|
|
390
|
+
{ pins: PinnedAsset[][] },
|
|
391
|
+
AbEvent,
|
|
392
|
+
{ pins: PinnedAsset[][] }
|
|
393
|
+
>({
|
|
394
|
+
version: PINNED_ASSETS_REDUCER_VERSION,
|
|
395
|
+
initial: () => ({ pins: [] }),
|
|
396
|
+
fold(acc, events) {
|
|
397
|
+
for (const event of events) {
|
|
398
|
+
if (event.type !== 'build.created' && event.type !== 'spec.revised') continue
|
|
399
|
+
const assets = event.payload.assets
|
|
400
|
+
if (assets === undefined) continue
|
|
401
|
+
acc.pins.push(assets)
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
finish: (acc) => ({ pins: acc.pins.slice() }),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
/** Latest-pinned revision of `kind/name` over a reduced pin state, or (with
|
|
408
|
+
* `rev`) the revision when any pin named it. */
|
|
409
|
+
export function pinnedRevision(
|
|
410
|
+
state: { pins: readonly (readonly PinnedAsset[])[] },
|
|
411
|
+
kind: string,
|
|
412
|
+
name: string,
|
|
413
|
+
rev?: number,
|
|
414
|
+
): number | undefined {
|
|
415
|
+
let latest: number | undefined
|
|
416
|
+
for (const assets of state.pins) {
|
|
417
|
+
if (rev !== undefined) {
|
|
418
|
+
if (assets.some((a) => a.kind === kind && a.name === name && a.revision === rev)) return rev
|
|
419
|
+
continue
|
|
420
|
+
}
|
|
421
|
+
// Each pin event replaces the set, so the newest event that carries a set
|
|
422
|
+
// decides; absent from that set means unpinned.
|
|
423
|
+
latest = assets.find((a) => a.kind === kind && a.name === name)?.revision
|
|
424
|
+
}
|
|
425
|
+
return latest
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Latest-pinned revision of `kind/name` as the build's events record it, or
|
|
429
|
+
* (with `rev`) the revision when any pin in the log named it. */
|
|
430
|
+
export function findPinnedRevision(
|
|
431
|
+
events: AbEvent[],
|
|
432
|
+
kind: string,
|
|
433
|
+
name: string,
|
|
434
|
+
rev?: number,
|
|
435
|
+
): number | undefined {
|
|
436
|
+
return pinnedRevision(pinnedAssetsReducer.reduce(events), kind, name, rev)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** `BuildStore.getPinnedTicketAsset` for any adapter: read the build's own
|
|
440
|
+
* events to find the pinned revision, then fetch exactly that revision from the
|
|
441
|
+
* ticket's assets. Earlier revisions stay retrievable after a replace or a
|
|
442
|
+
* removal (SPEC §7.1), so the bytes can never drift, and no lease or liveness is
|
|
443
|
+
* needed, so a finished build still answers. Null when the build never pinned
|
|
444
|
+
* `kind/name` (or the revision). Rejects an unknown build. */
|
|
445
|
+
export async function resolvePinnedAsset(
|
|
446
|
+
store: Pick<BuildStore, 'getBuild' | 'getEvents' | 'getTicketAsset'>,
|
|
447
|
+
slug: string,
|
|
448
|
+
kind: string,
|
|
449
|
+
name: string,
|
|
450
|
+
rev?: number,
|
|
451
|
+
): Promise<TicketAsset | null> {
|
|
452
|
+
const record = await store.getBuild(slug)
|
|
453
|
+
if (record === null) throw new Error(`unknown build "${slug}"`)
|
|
454
|
+
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)
|
|
458
|
+
}
|