@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
|
@@ -16,6 +16,8 @@ import type { TicketSource } from '../types'
|
|
|
16
16
|
import { DEFAULT_TICKETS_DIR, FileTicketSource } from './file'
|
|
17
17
|
import { LinearTicketSource } from './linear'
|
|
18
18
|
import { HostedTicketSource } from './remote'
|
|
19
|
+
import { ticketLifecycle } from './lifecycle'
|
|
20
|
+
import { normalizeServiceURL } from '../../connection'
|
|
19
21
|
|
|
20
22
|
function reason(error: unknown): string {
|
|
21
23
|
return error instanceof Error ? error.message : String(error)
|
|
@@ -32,26 +34,41 @@ export async function createTicketSource(
|
|
|
32
34
|
plugins: PluginRegistry = createPluginRegistry(),
|
|
33
35
|
): Promise<TicketSource> {
|
|
34
36
|
if (config.source === 'hosted') {
|
|
35
|
-
const
|
|
37
|
+
const buildUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
|
|
38
|
+
const rawUrl = config.url ?? buildUrl
|
|
36
39
|
if (!rawUrl) {
|
|
37
40
|
throw new Error(
|
|
38
|
-
'
|
|
41
|
+
'Hosted tickets require [tickets] url when builds are local; alternatively set [store] url or AB_STORE to a service',
|
|
39
42
|
)
|
|
40
43
|
}
|
|
41
44
|
let serviceUrl: URL
|
|
42
45
|
try {
|
|
43
|
-
serviceUrl = new URL(rawUrl)
|
|
46
|
+
serviceUrl = new URL(normalizeServiceURL(rawUrl, 'tickets.url'))
|
|
44
47
|
} catch {
|
|
45
|
-
throw new Error(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
throw new Error('AB_STORE must be an HTTP(S) URL when [tickets].source = "hosted"')
|
|
48
|
+
throw new Error(
|
|
49
|
+
'Hosted tickets require [tickets] url when [store] url / AB_STORE selects local builds',
|
|
50
|
+
)
|
|
49
51
|
}
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
env
|
|
52
|
+
const ticketSession = env.AB_TICKET_TOKEN !== undefined || env.AB_TICKET_URL !== undefined
|
|
53
|
+
if (
|
|
54
|
+
ticketSession &&
|
|
55
|
+
(!env.AB_TICKET_TOKEN ||
|
|
56
|
+
!env.AB_TICKET_URL ||
|
|
57
|
+
normalizeServiceURL(env.AB_TICKET_URL) !== normalizeServiceURL(rawUrl))
|
|
54
58
|
)
|
|
59
|
+
throw new Error('Ticket session credential does not match the selected ticket service')
|
|
60
|
+
const token = ticketSession
|
|
61
|
+
? env.AB_TICKET_TOKEN
|
|
62
|
+
: projectToken(
|
|
63
|
+
rawUrl,
|
|
64
|
+
(await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
|
|
65
|
+
config.url &&
|
|
66
|
+
(!buildUrl ||
|
|
67
|
+
!/^https?:/.test(buildUrl) ||
|
|
68
|
+
normalizeServiceURL(config.url) !== normalizeServiceURL(buildUrl))
|
|
69
|
+
? { ...env, AB_TOKEN: undefined }
|
|
70
|
+
: env,
|
|
71
|
+
)
|
|
55
72
|
if (!token) {
|
|
56
73
|
throw new Error(
|
|
57
74
|
'No project credential; run ab connect (or set AB_TOKEN) — a project credential is required when [tickets].source = "hosted"',
|
|
@@ -60,13 +77,32 @@ export async function createTicketSource(
|
|
|
60
77
|
if (config.teamKey === undefined) {
|
|
61
78
|
throw new Error('[tickets].source = "hosted" requires teamKey')
|
|
62
79
|
}
|
|
63
|
-
|
|
80
|
+
const source = new HostedTicketSource({
|
|
64
81
|
url: serviceUrl.toString(),
|
|
65
82
|
token,
|
|
66
83
|
teamKey: config.teamKey,
|
|
67
84
|
...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
|
|
68
85
|
...(config.createState !== undefined ? { createState: config.createState } : {}),
|
|
69
86
|
})
|
|
87
|
+
// Delegated sessions may read policy, but never redefine project authority.
|
|
88
|
+
if (!ticketSession && !env.AB_HARVEST && !env.AB_PHASE) {
|
|
89
|
+
const lifecycle = ticketLifecycle(config)
|
|
90
|
+
await source.synchronizePolicy({
|
|
91
|
+
version: 1,
|
|
92
|
+
tickets: {
|
|
93
|
+
source: 'hosted',
|
|
94
|
+
teamKey: config.teamKey,
|
|
95
|
+
readyState: lifecycle.ready,
|
|
96
|
+
triageState: lifecycle.triage,
|
|
97
|
+
claimedState: lifecycle.doing,
|
|
98
|
+
doneState: lifecycle.done,
|
|
99
|
+
createState: lifecycle.create,
|
|
100
|
+
proposalState: lifecycle.proposal,
|
|
101
|
+
...(config.readyLabels !== undefined ? { readyLabels: config.readyLabels } : {}),
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return source
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
if (config.source === 'linear') {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fileRequestedIds } from './requested-id'
|
|
2
|
+
import { ticketRevision, checkTicketRevision } from './revision'
|
|
1
3
|
/**
|
|
2
4
|
* In-memory TicketSource for seam tests (SPEC §3.2, §13). Mirrors the policy
|
|
3
5
|
* shape of the real adapters: initiation (listReady/get/claim/create),
|
|
@@ -12,11 +14,13 @@ import type {
|
|
|
12
14
|
TicketListing,
|
|
13
15
|
TicketSource,
|
|
14
16
|
TicketUpdate,
|
|
17
|
+
TicketMutation,
|
|
15
18
|
} from '../types'
|
|
16
19
|
import { validateTicketUpdate } from './update'
|
|
17
20
|
|
|
18
21
|
function cloneTicket(ticket: Ticket, doneState: string): Ticket {
|
|
19
22
|
return {
|
|
23
|
+
revision: ticketRevision(ticket),
|
|
20
24
|
ref: { ...ticket.ref },
|
|
21
25
|
...(ticket.creationKey !== undefined ? { creationKey: ticket.creationKey } : {}),
|
|
22
26
|
title: ticket.title,
|
|
@@ -29,6 +33,7 @@ function cloneTicket(ticket: Ticket, doneState: string): Ticket {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export class FakeTicketSource implements TicketSource {
|
|
36
|
+
readonly requestedIds = fileRequestedIds
|
|
32
37
|
readonly name = 'fake'
|
|
33
38
|
|
|
34
39
|
/** Journal of every projection sent outward (§13), in call order. */
|
|
@@ -111,6 +116,7 @@ export class FakeTicketSource implements TicketSource {
|
|
|
111
116
|
const ticket = this.tickets.get(id)
|
|
112
117
|
if (!ticket || ticket.state === this.doneState || this.claimed.has(id)) return false
|
|
113
118
|
this.claimed.add(id)
|
|
119
|
+
ticket.state = 'Doing'
|
|
114
120
|
return true
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -131,8 +137,10 @@ export class FakeTicketSource implements TicketSource {
|
|
|
131
137
|
const adopted = this.idempotency.get(opts.idempotencyKey)
|
|
132
138
|
if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'), this.doneState)
|
|
133
139
|
}
|
|
134
|
-
const id = `fake-${this.nextId++}`
|
|
140
|
+
const id = opts.requestedId ?? `fake-${this.nextId++}`
|
|
141
|
+
if (this.tickets.has(id)) throw new Error(`requested ticket id already occupied: ${id}`)
|
|
135
142
|
const ticket: Ticket = {
|
|
143
|
+
revision: 'pending',
|
|
136
144
|
ref: { source: this.name, id, title: draft.title },
|
|
137
145
|
...(opts.idempotencyKey !== undefined ? { creationKey: opts.idempotencyKey } : {}),
|
|
138
146
|
title: draft.title,
|
|
@@ -153,6 +161,27 @@ export class FakeTicketSource implements TicketSource {
|
|
|
153
161
|
return cloneTicket(ticket, this.doneState)
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
|
|
165
|
+
const ticket = this.require(id, 'revision-checked write')
|
|
166
|
+
checkTicketRevision(cloneTicket(ticket, this.doneState), expected)
|
|
167
|
+
if (mutation.kind === 'update') await this.update(id, mutation.patch)
|
|
168
|
+
else if (mutation.kind === 'transition') await this.transition(id, mutation.state)
|
|
169
|
+
else {
|
|
170
|
+
for (const blocker of mutation.ids) {
|
|
171
|
+
this.require(blocker, 'blocker')
|
|
172
|
+
if (mutation.operation === 'block' && blocker === id)
|
|
173
|
+
throw new Error(`ticket "${id}" cannot block itself`)
|
|
174
|
+
}
|
|
175
|
+
// No await between validation and in-memory batch application.
|
|
176
|
+
const blockers = new Set(ticket.blockedBy ?? [])
|
|
177
|
+
for (const blocker of mutation.ids) {
|
|
178
|
+
if (mutation.operation === 'block') blockers.add(blocker)
|
|
179
|
+
else blockers.delete(blocker)
|
|
180
|
+
}
|
|
181
|
+
ticket.blockedBy = [...blockers]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
156
185
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
157
186
|
const validated = validateTicketUpdate(patch)
|
|
158
187
|
const ticket = this.require(id, 'update')
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { TicketAssetStore } from '../types'
|
|
4
|
+
import type { Frontmatter } from './file-codec'
|
|
5
|
+
import { decodePortableAsset, encodePortableAsset, type PortableAsset } from './portable-assets'
|
|
6
|
+
import { contentHash } from '../../store/types'
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_TICKET_ASSET_LIMITS,
|
|
9
|
+
summarizeTicketAsset,
|
|
10
|
+
validateTicketAssetInput,
|
|
11
|
+
type TicketAsset,
|
|
12
|
+
type TicketAssetInput,
|
|
13
|
+
} from '../../store/ticket-assets'
|
|
14
|
+
|
|
15
|
+
type History = Record<string, Array<{ asset: PortableAsset | null; createdAt: string }>>
|
|
16
|
+
// Native revisions occupy a separate range so legacy recorded pins remain unambiguous.
|
|
17
|
+
const FIRST_REVISION = 1_000_000_000
|
|
18
|
+
export class FileTicketAssets implements TicketAssetStore {
|
|
19
|
+
constructor(
|
|
20
|
+
private dir: string,
|
|
21
|
+
private load: (
|
|
22
|
+
id: string,
|
|
23
|
+
) => Promise<{ front: Frontmatter; exists?: boolean; save(front: Frontmatter): Promise<void> }>,
|
|
24
|
+
) {}
|
|
25
|
+
async ticketAssetLimits() {
|
|
26
|
+
return { ...DEFAULT_TICKET_ASSET_LIMITS }
|
|
27
|
+
}
|
|
28
|
+
private key(kind: string, name: string) {
|
|
29
|
+
return `${kind}/${name}`
|
|
30
|
+
}
|
|
31
|
+
private path(id: string) {
|
|
32
|
+
return join(this.dir, '.ab-assets', `${Buffer.from(id).toString('hex')}.json`)
|
|
33
|
+
}
|
|
34
|
+
private async history(id: string, front: Frontmatter): Promise<History> {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(await readFile(this.path(id), 'utf8')) as History
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
39
|
+
}
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
(front.attachments ?? []).map((asset) => [
|
|
42
|
+
this.key(asset.kind, asset.name),
|
|
43
|
+
[{ asset, createdAt: '1970-01-01T00:00:00.000Z' }],
|
|
44
|
+
]),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
async hasTicketAssetHistory(_repo: string, id: string, kind: string, name: string) {
|
|
48
|
+
const { front } = await this.load(id)
|
|
49
|
+
return this.key(kind, name) in (await this.history(id, front))
|
|
50
|
+
}
|
|
51
|
+
private materialize(
|
|
52
|
+
repo: string,
|
|
53
|
+
id: string,
|
|
54
|
+
asset: PortableAsset,
|
|
55
|
+
revision: number,
|
|
56
|
+
createdAt: string,
|
|
57
|
+
): TicketAsset {
|
|
58
|
+
const input = decodePortableAsset(asset)
|
|
59
|
+
const size = validateTicketAssetInput(input)
|
|
60
|
+
return {
|
|
61
|
+
meta: {
|
|
62
|
+
repo,
|
|
63
|
+
ticketId: id,
|
|
64
|
+
kind: asset.kind,
|
|
65
|
+
name: asset.name,
|
|
66
|
+
layout: asset.layout,
|
|
67
|
+
revision,
|
|
68
|
+
createdAt,
|
|
69
|
+
size,
|
|
70
|
+
entries: input.entries.map((e) =>
|
|
71
|
+
e.type === 'dir'
|
|
72
|
+
? e
|
|
73
|
+
: {
|
|
74
|
+
type: 'file',
|
|
75
|
+
path: e.path,
|
|
76
|
+
size: e.content.byteLength,
|
|
77
|
+
blobRef: contentHash(e.content),
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
},
|
|
81
|
+
entries: input.entries,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async getTicketAsset(repo: string, id: string, kind: string, name: string, revision?: number) {
|
|
85
|
+
const { front } = await this.load(id)
|
|
86
|
+
const history = (await this.history(id, front))[this.key(kind, name)]
|
|
87
|
+
if (!history) return null
|
|
88
|
+
const index = revision === undefined ? history.length - 1 : revision - FIRST_REVISION
|
|
89
|
+
const item = history[index]
|
|
90
|
+
return item?.asset
|
|
91
|
+
? this.materialize(repo, id, item.asset, FIRST_REVISION + index, item.createdAt)
|
|
92
|
+
: null
|
|
93
|
+
}
|
|
94
|
+
async listTicketAssets(repo: string, id: string) {
|
|
95
|
+
const { front } = await this.load(id)
|
|
96
|
+
const history = await this.history(id, front)
|
|
97
|
+
return Object.values(history).flatMap((items) => {
|
|
98
|
+
const item = items.at(-1)!
|
|
99
|
+
return item.asset
|
|
100
|
+
? [
|
|
101
|
+
summarizeTicketAsset(
|
|
102
|
+
this.materialize(
|
|
103
|
+
repo,
|
|
104
|
+
id,
|
|
105
|
+
item.asset,
|
|
106
|
+
FIRST_REVISION + items.length - 1,
|
|
107
|
+
item.createdAt,
|
|
108
|
+
).meta,
|
|
109
|
+
),
|
|
110
|
+
]
|
|
111
|
+
: []
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
private async persist(
|
|
115
|
+
id: string,
|
|
116
|
+
history: History,
|
|
117
|
+
loaded: Awaited<ReturnType<FileTicketAssets['load']>>,
|
|
118
|
+
) {
|
|
119
|
+
const path = this.path(id)
|
|
120
|
+
await mkdir(join(this.dir, '.ab-assets'), { recursive: true })
|
|
121
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`
|
|
122
|
+
await writeFile(temp, JSON.stringify(history))
|
|
123
|
+
await rename(temp, path)
|
|
124
|
+
// The journal is authoritative after interruption; repair the portable projection on retry.
|
|
125
|
+
loaded.front.attachments = Object.values(history).flatMap((items) =>
|
|
126
|
+
items.at(-1)!.asset ? [items.at(-1)!.asset!] : [],
|
|
127
|
+
)
|
|
128
|
+
await loaded.save(loaded.front)
|
|
129
|
+
}
|
|
130
|
+
async putTicketAsset(repo: string, id: string, input: TicketAssetInput) {
|
|
131
|
+
validateTicketAssetInput(input)
|
|
132
|
+
const loaded = await this.load(id)
|
|
133
|
+
if (loaded.exists === false)
|
|
134
|
+
throw new Error(`file ticket source: asset on unknown ticket "${id}"`)
|
|
135
|
+
const history = await this.history(id, loaded.front)
|
|
136
|
+
const key = this.key(input.kind, input.name)
|
|
137
|
+
const items = history[key] ?? []
|
|
138
|
+
history[key] = items
|
|
139
|
+
const asset = encodePortableAsset(input)
|
|
140
|
+
const createdAt = new Date().toISOString()
|
|
141
|
+
items.push({ asset, createdAt })
|
|
142
|
+
await this.persist(id, history, loaded)
|
|
143
|
+
return this.materialize(repo, id, asset, FIRST_REVISION + items.length - 1, createdAt).meta
|
|
144
|
+
}
|
|
145
|
+
async removeTicketAsset(repo: string, id: string, kind: string, name: string) {
|
|
146
|
+
const current = await this.getTicketAsset(repo, id, kind, name)
|
|
147
|
+
const loaded = await this.load(id)
|
|
148
|
+
if (loaded.exists === false)
|
|
149
|
+
throw new Error(`file ticket source: asset on unknown ticket "${id}"`)
|
|
150
|
+
const history = await this.history(id, loaded.front)
|
|
151
|
+
const key = this.key(kind, name)
|
|
152
|
+
if (history[key]?.at(-1)?.asset === null) return null
|
|
153
|
+
const items = history[key] ?? []
|
|
154
|
+
history[key] = items
|
|
155
|
+
items.push({ asset: null, createdAt: new Date().toISOString() })
|
|
156
|
+
await this.persist(id, history, loaded)
|
|
157
|
+
return current?.meta ?? null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { portableAssetSchema } from './portable-assets'
|
|
2
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
const frontmatterSchema = z.strictObject({
|
|
6
|
+
comments: z
|
|
7
|
+
.array(
|
|
8
|
+
z.strictObject({ body: z.string(), createdAt: z.string(), author: z.string().optional() }),
|
|
9
|
+
)
|
|
10
|
+
.optional(),
|
|
11
|
+
attachments: z
|
|
12
|
+
.array(portableAssetSchema)
|
|
13
|
+
.superRefine((assets, ctx) => {
|
|
14
|
+
const names = new Set<string>()
|
|
15
|
+
for (const asset of assets) {
|
|
16
|
+
const key = `${asset.kind}/${asset.name}`
|
|
17
|
+
if (names.has(key))
|
|
18
|
+
ctx.addIssue({ code: 'custom', message: `duplicate attachment: ${key}` })
|
|
19
|
+
names.add(key)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.optional(),
|
|
23
|
+
id: z.string().min(1),
|
|
24
|
+
title: z.string().min(1),
|
|
25
|
+
labels: z.array(z.string()).default([]),
|
|
26
|
+
/** Source-local blocker ids (§13). Absent ≡ no dependencies, which keeps
|
|
27
|
+
* every pre-existing ticket file valid. */
|
|
28
|
+
blockedBy: z.array(z.string()).optional(),
|
|
29
|
+
/** Stable external-create adoption key. It travels with the ticket across
|
|
30
|
+
* state-directory moves, so retries find it in any lifecycle state. */
|
|
31
|
+
idempotencyKey: z.string().min(1).optional(),
|
|
32
|
+
})
|
|
33
|
+
export type Frontmatter = z.infer<typeof frontmatterSchema>
|
|
34
|
+
|
|
35
|
+
const OPEN_FENCE = '+++\n'
|
|
36
|
+
const CLOSE_FENCE = '\n+++\n'
|
|
37
|
+
|
|
38
|
+
/** A malformed record that a complete tracker scan may safely exclude. This is
|
|
39
|
+
* deliberately narrower than ordinary Error: filesystem failures, layout
|
|
40
|
+
* violations, duplicate ids, and unexpected exceptions must still abort the
|
|
41
|
+
* listing. */
|
|
42
|
+
export class TicketFileValidationError extends Error {
|
|
43
|
+
override readonly name = 'TicketFileValidationError'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseTicketFile(path: string, raw: string): { front: Frontmatter; body: string } {
|
|
47
|
+
if (!raw.startsWith(OPEN_FENCE)) {
|
|
48
|
+
throw new TicketFileValidationError(
|
|
49
|
+
`${path}: malformed ticket file — missing opening "+++" fence`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const close = raw.indexOf(CLOSE_FENCE, OPEN_FENCE.length)
|
|
53
|
+
if (close === -1) {
|
|
54
|
+
throw new TicketFileValidationError(
|
|
55
|
+
`${path}: malformed ticket file — missing closing "+++" fence`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
let parsed: unknown
|
|
59
|
+
try {
|
|
60
|
+
parsed = parseToml(raw.slice(OPEN_FENCE.length, close))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
63
|
+
throw new TicketFileValidationError(`${path}: malformed TOML frontmatter — ${message}`)
|
|
64
|
+
}
|
|
65
|
+
const result = frontmatterSchema.safeParse(parsed)
|
|
66
|
+
if (!result.success) {
|
|
67
|
+
const issues = result.error.issues
|
|
68
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
69
|
+
.join('; ')
|
|
70
|
+
throw new TicketFileValidationError(`${path}: invalid frontmatter — ${issues}`)
|
|
71
|
+
}
|
|
72
|
+
return { front: result.data, body: raw.slice(close + CLOSE_FENCE.length) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function serializeTicketFile(front: Frontmatter, body: string): string {
|
|
76
|
+
const data: Record<string, unknown> = { id: front.id, title: front.title }
|
|
77
|
+
if (front.labels.length > 0) data.labels = front.labels
|
|
78
|
+
// Only when nonempty: a file that never declared blockers must round-trip
|
|
79
|
+
// byte-identically rather than sprouting `blockedBy = []` on every write.
|
|
80
|
+
if (front.blockedBy !== undefined && front.blockedBy.length > 0) {
|
|
81
|
+
data.blockedBy = front.blockedBy
|
|
82
|
+
}
|
|
83
|
+
if (front.idempotencyKey !== undefined) {
|
|
84
|
+
data.idempotencyKey = front.idempotencyKey
|
|
85
|
+
}
|
|
86
|
+
if (front.comments !== undefined) data.comments = front.comments
|
|
87
|
+
if (front.attachments !== undefined) data.attachments = front.attachments
|
|
88
|
+
// smol-toml stringify ends with a newline; the closing fence follows it.
|
|
89
|
+
return `${OPEN_FENCE}${stringifyToml(data)}+++\n${body}`
|
|
90
|
+
}
|