@defrex/autobuild 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +119 -15
  3. package/docs/architecture.md +59 -3
  4. package/docs/configuration.md +41 -9
  5. package/docs/hosted-service.md +31 -5
  6. package/docs/operator-api.md +7 -0
  7. package/docs/remote-store-protocol.md +10 -0
  8. package/docs/remote-ticket-protocol.md +151 -9
  9. package/docs/setup.md +33 -7
  10. package/docs/ticket-migration.md +64 -0
  11. package/package.json +3 -3
  12. package/packages/core/src/cli/build-control.ts +12 -1
  13. package/packages/core/src/cli/connect.ts +141 -38
  14. package/packages/core/src/cli/context.ts +7 -1
  15. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  16. package/packages/core/src/cli/dispatch.ts +156 -3
  17. package/packages/core/src/cli/harvest.ts +13 -3
  18. package/packages/core/src/cli/help.ts +14 -8
  19. package/packages/core/src/cli/installation.ts +67 -13
  20. package/packages/core/src/cli/main.ts +20 -2
  21. package/packages/core/src/cli/mcp.ts +2 -1
  22. package/packages/core/src/cli/self-update.ts +5 -1
  23. package/packages/core/src/cli/ticket-assets.ts +7 -2
  24. package/packages/core/src/cli/ticket-editor.ts +82 -0
  25. package/packages/core/src/cli/ticket.ts +164 -34
  26. package/packages/core/src/config/schema.ts +35 -3
  27. package/packages/core/src/connection.ts +3 -3
  28. package/packages/core/src/events/catalog.ts +3 -2
  29. package/packages/core/src/events/payloads.ts +6 -0
  30. package/packages/core/src/integration/harness.ts +37 -2
  31. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
  32. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  33. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  34. package/packages/core/src/kernel/reducer-registry.ts +2 -0
  35. package/packages/core/src/ontology.ts +1 -0
  36. package/packages/core/src/operator/annotations.ts +12 -0
  37. package/packages/core/src/operator/index.ts +5 -0
  38. package/packages/core/src/operator/protocol.ts +19 -2
  39. package/packages/core/src/operator/registry.ts +61 -8
  40. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  41. package/packages/core/src/operator/tickets.ts +130 -20
  42. package/packages/core/src/plugin-sdk/index.ts +6 -0
  43. package/packages/core/src/plugins/runtimes.ts +5 -1
  44. package/packages/core/src/ports/runner/session-env.ts +2 -0
  45. package/packages/core/src/ports/tickets/contract.ts +66 -1
  46. package/packages/core/src/ports/tickets/create.ts +48 -12
  47. package/packages/core/src/ports/tickets/fake.ts +30 -1
  48. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  49. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  50. package/packages/core/src/ports/tickets/file.ts +114 -120
  51. package/packages/core/src/ports/tickets/grants.ts +47 -0
  52. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  53. package/packages/core/src/ports/tickets/identity.ts +20 -0
  54. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  55. package/packages/core/src/ports/tickets/linear.ts +74 -9
  56. package/packages/core/src/ports/tickets/migration.ts +336 -0
  57. package/packages/core/src/ports/tickets/operations.ts +38 -7
  58. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  59. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  60. package/packages/core/src/ports/tickets/remote.ts +60 -3
  61. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  62. package/packages/core/src/ports/tickets/revision.ts +62 -0
  63. package/packages/core/src/ports/tickets/states.ts +61 -0
  64. package/packages/core/src/ports/types.ts +40 -3
  65. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  66. package/packages/core/src/ports/workspace/create.ts +5 -1
  67. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  68. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  69. package/packages/core/src/processes/dispatcher.ts +110 -32
  70. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  71. package/packages/core/src/processes/harvest-child.ts +30 -4
  72. package/packages/core/src/processes/harvest-runner.ts +24 -4
  73. package/packages/core/src/processes/harvest.ts +34 -14
  74. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  75. package/packages/core/src/processes/repo-view.ts +35 -0
  76. package/packages/core/src/store/contract.ts +32 -1
  77. package/packages/core/src/store/local/store.ts +1 -1
  78. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  79. package/packages/core/src/store/ticket-assets.ts +86 -8
  80. package/packages/core/src/store/types.ts +1 -1
  81. package/skills/guide/SKILL.md +91 -11
  82. package/skills/guide/references/operator-api.md +7 -0
  83. package/skills/guide/references/remote-store-protocol.md +10 -0
  84. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  85. package/skills/guide/references/setup.md +33 -7
  86. package/skills/guide/references/ticket-migration.md +64 -0
  87. package/skills/tickets/SKILL.md +71 -0
  88. package/templates/hosted-service/package.json +2 -2
@@ -67,6 +67,16 @@ must match the credential's project; mismatches are 403. Owner admin requests
67
67
  require this selector and registration is checked before configuration is read.
68
68
  Internal operator credentials remain forbidden on machine ticket routes.
69
69
 
