@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based TicketSource (SPEC §3.2, §13): the default tracker. A directory
|
|
3
|
+
* of state directories — `triage/ ready/ doing/ done/` — each holding
|
|
4
|
+
* `<id>.md` files: TOML frontmatter between `+++` fences, then the ticket
|
|
5
|
+
* body, where the spec lives pre-build (§6.3). This adapter is the policy's
|
|
6
|
+
* proof: a source with nowhere to put blobs must be fully workable, because
|
|
7
|
+
* the tracker initiates and receives projections only — never consulted
|
|
8
|
+
* mid-build, never artifact storage.
|
|
9
|
+
*
|
|
10
|
+
* A ticket's state IS the directory it sits in. There is no `state` field to
|
|
11
|
+
* disagree with the filesystem, and `transition` and `claim` are rename(2).
|
|
12
|
+
* Moving a valid record from `triage/` to `ready/` is the complete
|
|
13
|
+
* lifecycle-state grooming UX; listing validates every candidate before it can
|
|
14
|
+
* become eligible.
|
|
15
|
+
*
|
|
16
|
+
* Concurrency: writes locate and validate immediately before a same-path
|
|
17
|
+
* rewrite or same-filesystem rename; there is no filesystem lock. The
|
|
18
|
+
* dispatcher remains the lifecycle single writer (SPEC §12), and concurrent
|
|
19
|
+
* human edits are explicitly last-write-wins. State renames never cross a
|
|
20
|
+
* filesystem: `<dir>/ready` → `<dir>/doing` is one mount by construction.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
25
|
+
import { z } from 'zod'
|
|
26
|
+
import { systemClock, type Clock } from '../../store/types'
|
|
27
|
+
import type {
|
|
28
|
+
DependencyState,
|
|
29
|
+
Ticket,
|
|
30
|
+
TicketCreateOptions,
|
|
31
|
+
TicketDraft,
|
|
32
|
+
TicketListing,
|
|
33
|
+
TicketSource,
|
|
34
|
+
TicketUpdate,
|
|
35
|
+
} from '../types'
|
|
36
|
+
import { validateTicketUpdate } from './update'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The canonical states, in workflow order. This set is closed: the states are
|
|
40
|
+
* directories, and a tracker whose states are discoverable by `ls` cannot also
|
|
41
|
+
* let config invent new ones.
|
|
42
|
+
*/
|
|
43
|
+
export const TICKET_STATES = ['Triage', 'Ready', 'Doing', 'Done'] as const
|
|
44
|
+
export type TicketState = (typeof TICKET_STATES)[number]
|
|
45
|
+
|
|
46
|
+
/** Repo-relative fallback when no selected local-state root is supplied. */
|
|
47
|
+
export const DEFAULT_TICKETS_DIR = '.autobuild/tickets'
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canonicalize a state name to its directory. Case-insensitive in (a config's
|
|
51
|
+
* `readyState = "ready"` means the `ready/` directory), canonical out — and
|
|
52
|
+
* anything else is a loud error, because a typo'd state must not silently
|
|
53
|
+
* create a fifth directory no `ls` of the four would ever show.
|
|
54
|
+
*/
|
|
55
|
+
export function stateDir(state: string): TicketState {
|
|
56
|
+
const match = TICKET_STATES.find((s) => s.toLowerCase() === state.toLowerCase())
|
|
57
|
+
if (match === undefined) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`file ticket source: unknown state "${state}" — this tracker's states are ` +
|
|
60
|
+
`the directories: ${TICKET_STATES.join(', ')}`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
return match
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function dirName(state: TicketState): string {
|
|
67
|
+
return state.toLowerCase()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const frontmatterSchema = z.strictObject({
|
|
71
|
+
id: z.string().min(1),
|
|
72
|
+
title: z.string().min(1),
|
|
73
|
+
labels: z.array(z.string()).default([]),
|
|
74
|
+
/** Source-local blocker ids (§13). Absent ≡ no dependencies, which keeps
|
|
75
|
+
* every pre-existing ticket file valid. */
|
|
76
|
+
blockedBy: z.array(z.string()).optional(),
|
|
77
|
+
/** Stable external-create adoption key. It travels with the ticket across
|
|
78
|
+
* state-directory moves, so retries find it in any lifecycle state. */
|
|
79
|
+
idempotencyKey: z.string().min(1).optional(),
|
|
80
|
+
})
|
|
81
|
+
type Frontmatter = z.infer<typeof frontmatterSchema>
|
|
82
|
+
|
|
83
|
+
const OPEN_FENCE = '+++\n'
|
|
84
|
+
const CLOSE_FENCE = '\n+++\n'
|
|
85
|
+
|
|
86
|
+
/** A malformed record that a complete tracker scan may safely exclude. This is
|
|
87
|
+
* deliberately narrower than ordinary Error: filesystem failures, layout
|
|
88
|
+
* violations, duplicate ids, and unexpected exceptions must still abort the
|
|
89
|
+
* listing. */
|
|
90
|
+
export class TicketFileValidationError extends Error {
|
|
91
|
+
override readonly name = 'TicketFileValidationError'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseTicketFile(path: string, raw: string): { front: Frontmatter; body: string } {
|
|
95
|
+
if (!raw.startsWith(OPEN_FENCE)) {
|
|
96
|
+
throw new TicketFileValidationError(
|
|
97
|
+
`${path}: malformed ticket file — missing opening "+++" fence`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
const close = raw.indexOf(CLOSE_FENCE, OPEN_FENCE.length)
|
|
101
|
+
if (close === -1) {
|
|
102
|
+
throw new TicketFileValidationError(
|
|
103
|
+
`${path}: malformed ticket file — missing closing "+++" fence`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
let parsed: unknown
|
|
107
|
+
try {
|
|
108
|
+
parsed = parseToml(raw.slice(OPEN_FENCE.length, close))
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
111
|
+
throw new TicketFileValidationError(`${path}: malformed TOML frontmatter — ${message}`)
|
|
112
|
+
}
|
|
113
|
+
const result = frontmatterSchema.safeParse(parsed)
|
|
114
|
+
if (!result.success) {
|
|
115
|
+
const issues = result.error.issues
|
|
116
|
+
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
|
117
|
+
.join('; ')
|
|
118
|
+
throw new TicketFileValidationError(`${path}: invalid frontmatter — ${issues}`)
|
|
119
|
+
}
|
|
120
|
+
return { front: result.data, body: raw.slice(close + CLOSE_FENCE.length) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function serializeTicketFile(front: Frontmatter, body: string): string {
|
|
124
|
+
const data: Record<string, unknown> = { id: front.id, title: front.title }
|
|
125
|
+
if (front.labels.length > 0) data.labels = front.labels
|
|
126
|
+
// Only when nonempty: a file that never declared blockers must round-trip
|
|
127
|
+
// byte-identically rather than sprouting `blockedBy = []` on every write.
|
|
128
|
+
if (front.blockedBy !== undefined && front.blockedBy.length > 0) {
|
|
129
|
+
data.blockedBy = front.blockedBy
|
|
130
|
+
}
|
|
131
|
+
if (front.idempotencyKey !== undefined) {
|
|
132
|
+
data.idempotencyKey = front.idempotencyKey
|
|
133
|
+
}
|
|
134
|
+
// smol-toml stringify ends with a newline; the closing fence follows it.
|
|
135
|
+
return `${OPEN_FENCE}${stringifyToml(data)}+++\n${body}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A ticket file found on disk: its id, its state (= its directory), its path. */
|
|
139
|
+
interface Located {
|
|
140
|
+
id: string
|
|
141
|
+
state: TicketState
|
|
142
|
+
path: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class FileTicketSource implements TicketSource {
|
|
146
|
+
readonly name = 'file'
|
|
147
|
+
|
|
148
|
+
private readonly dir: string
|
|
149
|
+
private readonly clock: Clock
|
|
150
|
+
private readonly createState: TicketState
|
|
151
|
+
private readonly doneState: TicketState
|
|
152
|
+
private readonly selfIgnore: boolean
|
|
153
|
+
|
|
154
|
+
constructor(opts: {
|
|
155
|
+
dir: string
|
|
156
|
+
/** Injectable for deterministic comment timestamps. */
|
|
157
|
+
clock?: Clock
|
|
158
|
+
/** State assigned by create — proposals land in Triage (SPEC §12). */
|
|
159
|
+
createState?: string
|
|
160
|
+
/**
|
|
161
|
+
* The state this source considers complete, for dependency resolution
|
|
162
|
+
* (§13: this adapter's native lifecycle). Default 'Done' — and, like every
|
|
163
|
+
* state here, it names a directory, so it is canonicalized rather than
|
|
164
|
+
* compared raw.
|
|
165
|
+
*/
|
|
166
|
+
doneState?: string
|
|
167
|
+
/**
|
|
168
|
+
* Write a self-excluding `<dir>/.gitignore`. Set ONLY for the defaulted
|
|
169
|
+
* local-state `tickets/` backlog (see createTicketSource): an explicitly
|
|
170
|
+
* configured dir is the user's directory, and ab does not decide git's
|
|
171
|
+
* view of it.
|
|
172
|
+
*/
|
|
173
|
+
selfIgnore?: boolean
|
|
174
|
+
}) {
|
|
175
|
+
this.dir = opts.dir
|
|
176
|
+
this.clock = opts.clock ?? systemClock
|
|
177
|
+
this.createState = stateDir(opts.createState ?? 'Triage')
|
|
178
|
+
this.doneState = stateDir(opts.doneState ?? 'Done')
|
|
179
|
+
this.selfIgnore = opts.selfIgnore ?? false
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
|
|
183
|
+
const labels = criteria.labels ?? []
|
|
184
|
+
const state = criteria.state === undefined ? undefined : stateDir(criteria.state)
|
|
185
|
+
// Through listAll, not a single readdir: scan completes its layout and
|
|
186
|
+
// duplicate-id checks before any per-record validation is contained. A
|
|
187
|
+
// malformed record can never weaken the invariant preventing a `cp`
|
|
188
|
+
// between state dirs from becoming a silent double-dispatch.
|
|
189
|
+
const listing = await this.listAll()
|
|
190
|
+
return {
|
|
191
|
+
tickets: listing.tickets.filter(
|
|
192
|
+
(ticket) =>
|
|
193
|
+
(state === undefined || ticket.state === state) &&
|
|
194
|
+
labels.every((label) => ticket.labels.includes(label)),
|
|
195
|
+
),
|
|
196
|
+
diagnostics: listing.diagnostics,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async get(id: string): Promise<Ticket | null> {
|
|
201
|
+
const found = await this.locate(id)
|
|
202
|
+
if (found === null) return null
|
|
203
|
+
const loaded = await this.loadAt(found)
|
|
204
|
+
return this.toTicket(loaded.front, loaded.body, found.state)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Claim-before-launch (SPEC §12): rename into `doing/`. The relocation IS
|
|
209
|
+
* the claim record — no `claimedBy` field, which is what makes "claiming
|
|
210
|
+
* visibly removes it from ready/" true.
|
|
211
|
+
*
|
|
212
|
+
* The guard refuses a ticket already in Doing or in this source's configured
|
|
213
|
+
* terminal state rather than requiring one in Ready. That inversion is
|
|
214
|
+
* deliberate: a legal `[tickets] readyState = "Triage"` would otherwise
|
|
215
|
+
* stall forever. Other nonterminal states remain claimable after an explicit
|
|
216
|
+
* handback, while terminal work can never be reopened by claim.
|
|
217
|
+
*/
|
|
218
|
+
async claim(id: string): Promise<boolean> {
|
|
219
|
+
await this.ensureLayout()
|
|
220
|
+
const found = await this.locate(id)
|
|
221
|
+
if (found === null) return false
|
|
222
|
+
// Revalidate at the targeted operation boundary, even when lifecycle state
|
|
223
|
+
// will make the claim a no-op. Besides keeping malformed records loud,
|
|
224
|
+
// this closes the list→claim window where a human could corrupt a record
|
|
225
|
+
// after it was listed but before the dispatcher claims it.
|
|
226
|
+
await this.loadAt(found)
|
|
227
|
+
if (found.state === 'Doing' || found.state === this.doneState) return false
|
|
228
|
+
await rename(found.path, this.pathIn('Doing', id))
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Projections flow outward only (SPEC §13): the comment is appended below
|
|
234
|
+
* the existing content, which is preserved byte-exactly — the spec in the
|
|
235
|
+
* body (§6.3) is never rewritten.
|
|
236
|
+
*/
|
|
237
|
+
async comment(id: string, body: string): Promise<void> {
|
|
238
|
+
await this.ensureLayout()
|
|
239
|
+
const found = await this.locate(id)
|
|
240
|
+
if (found === null) {
|
|
241
|
+
throw new Error(`file ticket source: comment on unknown ticket "${id}"`)
|
|
242
|
+
}
|
|
243
|
+
const raw = await readFile(found.path, 'utf8')
|
|
244
|
+
parseTicketFile(found.path, raw) // surface malformed files before appending
|
|
245
|
+
const separator = raw.endsWith('\n') ? '\n' : '\n\n'
|
|
246
|
+
const stamp = this.clock().toISOString()
|
|
247
|
+
await writeFile(found.path, `${raw}${separator}## Comment (${stamp})\n\n${body}\n`)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A move, never a rewrite — which is why the body survives byte-exactly. */
|
|
251
|
+
async transition(id: string, state: string): Promise<void> {
|
|
252
|
+
await this.ensureLayout()
|
|
253
|
+
const target = stateDir(state)
|
|
254
|
+
const found = await this.locate(id)
|
|
255
|
+
if (found === null) {
|
|
256
|
+
throw new Error(`file ticket source: transition on unknown ticket "${id}"`)
|
|
257
|
+
}
|
|
258
|
+
// Targeted writes remain loud for malformed records, including an
|
|
259
|
+
// otherwise-idempotent transition to the current state.
|
|
260
|
+
await this.loadAt(found)
|
|
261
|
+
// Idempotent by construction — the dispatcher retries transitions after a
|
|
262
|
+
// crash (file.test.ts explains the window). rename(2) would no-op here
|
|
263
|
+
// anyway, but resting idempotency on that POSIX subtlety would make it a
|
|
264
|
+
// property of the syscall rather than of this adapter.
|
|
265
|
+
if (found.state === target) return
|
|
266
|
+
await rename(found.path, this.pathIn(target, id))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Writes `<createState>/file-<n>.md` with the next free n (gaps reused),
|
|
270
|
+
* or adopts the ticket carrying the same idempotency key. */
|
|
271
|
+
async create(draft: TicketDraft, opts: TicketCreateOptions = {}): Promise<Ticket> {
|
|
272
|
+
await this.ensureLayout()
|
|
273
|
+
const located = await this.scan()
|
|
274
|
+
if (opts.idempotencyKey !== undefined) {
|
|
275
|
+
const matches: Array<{ found: Located; front: Frontmatter; body: string }> = []
|
|
276
|
+
for (const found of located) {
|
|
277
|
+
const loaded = await this.loadAt(found)
|
|
278
|
+
if (loaded.front.idempotencyKey === opts.idempotencyKey) {
|
|
279
|
+
matches.push({ found, ...loaded })
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (matches.length > 1) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`file ticket source: idempotency key "${opts.idempotencyKey}" exists on multiple tickets: ${matches.map((match) => match.found.id).join(', ')}`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
const adopted = matches[0]
|
|
288
|
+
if (adopted !== undefined) {
|
|
289
|
+
return this.toTicket(adopted.front, adopted.body, adopted.found.state)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const taken = new Set(located.map((f) => f.id))
|
|
293
|
+
let n = 1
|
|
294
|
+
while (taken.has(`file-${n}`)) n += 1
|
|
295
|
+
const front: Frontmatter = {
|
|
296
|
+
id: `file-${n}`,
|
|
297
|
+
title: draft.title,
|
|
298
|
+
labels: [...(draft.labels ?? [])],
|
|
299
|
+
...(draft.blockedBy !== undefined && draft.blockedBy.length > 0
|
|
300
|
+
? { blockedBy: [...draft.blockedBy] }
|
|
301
|
+
: {}),
|
|
302
|
+
...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}),
|
|
303
|
+
}
|
|
304
|
+
const targetState = opts.state === undefined ? this.createState : stateDir(opts.state)
|
|
305
|
+
await writeFile(this.pathIn(targetState, front.id), serializeTicketFile(front, draft.body))
|
|
306
|
+
return this.toTicket(front, draft.body, targetState)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
310
|
+
const validated = validateTicketUpdate(patch)
|
|
311
|
+
await this.ensureLayout()
|
|
312
|
+
const loaded = await this.requireForWrite(id, 'update')
|
|
313
|
+
const front: Frontmatter = {
|
|
314
|
+
...loaded.front,
|
|
315
|
+
...(validated.title !== undefined ? { title: validated.title } : {}),
|
|
316
|
+
...(validated.labels !== undefined ? { labels: [...validated.labels] } : {}),
|
|
317
|
+
}
|
|
318
|
+
await this.rewriteAt(loaded.found, front, validated.body ?? loaded.body)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async addBlocker(id: string, blockerId: string): Promise<void> {
|
|
322
|
+
await this.ensureLayout()
|
|
323
|
+
const loaded = await this.requireForWrite(id, 'addBlocker')
|
|
324
|
+
if (id === blockerId) {
|
|
325
|
+
throw new Error(`file ticket source: ticket "${id}" cannot block itself`)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const blocker = await this.locate(blockerId)
|
|
329
|
+
if (blocker === null) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`file ticket source: addBlocker on ticket "${id}" names unknown blocker "${blockerId}"`,
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
await this.loadAt(blocker) // Existence means a valid ticket, not only a path.
|
|
335
|
+
|
|
336
|
+
if (loaded.front.blockedBy?.includes(blockerId)) return
|
|
337
|
+
await this.rewriteAt(
|
|
338
|
+
loaded.found,
|
|
339
|
+
{
|
|
340
|
+
...loaded.front,
|
|
341
|
+
blockedBy: [...(loaded.front.blockedBy ?? []), blockerId],
|
|
342
|
+
},
|
|
343
|
+
loaded.body,
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async removeBlocker(id: string, blockerId: string): Promise<void> {
|
|
348
|
+
await this.ensureLayout()
|
|
349
|
+
const loaded = await this.requireForWrite(id, 'removeBlocker')
|
|
350
|
+
const remaining = (loaded.front.blockedBy ?? []).filter((candidate) => candidate !== blockerId)
|
|
351
|
+
if (remaining.length === (loaded.front.blockedBy ?? []).length) return
|
|
352
|
+
|
|
353
|
+
const front: Frontmatter = { ...loaded.front }
|
|
354
|
+
if (remaining.length === 0) delete front.blockedBy
|
|
355
|
+
else front.blockedBy = remaining
|
|
356
|
+
await this.rewriteAt(loaded.found, front, loaded.body)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Dependency nodes (§13). Resolution is this source's own lifecycle, and
|
|
361
|
+
* this source's lifecycle is the filesystem: a blocker is complete when its
|
|
362
|
+
* file sits in the done directory. Nothing is read from the frontmatter to
|
|
363
|
+
* decide it — there is no `state` field to disagree with the directory.
|
|
364
|
+
*
|
|
365
|
+
* An id that cannot name a file (path traversal) is reported as
|
|
366
|
+
* `exists: false` rather than thrown: a bad reference is a missing
|
|
367
|
+
* dependency, and one ticket's typo must not abort the whole tick. A
|
|
368
|
+
* *malformed* file still throws — that is operator error about a real
|
|
369
|
+
* ticket, and the dispatcher confines the blast radius to the one ticket
|
|
370
|
+
* that referenced it.
|
|
371
|
+
*/
|
|
372
|
+
async dependencyStates(ids: string[]): Promise<DependencyState[]> {
|
|
373
|
+
const states: DependencyState[] = []
|
|
374
|
+
for (const id of ids) {
|
|
375
|
+
let found: Located | null
|
|
376
|
+
try {
|
|
377
|
+
found = await this.locate(id)
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error instanceof Error && error.message.includes('invalid ticket id')) {
|
|
380
|
+
states.push({ id, exists: false, resolved: false, blockedBy: [] })
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
throw error
|
|
384
|
+
}
|
|
385
|
+
if (found === null) {
|
|
386
|
+
states.push({ id, exists: false, resolved: false, blockedBy: [] })
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
const loaded = await this.loadAt(found)
|
|
390
|
+
states.push({
|
|
391
|
+
id,
|
|
392
|
+
exists: true,
|
|
393
|
+
resolved: found.state === this.doneState,
|
|
394
|
+
blockedBy: [...(loaded.front.blockedBy ?? [])],
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
return states
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The four state dirs, plus — for the defaulted backlog only — a
|
|
402
|
+
* self-excluding `.gitignore`. Same move `src/cli/context.ts` makes for
|
|
403
|
+
* `.ab/`: the dir hides itself from git in ANY repo, so the local backlog is
|
|
404
|
+
* never staged, without mutating the repo's own tracked `.gitignore` as an
|
|
405
|
+
* unreviewed side effect. Idempotent; called at the head of every write.
|
|
406
|
+
*/
|
|
407
|
+
private async ensureLayout(): Promise<void> {
|
|
408
|
+
for (const state of TICKET_STATES) {
|
|
409
|
+
await mkdir(join(this.dir, dirName(state)), { recursive: true })
|
|
410
|
+
}
|
|
411
|
+
if (this.selfIgnore) await writeFile(join(this.dir, '.gitignore'), '*\n')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private pathIn(state: TicketState, id: string): string {
|
|
415
|
+
return join(this.dir, dirName(state), `${this.safeId(id)}.md`)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private safeId(id: string): string {
|
|
419
|
+
if (id.includes('/') || id.includes('\\') || id.includes('..')) {
|
|
420
|
+
throw new Error(`file ticket source: invalid ticket id "${id}"`)
|
|
421
|
+
}
|
|
422
|
+
return id
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Find the one state dir holding `<id>.md`; null when absent. */
|
|
426
|
+
private async locate(id: string): Promise<Located | null> {
|
|
427
|
+
this.safeId(id)
|
|
428
|
+
const hits: Located[] = []
|
|
429
|
+
for (const state of TICKET_STATES) {
|
|
430
|
+
const path = this.pathIn(state, id)
|
|
431
|
+
try {
|
|
432
|
+
await stat(path)
|
|
433
|
+
hits.push({ id, state, path })
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (hits.length > 1)
|
|
439
|
+
throw duplicateError(
|
|
440
|
+
id,
|
|
441
|
+
hits.map((h) => h.path),
|
|
442
|
+
)
|
|
443
|
+
return hits[0] ?? null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Every ticket file across the four state dirs, sorted, duplicates rejected. */
|
|
447
|
+
private async scan(): Promise<Located[]> {
|
|
448
|
+
await this.assertNoLooseTickets()
|
|
449
|
+
const byId = new Map<string, Located[]>()
|
|
450
|
+
for (const state of TICKET_STATES) {
|
|
451
|
+
for (const entry of await this.readMarkdown(join(this.dir, dirName(state)))) {
|
|
452
|
+
const id = entry.slice(0, -'.md'.length)
|
|
453
|
+
const located = { id, state, path: join(this.dir, dirName(state), entry) }
|
|
454
|
+
byId.set(id, [...(byId.get(id) ?? []), located])
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const [id, hits] of byId) {
|
|
458
|
+
if (hits.length > 1)
|
|
459
|
+
throw duplicateError(
|
|
460
|
+
id,
|
|
461
|
+
hits.map((h) => h.path),
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
return [...byId.values()].flat().sort((a, b) => a.id.localeCompare(b.id))
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* A `.md` at the tracker root has no state — it is invisible to every scan
|
|
469
|
+
* and would never dispatch. Name it rather than ignore it.
|
|
470
|
+
*/
|
|
471
|
+
private async assertNoLooseTickets(): Promise<void> {
|
|
472
|
+
for (const entry of await this.readMarkdown(this.dir)) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`${join(this.dir, entry)}: ticket file outside a state directory — a ticket's ` +
|
|
475
|
+
`state is the directory it sits in; move it into one of: ` +
|
|
476
|
+
`${TICKET_STATES.map(dirName).join('/, ')}/`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async readMarkdown(path: string): Promise<string[]> {
|
|
482
|
+
try {
|
|
483
|
+
return (await readdir(path)).filter((e) => e.endsWith('.md')).sort()
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
486
|
+
throw error
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private async loadAt(found: Located): Promise<{ front: Frontmatter; body: string }> {
|
|
491
|
+
const loaded = parseTicketFile(found.path, await readFile(found.path, 'utf8'))
|
|
492
|
+
if (loaded.front.id !== found.id) {
|
|
493
|
+
throw new TicketFileValidationError(
|
|
494
|
+
`${found.path}: frontmatter id "${loaded.front.id}" does not match filename`,
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
return loaded
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private async requireForWrite(
|
|
501
|
+
id: string,
|
|
502
|
+
operation: string,
|
|
503
|
+
): Promise<{ found: Located; front: Frontmatter; body: string }> {
|
|
504
|
+
const found = await this.locate(id)
|
|
505
|
+
if (found === null) {
|
|
506
|
+
throw new Error(`file ticket source: ${operation} on unknown ticket "${id}"`)
|
|
507
|
+
}
|
|
508
|
+
return { found, ...(await this.loadAt(found)) }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Rewrite only the located file: editable writes can never move lifecycle
|
|
512
|
+
* state because the existing state-directory path remains authoritative. */
|
|
513
|
+
private async rewriteAt(found: Located, front: Frontmatter, body: string): Promise<void> {
|
|
514
|
+
await writeFile(found.path, serializeTicketFile(front, body))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private async listAll(): Promise<TicketListing> {
|
|
518
|
+
const tickets: Ticket[] = []
|
|
519
|
+
const diagnostics: string[] = []
|
|
520
|
+
// Complete discovery first. Structural failures (including duplicate ids)
|
|
521
|
+
// therefore win even when one of the discovered records is also malformed.
|
|
522
|
+
const located = await this.scan()
|
|
523
|
+
for (const found of located) {
|
|
524
|
+
try {
|
|
525
|
+
const loaded = await this.loadAt(found)
|
|
526
|
+
tickets.push(this.toTicket(loaded.front, loaded.body, found.state))
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (error instanceof TicketFileValidationError) {
|
|
529
|
+
diagnostics.push(error.message)
|
|
530
|
+
continue
|
|
531
|
+
}
|
|
532
|
+
throw error
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return { tickets, diagnostics }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private toTicket(front: Frontmatter, body: string, state: TicketState): Ticket {
|
|
539
|
+
return {
|
|
540
|
+
ref: { source: this.name, id: front.id, title: front.title },
|
|
541
|
+
...(front.idempotencyKey !== undefined ? { creationKey: front.idempotencyKey } : {}),
|
|
542
|
+
title: front.title,
|
|
543
|
+
body,
|
|
544
|
+
state,
|
|
545
|
+
labels: [...front.labels],
|
|
546
|
+
...(front.blockedBy !== undefined && front.blockedBy.length > 0
|
|
547
|
+
? { blockedBy: [...front.blockedBy] }
|
|
548
|
+
: {}),
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function duplicateError(id: string, paths: string[]): Error {
|
|
554
|
+
return new Error(
|
|
555
|
+
`file ticket source: ticket "${id}" exists in more than one state directory: ` +
|
|
556
|
+
`${paths.join(', ')} — a ticket's state is the directory it sits in; keep ` +
|
|
557
|
+
`exactly one copy (use mv, not cp)`,
|
|
558
|
+
)
|
|
559
|
+
}
|