@defrex/autobuild 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -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. State renames never cross a
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 { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
27
- import { z } from 'zod'
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
- const STATE_NAME_RULE =
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
- const lifecycle: string[] = [...LIFECYCLE_DIRS]
69
- const extra = directories
70
- .filter((name) => !name.startsWith('.') && !lifecycle.includes(name))
71
- .sort()
72
- return [...lifecycle, ...extra]
73
- }
74
-
75
- /**
76
- * Resolve a state name to its directory. Case-insensitive in (a config's
77
- * `readyState = "ready"` means the `ready/` directory), the directory's real
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) // surface malformed files before appending
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
- await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
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 === undefined ? undefined : opts.about.trim()
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
- if (id.includes('/') || id.includes('\\') || id.includes('..')) {
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
- await writeFile(found.path, serializeTicketFile(front, body))
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,
@@ -0,0 +1,47 @@
1
+ import {
2
+ AUTOBUILD_VERSION,
3
+ AUTOBUILD_VERSION_HEADER,
4
+ REMOTE_STORE_PROTOCOL_VERSION,
5
+ REMOTE_STORE_PROTOCOL_VERSION_HEADER,
6
+ } from '../../store/remote/version'
7
+ import { ticketGrantResponseSchema } from './remote-protocol'
8
+
9
+ export async function ticketGrantRequest(url: string, token: string, input: unknown) {
10
+ const response = await fetch(`${url.replace(/\/+$/, '')}/tickets/grants`, {
11
+ method: 'POST',
12
+ headers: {
13
+ authorization: `Bearer ${token}`,
14
+ 'content-type': 'application/json',
15
+ [AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
16
+ [REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
17
+ },
18
+ body: JSON.stringify(input),
19
+ redirect: 'error',
20
+ signal: AbortSignal.timeout(15000),
21
+ })
22
+ // Teardown can race guest/host cleanup; an ended grant is already revoked.
23
+ if (
24
+ response.status === 401 &&
25
+ typeof input === 'object' &&
26
+ input !== null &&
27
+ 'action' in input &&
28
+ input.action === 'revoke'
29
+ )
30
+ return { ok: true }
31
+ if (!response.ok)
32
+ throw new Error(
33
+ `Ticket grant request failed (${response.status}); refusing project-authority fallback`,
34
+ )
35
+ return response.json()
36
+ }
37
+ export async function issueTicketGrant(
38
+ url: string,
39
+ token: string,
40
+ repo: string,
41
+ execution: string,
42
+ kind: 'runner' | 'phase',
43
+ ) {
44
+ return ticketGrantResponseSchema.parse(
45
+ await ticketGrantRequest(url, token, { action: 'issue', repo, execution, kind }),
46
+ )
47
+ }
@@ -5,7 +5,24 @@ export { HostedTicketSource } from './remote'
5
5
  export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
6
6
  export {
7
7
  HOSTED_TICKET_OPERATIONS,
8
+ TICKET_COMMENTS_HEADER,
9
+ TICKET_COMMENTS_CAPABILITY,
8
10
  hostedTicketContextSchema,
9
11
  hostedTicketRequestSchemas,
12
+ TICKET_REVISIONS_HEADER,
13
+ TICKET_REVISIONS_CAPABILITY,
10
14
  } from './remote-protocol'
11
15
  export type { HostedTicketContext, HostedTicketOperation } from './remote-protocol'
16
+
17
+ export {
18
+ TicketRevisionConflict,
19
+ TicketRevisionUnsupported,
20
+ mutateTicketIfRevision,
21
+ } from './revision'
22
+
23
+ export { hostedTicketPolicySchema, type HostedTicketPolicy } from './remote-protocol'
24
+ export { ticketLifecycle } from './lifecycle'
25
+
26
+ export { orderStates, resolveState, validateStateName, validateStatePurpose } from './states'
27
+
28
+ export { hostedRequestedIds, MAX_TICKET_NUMBER } from './requested-id'
@@ -0,0 +1,20 @@
1
+ import type { TicketRef } from '../../ontology'
2
+ import type { TicketSource } from '../types'
3
+
4
+ /** A per-operation comparison view. Durable references and proposal keys stay exact. */
5
+ export function ticketIdentity(source: TicketSource): (ref: TicketRef) => Promise<string> {
6
+ const cache = new Map<string, Promise<string>>()
7
+ return async (ref) => {
8
+ if (ref.source !== source.name) return `${ref.source}:${ref.id}`
9
+ let pending = cache.get(ref.id)
10
+ if (!pending) {
11
+ pending = source
12
+ .get(ref.id)
13
+ .then(
14
+ (ticket) => `${ref.source}:${ticket?.ref.source === ref.source ? ticket.ref.id : ref.id}`,
15
+ )
16
+ cache.set(ref.id, pending)
17
+ }
18
+ return pending
19
+ }
20
+ }