70
+ Hosted ticket attachments use the ticket service's existing repository-keyed
71
+ asset routes with the ticket connection's URL and credential, independently
72
+ of the build store. Names, revisions, tombstones and service limits are
73
+ unchanged. Existing service attachments need no migration; old local
74
+ attachments are not moved or used as a fallback. A claim or ticket spec re-pin
75
+ copies exact revisions into immutable chunked repository artifacts in the
76
+ destination build store. Build pins reference explicit snapshot manifests;
77
+ build-scoped asset reads need no ticket credentials. Old pins without snapshot
78
+ references keep their existing ticket-row lookup.
79
+
70
80
  The strict request envelope is:
71
81
 
72
82
  ```json
@@ -82,16 +92,37 @@ The strict request envelope is:
82
92
 
83
93
  `teamKey` remains required for older client compatibility. It and the optional
84
94
  `claimedState` / `createState` fields are ignored as policy inputs. The service
85
- loads the project's latest effective configuration from its repository journal
86
- and artifact bytes for every operation. Missing or invalid configuration returns
87
- an actionable 409; run the project's dispatcher to publish its configuration.
88
- There is no deployment-wide lifecycle fallback.
95
+ loads the project's durable independent ticket policy. Project authority publishes
96
+ version 1 policy through `POST /tickets/policy`, using the usual context/input
97
+ envelope. The input contains only `version` and `tickets`: source, teamKey,
98
+ readyState, triageState, claimedState, doneState, createState, proposalState and
99
+ optional readyLabels. URL, secrets, and pipeline configuration are rejected.
100
+ Only project or owner authority can publish. This does not append BuildStore
101
+ history. Registration removal invalidates policy and grants from that registration.
102
+
103
+ For compatibility, projects without independent policy use their latest uploaded
104
+ dispatcher configuration with the old `Backlog`, `Doing`, `Done` defaults.
105
+ New policy defaults are `triage`, `ready`, `doing`, `done`; create/proposal follow
106
+ triage. States are persistent project data, including empty states and optional
107
+ one-line purposes. Configured role names always exist; removed roles remain as
108
+ custom states and keep their tickets. Explicit settings preserve state spelling.
109
+ State lookup prefers an exact match, then a unique case-insensitive match;
110
+ unknown or ambiguous names are refused. Lists order configured triage, ready,
111
+ doing, done first (deduplicated), then other names alphabetically.
112
+
113
+ `[tickets] url` selects the ticket service independently of the build store;
114
+ otherwise the selected remote build store is used. A local build store requires
115
+ an explicit ticket URL. Credentials are resolved for that selected service.
116
+ `ab connect --tickets URL` registers and publishes policy before any dispatcher run.
89
117
 
90
- Hosted lifecycle defaults are triage `Backlog`, claimed `Doing`, done `Done`,
91
- and create/proposal equal to triage. `readyState` is required. The allowed states
92
- are the distinct union of these configured roles, including custom create and
93
- proposal states; roles may overlap. Historical states remain readable unchanged.
94
- `tickets.doneState` controls completion and the dispatcher's merged transition.
118
+ `POST /tickets/grants` issues, renews or revokes short-lived, digest-stored ticket
119
+ grants. Project authority issues runner grants scoped to project and execution;
120
+ runners may issue only read-only phase children bound to themselves. Runner grants
121
+ allow read and create; phase grants allow read only. Policy, claim, moves, comments,
122
+ updates, blocker changes, BuildStore, and credential administration are denied.
123
+ AB_TICKET_URL and AB_TICKET_TOKEN carry this authority separately from AB_STORE and
124
+ AB_TOKEN. Revocation, expiry, parent revocation and registration changes invalidate
125
+ grants. Existing combined-service BuildStore delegation remains supported.
95
126
 
96
127
  The service never contacts Linear or relays external trackers. If the project's
97
128
  source is Linear, file or another non-hosted source, machine HTTP, operator and
@@ -111,6 +142,8 @@ The operation names and `input` values are:
111
142
 
112
143
  | Operation | Input | Successful response |
113
144
  |---|---|---|
145
+ | `states` | `{}` | `{name, tickets, about?}[]`, including empty states |
146
+ | `add-state` | `{name, about?: string}` | `{state: {name,tickets,about?}, created: boolean}` |
114
147
  | `list-ready` | `{labels?: string[], state?: string}` | `TicketListing` |
115
148
  | `get` | `{id}` | `Ticket \| null` |
116
149
  | `claim` | `{id}` | `{claimed: boolean}` |
@@ -132,6 +165,35 @@ Ticket body strings are opaque. Clients, JSON handling, the service, and the
132
165
  database backend must not trim or normalize bytes, newlines, Unicode, blank
133
166
  lines, or trailing whitespace.
134
167
 
168
+ ## Readable ticket identity
169
+
170
+ New hosted tickets use `<teamKey>-<n>`, for example `ENG-1`. The effective
171
+ project configuration supplies the literal prefix; the request's `teamKey`
172
+ does not choose it. Numbers start at 1 in each project, increase atomically
173
+ across concurrent creates, and are never reused after deletion. Projects sharing
174
+ a team key have independent counters. Changing `teamKey` affects only future
175
+ ids and continues the same numbering; existing ids retain their captured prefix.
176
+
177
+ Every ticket operation, CLI command, MCP tool and blocker input accepts readable
178
+ ids in any letter case. Legacy storage ids (such as `ticket-<uuid>`) remain
179
+ aliases, including references in existing builds, harvest records and assets.
180
+ Current ticket and blocker output uses the canonical readable id. The exception
181
+ is `dependency-states`: each result's `id` echoes the requested id for correlation,
182
+ while its `blockedBy` edges are canonical. Raw historical events and artifacts
183
+ remain unchanged. These rules apply only to hosted tickets, not file or Linear ids.
184
+
185
+ Upgrade the service with its environment-aware migration command before serving
186
+ requests. The transactional migration preserves storage keys and assigns existing
187
+ tickets numbers in `(created_at, id)` order within each project. It first resolves
188
+ legacy team ownership, then reads each populated project's current independent
189
+ ticket policy. Projects without that policy use their selected effective
190
+ configuration through the configured blob backend. Independent-policy projects
191
+ need no uploaded config or blob access. Missing or invalid fallback config,
192
+ missing `teamKey`, or inaccessible required blobs abort the entire migration
193
+ unchanged with a project diagnostic. Restore that config/blob access and rerun;
194
+ an ownership assignment alone cannot supply a readable prefix. Empty projects
195
+ need no config lookup. Re-running a completed migration does not renumber tickets.
196
+
135
197
  ## Errors and conformance
136
198
 
137
199
  Errors are JSON `{ "error": string, "kind": "validation" | "not-found" |
@@ -170,3 +232,83 @@ project removal are also checked before parsing or source construction.
170
232
 
171
233
  Ticket admission and assets use the authenticated project, including for
172
234
  delegated harvest grants.
235
+
236
+ Custom state creation uses `/^[a-z][a-z0-9-]{0,39}$/`; an optional purpose must
237
+ be one nonblank line. A duplicate in any letter case is refused without
238
+ changing purpose. `add-state` requires project or owner machine authority;
239
+ ticket grants and harvest delegations can read states but cannot create them.
240
+ Request context cannot supply lifecycle policy or insert another project's
241
+ states. These operations use the existing wire version and strict envelopes.
242
+
243
+ ## Revision-checked ticket writes
244
+
245
+ Every ticket read includes an opaque `revision`. Pass it as `expectedRevision`
246
+ (on MCP/operator requests) or `--expected-revision <opaque>` on `ab ticket update`,
247
+ `move`, `block` and `unblock`. A stale write fails without changing anything and
248
+ reports the current revision. Omission preserves unguarded behavior. Blocker
249
+ checks cover the entire batch. Dispatcher moves remain unguarded and change the
250
+ revision. Sources without guarded writes, including Linear, refuse supplied
251
+ revisions rather than ignoring them.
252
+
253
+ `ab ticket edit <id>` opens `$VISUAL`, falling back to `$EDITOR`, using TOML
254
+ frontmatter and a Markdown body. Only title, body and labels are editable;
255
+ id, blockers, creation key and reference comments are read-only. It saves only
256
+ changed fields with the opening revision. Unchanged or formatting-only edits
257
+ write nothing. On conflict, invalid input or editor failure, the error names an
258
+ absolute file path containing the edited text. Read the current ticket and
259
+ reapply the intended changes before retrying; conflicts are never merged.
260
+
261
+ File and Linear revisions fingerprint title, body, canonical label/blocker sets
262
+ and state; reverting identical content restores its fingerprint. File checks
263
+ re-read immediately before writing, with the existing no-lock race window.
264
+ Direct file edits and state-directory moves change revisions. File revisions also
265
+ cover structured comments and portable attachments. Legacy file comments change
266
+ the body; structured comments preserve it. Separate hosted comments do not change
267
+ ticket revisions. Hosted revisions
268
+ are monotonic decimal bigint strings, and checks run under the same row lock
269
+ and transaction as all requested changes. Hosted reads return a coherent
270
+ snapshot of fields, blockers and revision.
271
+
272
+ ### Guarded mutation transport
273
+
274
+ `POST /tickets/mutate-if-revision` accepts the usual authenticated context and
275
+ `input: { id, expectedRevision, mutation }`. Mutation is one of
276
+ `{ kind: "update", patch: { title?, body?, labels? } }`,
277
+ `{ kind: "transition", state }`, or
278
+ `{ kind: "blockers", operation: "block" | "unblock", ids: string[] }`.
279
+ Unknown keys and empty patches are refused. Success returns `{ ok: true }`.
280
+ A revision conflict returns HTTP 409 with `kind: "conflict"`, `error`, `id`,
281
+ `expectedRevision` and `currentRevision`. Delegated credentials retain existing
282
+ permissions: none of the guarded mutation variants grants additional access.
283
+ Ticket responses include `revision` only when the request advertises
284
+ `x-autobuild-ticket-revisions: 1`. The current hosted client sends this header
285
+ on every request and requires revision-bearing responses. Released clients
286
+ through 0.12.x omit the header and retain their strict decoder's original
287
+ response shape on get, list-ready and create. This explicit capability also
288
+ supports new source checkouts that still report version 0.12.0; version alone
289
+ cannot distinguish them from released clients. Unknown capability values do
290
+ not opt in. Clients before 0.11 also omit the existing resolution projections.
291
+ The capability does not change authentication, authorization or the remote
292
+ BuildStore protocol version.
293
+
294
+ ## Requested ids for migration
295
+
296
+ Create options accept optional `requestedId: string` alongside `state` and
297
+ `idempotencyKey`. Older clients omit it and responses are unchanged. Unsupported
298
+ sources reject supplied ids; Linear is not an import target. File sources keep
299
+ filesystem-safe ids exactly and never overwrite an occupied id.
300
+
301
+ Hosted sources preserve only literal own-team `<teamKey>-<n>` ids, where `n` is a
302
+ positive canonical decimal within PostgreSQL bigint bounds. For example,
303
+ `AUT-706` in team `AUT` keeps 706; `OTHER-706`, `AUT-01` and `AUT-0` use ordinary
304
+ allocation. Overflow canonical numbers are rejected. A matching creation key is
305
+ adopted before occupancy checks. A kept number belonging to a different ticket
306
+ fails without replacing it. Creation and counter advancement share the existing
307
+ project transaction and counter lock. Later automatic numbers exceed both the
308
+ counter and highest occupied number; importing a lower number never lowers the
309
+ counter. Requested ids require project or owner authority. Ticket grants and
310
+ delegated credentials receive 403 for create options containing `requestedId`,
311
+ before source construction. Their ordinary create grants remain unchanged.
312
+
313
+ See the [migration procedure](ticket-migration.md) for preflight, state mapping,
314
+ resumption and portable file records.
package/docs/setup.md CHANGED
@@ -398,13 +398,13 @@ the verifier does not fix it.
398
398
  ### Project-scoped hosted tickets
399
399
 
400
400
  Hosted ticket queues belong to the credential's registered repository, even
401
- when projects share a team key. The latest uploaded effective `[tickets]`
402
- configuration owns lifecycle: hosted defaults are Backlog / Doing / Done,
403
- `readyState` is required, `doneState` customizes completion, and create/proposal
404
- default to triage. The union of configured roles is allowed; roles may overlap.
405
- Owner admin requests require an explicit registered `context.repo`; project
406
- credentials cannot select another project. Missing effective configuration is
407
- an actionable refusal: run the project's dispatcher to publish it.
401
+ when projects share a team key. Independent project ticket policy owns lifecycle:
402
+ new defaults are triage / ready / doing / done, with create/proposal following
403
+ triage. Explicit names retain their spelling; doneState customizes completion.
404
+ `ab connect --tickets URL` publishes policy before dispatch. Older clients without
405
+ independent policy retain their uploaded dispatcher configuration and legacy defaults.
406
+ Owner admin requests require an explicit registered context.repo; project credentials
407
+ cannot select another project. See [local builds with hosted tickets](#local-builds-with-hosted-tickets).
408
408
 
409
409
  The service never contacts Linear. Projects with external sources (including
410
410
  file and Linear) keep using those sources directly through their dispatcher;
@@ -447,3 +447,29 @@ require the running service's blob backend, access mode, credentials and prefix
447
447
  (`AB_BLOB_PREFIX`), not just PostgreSQL access. `deploy:build` inherits this
448
448
  environment and migration failure stops Next build. Fresh databases, empty v1
449
449
  queues and v2 reruns stay SQL-only and do not initialize blob storage.
450
+
451
+ ## Local builds with hosted tickets
452
+
453
+ Hosted tickets do not require hosted builds. Set `[tickets] source = "hosted"`
454
+ and `teamKey = "ENG"`, then run `ab connect --tickets https://your-service.example`.
455
+ Approve the project in the browser. This saves a service-scoped credential and
456
+ `[tickets] url` while leaving `[store]` unchanged. Ticket commands work immediately,
457
+ before any dispatcher runs. `ab connect` reports every service and whether it serves
458
+ builds, tickets, or both. Use `ab disconnect --tickets` to revoke ticket access;
459
+ when services differ use `--builds` to select build access. Shared uses share one
460
+ credential, so disconnecting either shared use affects both.
461
+
462
+ Ticket URL selection is `[tickets] url`, then the selected build service (including
463
+ AB_STORE or a supported --store override). With local builds, `[tickets] url` is
464
+ required. Builds, events, artifacts, and harvest journals remain in the local store.
465
+ The service stores ticket records, project ticket policy, and restricted ticket
466
+ grants. Normal project clients synchronize current `[tickets]` policy before use;
467
+ failed synchronization fails the operation. Harvest grants can read and create;
468
+ phase grants can only read. Neither can change policy or access BuildStore routes.
469
+
470
+ New hosted policy defaults to `triage`, `ready`, `doing`, `done`, with creation and
471
+ harvest proposals in `triage`. Explicit state names preserve their spelling.
472
+ To retain earlier names configure `triageState = "Backlog"`,
473
+ `claimedState = "Doing"`, `doneState = "Done"`, and the desired `readyState`.
474
+ Existing ticket rows are never renamed. Older combined-service clients continue
475
+ using the uploaded dispatcher configuration until independent policy is published.
@@ -0,0 +1,64 @@
1
+ # Moving a project between ticket sources
2
+
3
+ Export and import use the ordinary file ticket layout. They operate on tickets
4
+ and current attachments through the ticket source; neither command contacts the
5
+ forge. File and hosted sources support import. Linear supports export only.
6
+
7
+ 1. Pause intake and stop other ticket writers for the cutover. Import preflight
8
+ does not reserve numbers against concurrent writers.
9
+ 2. With the old `[tickets]` configuration, run `ab ticket export /path/to/export`.
10
+ The destination must be absent or empty. Export reads every state, including
11
+ archived Linear tickets and empty discoverable states, and publishes the
12
+ directory only after all reads succeed. Back up the target tracker too.
13
+ 3. Configure the new `[tickets]` source, or use `ab connect --tickets URL` for a
14
+ hosted ticket service. Keep the export directory intact.
15
+ 4. Run `ab ticket import /path/to/export`. Inspect its printed ticket and state
16
+ mappings and `/path/to/export/.ab-import-map.json`.
17
+ 5. Inspect ticket bodies, labels, blockers, comments and attachments in the new
18
+ source. Confirm lifecycle configuration and the ready gate, then resume intake.
19
+
20
+ Both commands also accept
21
+ `--store <ref>` to reach legacy BuildStore attachments. Keep the old store available
22
+ through export when file or Linear tickets have attachments there.
23
+
24
+ State directory names use lowercase letters, digits and hyphens, start with a
25
+ letter and contain at most forty characters. Export maps `In Progress` to
26
+ `in-progress`, `Backlog` to `backlog`, and `Done` to `done`. It retains each ticket's
27
+ state rather than assigning lifecycle roles. Distinct names that collapse to the
28
+ same directory fail before publication. The hidden `.ab-migration.json` records
29
+ original display names and the state map. Import also accepts ordinary legacy
30
+ file layouts: it reuses exact or unique case-insensitive target states before
31
+ normalizing new names. A seeded `done` satisfies `Done`; absent `In Progress`
32
+ creates `in-progress`. Ambiguous matches and colliding state maps fail preflight.
33
+
34
+ File targets retain source ids. Hosted targets keep canonical own-team ids:
35
+ `AUT-706` imported into team `AUT` stays `AUT-706`. Other ids receive numbers above
36
+ all existing and preserved incoming numbers. Known clashes with unrelated tickets
37
+ are reported together before any target state, ticket, comment or asset write.
38
+ Content similarity and stale mapping files never establish identity.
39
+
40
+ Creation keys establish identity. Exports retain existing keys and assign keys to
41
+ legacy tickets. Importing an ordinary file layout first saves a hidden migration
42
+ identity and keys in that input directory. Preserve this file and the input across
43
+ retries. After interruption, rerun the same command: committed creates are adopted
44
+ by key even if their mapping write failed. Adopted bodies and other fields are not
45
+ overwritten. Missing blockers and comments are replayed, and unchanged attachments
46
+ do not create another revision. Comments are deduplicated by exact body, so two
47
+ identical source comment bodies produce one target comment. Original authors and
48
+ timestamps remain in the export; imported comments use the target's metadata.
49
+
50
+ Portable records use TOML frontmatter between `+++` fences and an unchanged
51
+ Markdown body. Alongside `id`, `title`, `labels`, `blockedBy` and `idempotencyKey`,
52
+ optional `comments` contain `body`, `createdAt` and optional `author`. Optional
53
+ `attachments` contain `kind`, `name`, `layout` (`file` or `tree`) and `entries`:
54
+ `{ type = "dir", path = "empty" }` or
55
+ `{ type = "file", path = "image.png", base64 = "…" }`. Common asset limits and
56
+ path validators apply. Only current attachment contents travel, including binary
57
+ files and empty directories; historical revisions do not. The exported directory
58
+ works directly as a file source, without its migration manifest or original store.
59
+ File source revision history stays in its hidden `.ab-assets` directory.
60
+
61
+ Legacy records without `comments` retain their Markdown comment append behavior;
62
+ export never interprets Markdown headings as structured history. New file tickets
63
+ use structured comments and preserve their body when a comment is added. There is
64
+ no continuous sync, assignee migration, state history or build-history migration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Agent-driven software delivery from groomed ticket to merged PR",
5
5
  "repository": {
6
6
  "type": "git",
@@ -143,8 +143,8 @@
143
143
  },
144
144
  "devDependencies": {
145
145
  "@biomejs/biome": "2.5.5",
146
- "@defrex/autobuild-postgres-store": "0.11.0",
147
- "@defrex/autobuild-vercel-sandbox": "0.11.0",
146
+ "@defrex/autobuild-postgres-store": "0.12.0",
147
+ "@defrex/autobuild-vercel-sandbox": "0.12.0",
148
148
  "@resvg/resvg-js": "2.6.2",
149
149
  "@types/bun": "^1.3.14",
150
150
  "@types/node": "^24.0.0",
@@ -464,6 +464,7 @@ export interface ControlBuildOpts {
464
464
  via?: Via
465
465
  action: BuildControlAction
466
466
  /** Injected only for --revise-spec-from-ticket. */
467
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>
467
468
  readTicketBody?: (ref: TicketRef) => Promise<string>
468
469
  }
469
470
 
@@ -624,7 +625,15 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
624
625
  )
625
626
  }
626
627
  body = await opts.readTicketBody(record.ticket)
627
- assets = await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
628
+ if (record.ticket.source === 'hosted' && !opts.prepareTicketAssets) {
629
+ throw new BuildControlError(
630
+ 'ticket-unavailable',
631
+ 'hosted ticket asset preparation is unavailable',
632
+ )
633
+ }
634
+ assets = opts.prepareTicketAssets
635
+ ? await opts.prepareTicketAssets(record.ticket, opts.store, record.repo)
636
+ : await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
628
637
  }
