@defrex/autobuild 0.11.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 +3 -1
- package/SPEC.md +335 -31
- package/docs/architecture.md +125 -7
- package/docs/configuration.md +142 -21
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +240 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +97 -7
- package/docs/remote-store-protocol.md +199 -144
- package/docs/remote-ticket-protocol.md +249 -19
- package/docs/setup.md +199 -16
- package/docs/ticket-dependencies.md +1 -1
- package/docs/ticket-migration.md +64 -0
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +364 -0
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +336 -41
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +51 -16
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +60 -16
- package/packages/core/src/cli/mcp.ts +9 -2
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +15 -1
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- 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 +172 -35
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +90 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +11 -2
- package/packages/core/src/events/payloads.ts +50 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +11 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +10 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +2 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +12 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -20
- package/packages/core/src/operator/tickets.ts +136 -28
- package/packages/core/src/plugin-sdk/index.ts +7 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +7 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +3 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +54 -10
- 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 -3
- 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 +96 -0
- package/packages/core/src/ports/tickets/remote.ts +61 -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 +6 -1
- package/packages/core/src/ports/workspace/create.ts +15 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +168 -41
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +31 -4
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +166 -104
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +2 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- 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/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- 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/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +232 -34
- package/skills/guide/references/operator-api.md +142 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +199 -144
- package/skills/guide/references/remote-ticket-protocol.md +253 -18
- package/skills/guide/references/setup.md +199 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/operate/SKILL.md +126 -36
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fileRequestedIds, validateFileTicketId } from './requested-id'
|
|
2
|
+
import { FileTicketAssets } from './file-assets'
|
|
1
3
|
/**
|
|
2
4
|
* File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
|
|
3
5
|
* of state directories — the lifecycle four `triage/ ready/ doing/ done/`
|
|
@@ -17,14 +19,28 @@
|
|
|
17
19
|
* Concurrency: writes locate and validate immediately before a same-path
|
|
18
20
|
* rewrite or same-filesystem rename; there is no filesystem lock. The
|
|
19
21
|
* dispatcher remains the lifecycle single writer (SPEC §12), and concurrent
|
|
20
|
-
* human edits are explicitly last-write-wins.
|
|
22
|
+
* unguarded human edits are explicitly last-write-wins. Revision-checked writes
|
|
23
|
+
* compare the final read, retaining the same unlocked read/write race window. State renames never cross a
|
|
21
24
|
* filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
|
|
22
25
|
*/
|
|
26
|
+
import {
|
|
27
|
+
STATE_NAME_RULE,
|
|
28
|
+
validateStateName,
|
|
29
|
+
validateStatePurpose,
|
|
30
|
+
orderStates,
|
|
31
|
+
resolveState,
|
|
32
|
+
} from './states'
|
|
23
33
|
import { readdirSync, statSync } from 'node:fs'
|
|
24
34
|
import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
25
35
|
import { join } from 'node:path'
|
|
26
|
-
import {
|
|
27
|
-
|
|
36
|
+
import {
|
|
37
|
+
parseTicketFile,
|
|
38
|
+
serializeTicketFile,
|
|
39
|
+
TicketFileValidationError,
|
|
40
|
+
type Frontmatter,
|
|
41
|
+
} from './file-codec'
|
|
42
|
+
export { TicketFileValidationError } from './file-codec'
|
|
43
|
+
import { ticketRevision, checkTicketRevision } from './revision'
|
|
28
44
|
import { systemClock, type Clock } from '../../store/types'
|
|
29
45
|
import type {
|
|
30
46
|
DependencyState,
|
|
@@ -35,6 +51,7 @@ import type {
|
|
|
35
51
|
TicketSource,
|
|
36
52
|
TicketStateInfo,
|
|
37
53
|
TicketUpdate,
|
|
54
|
+
TicketMutation,
|
|
38
55
|
} from '../types'
|
|
39
56
|
import { validateTicketUpdate } from './update'
|
|
40
57
|
|
|
@@ -51,116 +68,22 @@ const LIFECYCLE_DIRS = ['triage', 'ready', 'doing', 'done'] as const
|
|
|
51
68
|
* and non-`.md` files are never tickets, so it can never be mistaken for one. */
|
|
52
69
|
const ABOUT_FILE = '.about'
|
|
53
70
|
|
|
54
|
-
|
|
55
|
-
'a state name is one to forty characters of lowercase letters, digits, and hyphens, starting with a letter'
|
|
56
|
-
const STATE_NAME_RE = /^[a-z][a-z0-9-]{0,39}$/
|
|
71
|
+
export { validateStateName } from './states'
|
|
57
72
|
|
|
58
|
-
/** Throws, stating the rule, unless `name` may be used to create a state. */
|
|
59
|
-
export function validateStateName(name: string): void {
|
|
60
|
-
if (!STATE_NAME_RE.test(name)) {
|
|
61
|
-
throw new Error(`file ticket source: invalid state name "${name}" — ${STATE_NAME_RULE}`)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The state names a root listing yields: the lifecycle four, then every other
|
|
66
|
-
* non-dot directory alphabetically. Dot entries and files are never states. */
|
|
67
73
|
function statesIn(directories: string[]): string[] {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* name out — and a name matching no directory is a loud error, because a
|
|
79
|
-
* typo'd state must not silently create a directory no `ls` would show.
|
|
80
|
-
*/
|
|
81
|
-
function resolveState(name: string, existing: string[]): string {
|
|
82
|
-
if (existing.includes(name)) return name
|
|
83
|
-
const matches = existing.filter((s) => s.toLowerCase() === name.toLowerCase())
|
|
84
|
-
if (matches.length === 1) return matches[0] as string
|
|
85
|
-
if (matches.length > 1) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
`file ticket source: ambiguous state "${name}" — it matches the directories ${matches.join(', ')}`,
|
|
88
|
-
)
|
|
89
|
-
}
|
|
90
|
-
throw new Error(
|
|
91
|
-
`file ticket source: unknown state "${name}" — this tracker's states are ` +
|
|
92
|
-
`the directories: ${existing.join(', ')}`,
|
|
74
|
+
return orderStates(
|
|
75
|
+
[
|
|
76
|
+
...new Set([
|
|
77
|
+
...LIFECYCLE_DIRS.filter(
|
|
78
|
+
(name) => !directories.some((dir) => dir.toLowerCase() === name && dir !== name),
|
|
79
|
+
),
|
|
80
|
+
...directories.filter((name) => !name.startsWith('.')),
|
|
81
|
+
]),
|
|
82
|
+
],
|
|
83
|
+
LIFECYCLE_DIRS,
|
|
93
84
|
)
|
|
94
85
|
}
|
|
95
86
|
|
|
96
|
-
const frontmatterSchema = z.strictObject({
|
|
97
|
-
id: z.string().min(1),
|
|
98
|
-
title: z.string().min(1),
|
|
99
|
-
labels: z.array(z.string()).default([]),
|
|
100
|
-
/** Source-local blocker ids (§13). Absent ≡ no dependencies, which keeps
|
|
101
|
-
* every pre-existing ticket file valid. */
|
|
102
|
-
blockedBy: z.array(z.string()).optional(),
|
|
103
|
-
/** Stable external-create adoption key. It travels with the ticket across
|
|
104
|
-
* state-directory moves, so retries find it in any lifecycle state. */
|
|
105
|
-
idempotencyKey: z.string().min(1).optional(),
|
|
106
|
-
})
|
|
107
|
-
type Frontmatter = z.infer<typeof frontmatterSchema>
|
|
108
|
-
|
|
109
|
-
const OPEN_FENCE = '+++\n'
|
|
110
|
-
const CLOSE_FENCE = '\n+++\n'
|
|
111
|
-
|
|
112
|
-
/** A malformed record that a complete tracker scan may safely exclude. This is
|
|
113
|
-
* deliberately narrower than ordinary Error: filesystem failures, layout
|
|
114
|
-
* violations, duplicate ids, and unexpected exceptions must still abort the
|
|
115
|
-
* listing. */
|
|
116
|
-
export class TicketFileValidationError extends Error {
|
|
117
|
-
override readonly name = 'TicketFileValidationError'
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function parseTicketFile(path: string, raw: string): { front: Frontmatter; body: string } {
|
|
121
|
-
if (!raw.startsWith(OPEN_FENCE)) {
|
|
122
|
-
throw new TicketFileValidationError(
|
|
123
|
-
`${path}: malformed ticket file — missing opening "+++" fence`,
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
const close = raw.indexOf(CLOSE_FENCE, OPEN_FENCE.length)
|
|
127
|
-
if (close === -1) {
|
|
128
|
-
throw new TicketFileValidationError(
|
|
129
|
-
`${path}: malformed ticket file — missing closing "+++" fence`,
|
|
130
|
-
)
|
|
131
|
-
}
|
|
132
|
-
let parsed: unknown
|
|
133
|
-
try {
|
|
134
|
-
parsed = parseToml(raw.slice(OPEN_FENCE.length, close))
|
|
135
|
-
} catch (error) {
|
|
136
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
137
|
-
throw new TicketFileValidationError(`${path}: malformed TOML frontmatter — ${message}`)
|
|
138
|
-
}
|
|
139
|
-
const result = frontmatterSchema.safeParse(parsed)
|
|
140
|
-
if (!result.success) {
|
|
141
|
-
const issues = result.error.issues
|
|
142
|
-
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
143
|
-
.join('; ')
|
|
144
|
-
throw new TicketFileValidationError(`${path}: invalid frontmatter — ${issues}`)
|
|
145
|
-
}
|
|
146
|
-
return { front: result.data, body: raw.slice(close + CLOSE_FENCE.length) }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function serializeTicketFile(front: Frontmatter, body: string): string {
|
|
150
|
-
const data: Record<string, unknown> = { id: front.id, title: front.title }
|
|
151
|
-
if (front.labels.length > 0) data.labels = front.labels
|
|
152
|
-
// Only when nonempty: a file that never declared blockers must round-trip
|
|
153
|
-
// byte-identically rather than sprouting `blockedBy = []` on every write.
|
|
154
|
-
if (front.blockedBy !== undefined && front.blockedBy.length > 0) {
|
|
155
|
-
data.blockedBy = front.blockedBy
|
|
156
|
-
}
|
|
157
|
-
if (front.idempotencyKey !== undefined) {
|
|
158
|
-
data.idempotencyKey = front.idempotencyKey
|
|
159
|
-
}
|
|
160
|
-
// smol-toml stringify ends with a newline; the closing fence follows it.
|
|
161
|
-
return `${OPEN_FENCE}${stringifyToml(data)}+++\n${body}`
|
|
162
|
-
}
|
|
163
|
-
|
|
164
87
|
/** A ticket file found on disk: its id, its state (= its directory), its path. */
|
|
165
88
|
interface Located {
|
|
166
89
|
id: string
|
|
@@ -170,6 +93,8 @@ interface Located {
|
|
|
170
93
|
|
|
171
94
|
export class FileTicketSource implements TicketSource {
|
|
172
95
|
readonly name = 'file'
|
|
96
|
+
readonly requestedIds = fileRequestedIds
|
|
97
|
+
readonly assets: FileTicketAssets
|
|
173
98
|
|
|
174
99
|
private readonly dir: string
|
|
175
100
|
private readonly clock: Clock
|
|
@@ -198,6 +123,22 @@ export class FileTicketSource implements TicketSource {
|
|
|
198
123
|
*/
|
|
199
124
|
selfIgnore?: boolean
|
|
200
125
|
}) {
|
|
126
|
+
this.assets = new FileTicketAssets(opts.dir, async (id) => {
|
|
127
|
+
const found = await this.locate(id)
|
|
128
|
+
if (!found)
|
|
129
|
+
return {
|
|
130
|
+
exists: false,
|
|
131
|
+
front: { id, title: id, labels: [] },
|
|
132
|
+
save: async () => {
|
|
133
|
+
throw new Error(`file ticket source: asset on unknown ticket "${id}"`)
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
const loaded = { found, ...(await this.loadAt(found)) }
|
|
137
|
+
return {
|
|
138
|
+
...loaded,
|
|
139
|
+
save: (front: Frontmatter) => this.rewriteAt(loaded.found, front, loaded.body),
|
|
140
|
+
}
|
|
141
|
+
})
|
|
201
142
|
this.dir = opts.dir
|
|
202
143
|
this.clock = opts.clock ?? systemClock
|
|
203
144
|
const existing = this.statesSync()
|
|
@@ -273,7 +214,12 @@ export class FileTicketSource implements TicketSource {
|
|
|
273
214
|
throw new Error(`file ticket source: comment on unknown ticket "${id}"`)
|
|
274
215
|
}
|
|
275
216
|
const raw = await readFile(found.path, 'utf8')
|
|
276
|
-
parseTicketFile(found.path, raw)
|
|
217
|
+
const parsed = parseTicketFile(found.path, raw)
|
|
218
|
+
if (parsed.front.comments !== undefined) {
|
|
219
|
+
parsed.front.comments.push({ body, createdAt: this.clock().toISOString() })
|
|
220
|
+
await this.rewriteAt(found, parsed.front, parsed.body)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
277
223
|
const separator = raw.endsWith('\n') ? '\n' : '\n\n'
|
|
278
224
|
const stamp = this.clock().toISOString()
|
|
279
225
|
await writeFile(found.path, `${raw}${separator}## Comment (${stamp})\n\n${body}\n`)
|
|
@@ -301,6 +247,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
301
247
|
/** Writes `<createState>/file-<n>.md` with the next free n (gaps reused),
|
|
302
248
|
* or adopts the ticket carrying the same idempotency key. */
|
|
303
249
|
async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
|
|
250
|
+
if (opts.requestedId !== undefined) validateFileTicketId(opts.requestedId)
|
|
304
251
|
await this.ensureLayout()
|
|
305
252
|
// Resolve before anything is read or written: an unknown state touches no file.
|
|
306
253
|
const targetState =
|
|
@@ -332,7 +279,8 @@ export class FileTicketSource implements TicketSource {
|
|
|
332
279
|
let n = 1
|
|
333
280
|
while (taken.has(`file-${n}`)) n += 1
|
|
334
281
|
const front: Frontmatter = {
|
|
335
|
-
id: `file-${n}`,
|
|
282
|
+
id: opts.requestedId ?? `file-${n}`,
|
|
283
|
+
comments: [],
|
|
336
284
|
title: draft.title,
|
|
337
285
|
labels: [...(draft.labels ?? [])],
|
|
338
286
|
...(draft.blockedBy !== undefined && draft.blockedBy.length > 0
|
|
@@ -340,10 +288,50 @@ export class FileTicketSource implements TicketSource {
|
|
|
340
288
|
: {}),
|
|
341
289
|
...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
342
290
|
}
|
|
343
|
-
|
|
291
|
+
if (taken.has(front.id)) throw new Error(`requested ticket id already occupied: ${front.id}`)
|
|
292
|
+
await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body), {
|
|
293
|
+
flag: 'wx',
|
|
294
|
+
})
|
|
344
295
|
return this.toTicket(front, draft.body, targetState)
|
|
345
296
|
}
|
|
346
297
|
|
|
298
|
+
async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
|
|
299
|
+
const patch = mutation.kind === 'update' ? validateTicketUpdate(mutation.patch) : undefined
|
|
300
|
+
await this.ensureLayout()
|
|
301
|
+
const target =
|
|
302
|
+
mutation.kind === 'transition' ? resolveState(mutation.state, await this.states()) : undefined
|
|
303
|
+
if (mutation.kind === 'blockers') {
|
|
304
|
+
for (const blocker of new Set(mutation.ids)) {
|
|
305
|
+
if (mutation.operation === 'block' && blocker === id)
|
|
306
|
+
throw new Error(`ticket "${id}" cannot block itself`)
|
|
307
|
+
await this.requireForWrite(blocker, 'blocker')
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Final locate/read immediately precedes the write. This is best effort,
|
|
311
|
+
// matching unguarded file writes; it does not introduce filesystem locks.
|
|
312
|
+
const loaded = await this.requireForWrite(id, 'revision-checked write')
|
|
313
|
+
checkTicketRevision(this.toTicket(loaded.front, loaded.body, loaded.found.state), expected)
|
|
314
|
+
if (target !== undefined) {
|
|
315
|
+
if (loaded.found.state !== target) await rename(loaded.found.path, this.pathIn(target, id))
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const front = { ...loaded.front }
|
|
319
|
+
let body = loaded.body
|
|
320
|
+
if (patch) {
|
|
321
|
+
if (patch.title !== undefined) front.title = patch.title
|
|
322
|
+
if (patch.labels !== undefined) front.labels = patch.labels
|
|
323
|
+
if (patch.body !== undefined) body = patch.body
|
|
324
|
+
} else if (mutation.kind === 'blockers') {
|
|
325
|
+
const blockers = new Set(front.blockedBy ?? [])
|
|
326
|
+
for (const blocker of mutation.ids) {
|
|
327
|
+
if (mutation.operation === 'block') blockers.add(blocker)
|
|
328
|
+
else blockers.delete(blocker)
|
|
329
|
+
}
|
|
330
|
+
front.blockedBy = [...blockers]
|
|
331
|
+
}
|
|
332
|
+
await this.rewriteAt(loaded.found, front, body)
|
|
333
|
+
}
|
|
334
|
+
|
|
347
335
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
348
336
|
const validated = validateTicketUpdate(patch)
|
|
349
337
|
await this.ensureLayout()
|
|
@@ -448,10 +436,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
448
436
|
opts: { about?: string } = {},
|
|
449
437
|
): Promise<{ state: TicketStateInfo; created: boolean }> {
|
|
450
438
|
validateStateName(name)
|
|
451
|
-
const about = opts.about
|
|
452
|
-
if (about !== undefined && (about === '' || /[\r\n]/.test(about))) {
|
|
453
|
-
throw new Error('file ticket source: a state purpose must be one nonblank line')
|
|
454
|
-
}
|
|
439
|
+
const about = validateStatePurpose(opts.about)
|
|
455
440
|
const existing = await this.states()
|
|
456
441
|
const clash = existing.find((s) => s !== name && s.toLowerCase() === name.toLowerCase())
|
|
457
442
|
if (clash !== undefined) {
|
|
@@ -537,10 +522,7 @@ export class FileTicketSource implements TicketSource {
|
|
|
537
522
|
}
|
|
538
523
|
|
|
539
524
|
private safeId(id: string): string {
|
|
540
|
-
|
|
541
|
-
throw new Error(`file ticket source: invalid ticket id "${id}"`)
|
|
542
|
-
}
|
|
543
|
-
return id
|
|
525
|
+
return validateFileTicketId(id)
|
|
544
526
|
}
|
|
545
527
|
|
|
546
528
|
/** Find the one state dir holding `<id>.md`; null when absent. */
|
|
@@ -632,7 +614,9 @@ export class FileTicketSource implements TicketSource {
|
|
|
632
614
|
/** Rewrite only the located file: editable writes can never move lifecycle
|
|
633
615
|
* state because the existing state-directory path remains authoritative. */
|
|
634
616
|
private async rewriteAt(found: Located, front: Frontmatter, body: string): Promise<void> {
|
|
635
|
-
|
|
617
|
+
const temp = `${found.path}.${crypto.randomUUID()}.tmp`
|
|
618
|
+
await writeFile(temp, serializeTicketFile(front, body))
|
|
619
|
+
await rename(temp, found.path)
|
|
636
620
|
}
|
|
637
621
|
|
|
638
622
|
private async listAll(): Promise<TicketListing> {
|
|
@@ -658,8 +642,18 @@ export class FileTicketSource implements TicketSource {
|
|
|
658
642
|
|
|
659
643
|
private toTicket(front: Frontmatter, body: string, state: string): Ticket {
|
|
660
644
|
return {
|
|
645
|
+
revision: ticketRevision({
|
|
646
|
+
title: front.title,
|
|
647
|
+
body,
|
|
648
|
+
labels: front.labels,
|
|
649
|
+
state,
|
|
650
|
+
blockedBy: front.blockedBy,
|
|
651
|
+
...(front.comments !== undefined ? { comments: front.comments } : {}),
|
|
652
|
+
...(front.attachments !== undefined ? { attachments: front.attachments } : {}),
|
|
653
|
+
}),
|
|
661
654
|
ref: { source: this.name, id: front.id, title: front.title },
|
|
662
655
|
...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
|
|
656
|
+
...(front.comments !== undefined ? { comments: structuredClone(front.comments) } : {}),
|
|
663
657
|
title: front.title,
|
|
664
658
|
body,
|
|
665
659
|
state,
|