629
638
  const conformance = specConformance(body)
630
639
  if (!conformance.conforms) {
@@ -862,6 +871,7 @@ export interface AbBuildControlOpts {
862
871
  /** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
863
872
  storeRef?: string
864
873
  /** Injected only for --revise-spec-from-ticket. */
874
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>
865
875
  readTicketBody?: (ref: TicketRef) => Promise<string>
866
876
  /** Injectable adapter seam for unit tests. */
867
877
  openStore?: StoreOpener
@@ -881,6 +891,7 @@ export async function abBuildControl(opts: AbBuildControlOpts): Promise<BuildCon
881
891
  env: opts.env,
882
892
  action: opts.action,
883
893
  ...(opts.readTicketBody !== undefined ? { readTicketBody: opts.readTicketBody } : {}),
894
+ ...(opts.prepareTicketAssets ? { prepareTicketAssets: opts.prepareTicketAssets } : {}),
884
895
  }),
885
896
  )
886
897
  }
@@ -1,3 +1,6 @@
1
+ import { parseConfig } from '../config/load'
2
+ import { HostedTicketSource } from '../ports/tickets/remote'
3
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
1
4
  import { createHash, randomBytes, randomUUID } from 'node:crypto'
2
5
  import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
6
  import { constants } from 'node:fs'
@@ -14,6 +17,8 @@ import { resolveMainRepo } from './repo-state'
14
17
  import { ProjectCredentials } from './project-credentials'
15
18
 
16
19
  interface ConnectOptions {
20
+ tickets?: boolean
21
+ builds?: boolean
17
22
  cwd: string
18
23
  env: Record<string, string | undefined>
19
24
  exec: Exec
@@ -45,8 +50,15 @@ async function project(opts: ConnectOptions) {
45
50
  const store = table.store as { url?: unknown } | undefined
46
51
  if (store && (typeof store !== 'object' || Array.isArray(store)))
47
52
  throw new Error('Invalid [store] configuration')
48
- const service = store?.url === undefined ? undefined : normalizeServiceURL(String(store.url))
49
- return { checkout, repo, path, text, table, service }
53
+ const tickets = table.tickets as { source?: string; url?: string } | undefined
54
+ const buildService = opts.env.AB_STORE?.trim() || store?.url
55
+ const ticketService = tickets?.source === 'hosted' ? (tickets.url ?? buildService) : undefined
56
+ const selected = opts.tickets ? ticketService : buildService
57
+ const service =
58
+ typeof selected === 'string' && /^https?:/.test(selected)
59
+ ? normalizeServiceURL(selected)
60
+ : undefined
61
+ return { checkout, repo, path, text, table, service, buildService, ticketService }
50
62
  }
51
63
  async function request(
52
64
  opts: ConnectOptions,
@@ -84,45 +96,64 @@ async function atomicConfig(path: string, before: string, after: string) {
84
96
  }
85
97
  }
86
98
  export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
99
+ if (opts.tickets && opts.builds) throw new Error('Choose --tickets or --builds')
87
100
  const p = await project(opts)
101
+ if (opts.tickets && (p.table.tickets as { source?: string } | undefined)?.source !== 'hosted')
102
+ throw new Error('ab connect --tickets requires [tickets] source = "hosted"')
88
103
  const credentials = new ProjectCredentials(opts.env)
89
104
  if (raw === undefined) {
90
- if (!p.service) {
91
- opts.print(
92
- `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
93
- )
105
+ const connections = new Map<string, string[]>()
106
+ for (const [use, candidate] of [
107
+ ['builds', p.buildService],
108
+ ['tickets', p.ticketService],
109
+ ] as const) {
110
+ if (typeof candidate !== 'string' || !/^https?:/.test(candidate)) continue
111
+ const service = normalizeServiceURL(candidate)
112
+ connections.set(service, [...(connections.get(service) ?? []), use])
113
+ }
114
+ if (connections.size === 0) {
115
+ opts.print(`Project: ${p.repo}\nService: unconfigured`)
94
116
  return
95
117
  }
96
- const entry = credentials.load(p.service, p.repo)
97
- let status = 'missing; run ab connect'
98
- if (entry) {
99
- try {
100
- const response = await request(opts, p.service, 'status', undefined, entry.token)
101
- if (response.ok) {
102
- const body = (await response.json()) as {
103
- repo?: string
104
- credentialId?: string
105
- accepted?: boolean
118
+ for (const [service, uses] of connections) {
119
+ opts.print(`Uses: ${uses.join(', ')}`)
120
+ if (!service) {
121
+ opts.print(
122
+ `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
123
+ )
124
+ return
125
+ }
126
+ const entry = credentials.load(service, p.repo)
127
+ let status = 'missing; run ab connect'
128
+ if (entry) {
129
+ try {
130
+ const response = await request(opts, service, 'status', undefined, entry.token)
131
+ if (response.ok) {
132
+ const body = (await response.json()) as {
133
+ repo?: string
134
+ credentialId?: string
135
+ accepted?: boolean
136
+ }
137
+ if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
138
+ throw new Error('Invalid status response')
106
139
  }
107
- if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
108
- throw new Error('Invalid status response')
140
+ status = response.ok
141
+ ? 'accepted'
142
+ : response.status === 401 || response.status === 403
143
+ ? 'rejected; run ab connect'
144
+ : 'unreachable'
145
+ } catch {
146
+ status = 'unreachable'
109
147
  }
110
- status = response.ok
111
- ? 'accepted'
112
- : response.status === 401 || response.status === 403
113
- ? 'rejected; run ab connect'
114
- : 'unreachable'
115
- } catch {
116
- status = 'unreachable'
117
148
  }
149
+ opts.print(`Service: ${service}\nProject: ${p.repo}\nCredential: ${status}`)
118
150
  }
119
- opts.print(`Service: ${p.service}\nProject: ${p.repo}\nCredential: ${status}`)
120
151
  return
121
152
  }
122
153
  const service = normalizeServiceURL(raw)
123
- if (p.service && p.service !== service)
154
+ if (!opts.tickets && p.service && p.service !== service)
124
155
  throw new Error(
125
- 'Configured [store] url differs; update it explicitly before connecting to another service',
156
+ `Configured [${opts.tickets ? 'tickets' : 'store'}] url differs; update it explicitly before connecting to another service`,
126
157
  )
127
158
  await access(p.path, constants.W_OK)
128
159
  await access(dirname(p.path), constants.W_OK)
@@ -169,6 +200,7 @@ export async function abConnect(raw: string | undefined, opts: ConnectOptions):
169
200
  let approved = false
170
201
  let configChanged = false
171
202
  let saved = false
203
+ let updatedText = p.text
172
204
  try {
173
205
  let outcome: ConnectionOutcome | undefined
174
206
  while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
@@ -199,15 +231,42 @@ export async function abConnect(raw: string | undefined, opts: ConnectOptions):
199
231
  approved = true
200
232
  if (outcome.repo !== p.repo || outcome.credentialId !== id)
201
233
  throw new Error('Approval identity mismatch')
202
- if (!p.service) {
203
- // Dotted root key also supports an existing empty [store] table after it.
204
- const after = `store.url = ${JSON.stringify(service)}\n${p.text}`
205
- const parsed = parse(after)
206
- if ((parsed.store as { url?: string })?.url !== service)
207
- throw new Error('Cannot safely update [store] url; add it manually and retry')
208
- await atomicConfig(p.path, p.text, after)
234
+ if (!p.service || (opts.tickets && (p.table.tickets as { url?: string }).url !== service)) {
235
+ updatedText = setConnectionURL(p.text, opts.tickets ? 'tickets' : 'store', service)
236
+ if (opts.tickets) parseConfig(updatedText)
237
+ else parse(updatedText)
238
+ await atomicConfig(p.path, p.text, updatedText)
209
239
  configChanged = true
210
240
  }
241
+ const configuredTickets = p.table.tickets as { source?: string; url?: string } | undefined
242
+ if (
243
+ opts.tickets ||
244
+ (configuredTickets?.source === 'hosted' &&
245
+ (!configuredTickets.url || normalizeServiceURL(configuredTickets.url) === service))
246
+ ) {
247
+ const tickets = parseConfig(updatedText).tickets
248
+ const lifecycle = ticketLifecycle(tickets)
249
+ await new HostedTicketSource({
250
+ url: service,
251
+ token,
252
+ repo: p.repo,
253
+ teamKey: tickets.teamKey!,
254
+ ...(opts.fetch ? { fetchFn: (input, init) => opts.fetch!(String(input), init) } : {}),
255
+ }).synchronizePolicy({
256
+ version: 1,
257
+ tickets: {
258
+ source: 'hosted',
259
+ teamKey: tickets.teamKey!,
260
+ readyState: lifecycle.ready,
261
+ triageState: lifecycle.triage,
262
+ claimedState: lifecycle.doing,
263
+ doneState: lifecycle.done,
264
+ createState: lifecycle.create,
265
+ proposalState: lifecycle.proposal,
266
+ ...(tickets.readyLabels !== undefined ? { readyLabels: tickets.readyLabels } : {}),
267
+ },
268
+ })
269
+ }
211
270
  credentials.save({ service, repo: p.repo, id, token })
212
271
  saved = true
213
272
  if (previous) {
@@ -233,21 +292,35 @@ export async function abConnect(raw: string | undefined, opts: ConnectOptions):
233
292
  }
234
293
  if (configChanged) {
235
294
  try {
236
- await atomicConfig(p.path, `store.url = ${JSON.stringify(service)}\n${p.text}`, p.text)
295
+ await atomicConfig(p.path, updatedText, p.text)
237
296
  } catch {
238
- opts.print('Restore [store] url manually if needed; configuration changed concurrently.')
297
+ opts.print(
298
+ `Restore [${opts.tickets ? 'tickets' : 'store'}] url manually if needed; configuration changed concurrently.`,
299
+ )
239
300
  }
240
301
  }
241
302
  }
242
303
  throw new Error(
243
304
  approved
244
- ? 'Connection could not be saved; previous credential retained. Repair local storage and run ab connect again.'
305
+ ? `Connection could not be saved; previous credential retained. ${(error as Error).message}. Repair the reported error and run ab connect again.`
245
306
  : (error as Error).message,
246
307
  )
247
308
  }
248
309
  }
249
310
  export async function abDisconnect(opts: ConnectOptions): Promise<void> {
311
+ if (opts.tickets && opts.builds) throw new Error('Choose --tickets or --builds')
250
312
  const p = await project(opts)
313
+ if (
314
+ !opts.tickets &&
315
+ !opts.builds &&
316
+ p.ticketService &&
317
+ p.service &&
318
+ normalizeServiceURL(String(p.ticketService)) !== p.service
319
+ )
320
+ throw new Error(
321
+ 'Multiple services: use ab disconnect --tickets for tickets; set AB_STORE to the build service and select --builds for builds',
322
+ )
323
+ if (!p.service && !opts.builds && p.ticketService) return abDisconnect({ ...opts, tickets: true })
251
324
  if (!p.service) throw new Error('No configured service')
252
325
  const credentials = new ProjectCredentials(opts.env)
253
326
  const entry = credentials.load(p.service, p.repo)
@@ -255,7 +328,37 @@ export async function abDisconnect(opts: ConnectOptions): Promise<void> {
255
328
  opts.print('No stored credential; project configuration and service data retained.')
256
329
  return
257
330
  }
331
+ if (p.ticketService && p.buildService === p.ticketService)
332
+ opts.print('Builds and tickets share this credential; disconnecting affects both uses.')
258
333
  await revoke(opts, p.service, entry.token)
259
334
  credentials.remove(p.service, p.repo)
260
335
  opts.print('Disconnected; project registration, data, and [store] url retained.')
261
336
  }
337
+
338
+ /** Edit only the selected table; preserve unrelated configuration and comments. */
339
+ export function setConnectionURL(
340
+ text: string,
341
+ table: 'tickets' | 'store',
342
+ service: string,
343
+ ): string {
344
+ const lines = text.split('\n')
345
+ let inside = false
346
+ let start = -1
347
+ for (let i = 0; i < lines.length; i++) {
348
+ const line = lines[i]!
349
+ if (/^\s*\[/.test(line)) {
350
+ if (inside) {
351
+ lines.splice(i, 0, `url = ${JSON.stringify(service)}`)
352
+ return lines.join('\n')
353
+ }
354
+ inside = new RegExp(`^\\s*\\[${table}\\]\\s*(?:#.*)?$`).test(line)
355
+ if (inside) start = i
356
+ } else if (inside && /^\s*url\s*=/.test(line)) {
357
+ lines[i] = `url = ${JSON.stringify(service)}`
358
+ return lines.join('\n')
359
+ }
360
+ }
361
+ if (start >= 0) lines.splice(start + 1, 0, `url = ${JSON.stringify(service)}`)
362
+ else lines.push(`[${table}]`, `url = ${JSON.stringify(service)}`)
363
+ return lines.join('\n')
364
+ }
@@ -273,7 +273,13 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
273
273
  if (inputs.ticket === true) {
274
274
  const created = events.find((event) => event.type === 'build.created')
275
275
  if (created !== undefined && created.type === 'build.created') {
276
- const ticket = created.payload.ticket
276
+ const original = created.payload.ticket
277
+ const current =
278
+ original.source === 'hosted' ? (await store.getBuild(build))?.ticket : undefined
279
+ const ticket = {
280
+ ...original,
281
+ ...(current?.source === original.source ? { id: current.id } : {}),
282
+ }
277
283
  const lines = [
278
284
  `# Ticket: ${ticket.title ?? ticket.id}`,
279
285
  '',