@defrex/autobuild 0.8.0 → 0.9.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 (147) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +313 -26
  3. package/bin/ab-build-runner.ts +8 -2
  4. package/docs/architecture.md +57 -14
  5. package/docs/assets/headline-wide.png +0 -0
  6. package/docs/configuration.md +151 -25
  7. package/docs/hosted-dispatcher.md +69 -13
  8. package/docs/operator-api.md +2 -2
  9. package/docs/remote-store-protocol.md +169 -16
  10. package/docs/remote-ticket-protocol.md +1 -1
  11. package/docs/setup.md +12 -9
  12. package/docs/website.md +10 -0
  13. package/package.json +43 -23
  14. package/packages/core/src/cli/artifact.ts +130 -6
  15. package/packages/core/src/cli/binary.ts +9 -1
  16. package/packages/core/src/cli/build-control.ts +110 -13
  17. package/packages/core/src/cli/context.ts +46 -0
  18. package/packages/core/src/cli/dashboard/model.ts +57 -7
  19. package/packages/core/src/cli/dashboard/poll.ts +69 -10
  20. package/packages/core/src/cli/dashboard/render.ts +34 -5
  21. package/packages/core/src/cli/dispatch-frontend.ts +54 -5
  22. package/packages/core/src/cli/dispatch.ts +320 -46
  23. package/packages/core/src/cli/harvest.ts +122 -8
  24. package/packages/core/src/cli/help.ts +47 -8
  25. package/packages/core/src/cli/init-readiness-remote.ts +110 -0
  26. package/packages/core/src/cli/init-readiness-shared.ts +72 -0
  27. package/packages/core/src/cli/init-validation.ts +248 -222
  28. package/packages/core/src/cli/main.ts +70 -11
  29. package/packages/core/src/cli/mcp.ts +23 -1
  30. package/packages/core/src/cli/plugin.ts +7 -4
  31. package/packages/core/src/cli/remote-poll.ts +277 -0
  32. package/packages/core/src/cli/repo-state.ts +22 -0
  33. package/packages/core/src/cli/repository-status.ts +39 -1
  34. package/packages/core/src/cli/status.ts +149 -27
  35. package/packages/core/src/cli/terminals.ts +2 -1
  36. package/packages/core/src/cli/ticket-assets.ts +247 -0
  37. package/packages/core/src/cli/ticket.ts +307 -7
  38. package/packages/core/src/cli/wait.ts +147 -39
  39. package/packages/core/src/cli/watch.ts +128 -166
  40. package/packages/core/src/config/live.ts +22 -0
  41. package/packages/core/src/config/pipeline-source.ts +376 -0
  42. package/packages/core/src/config/schema.ts +230 -19
  43. package/packages/core/src/events/catalog.ts +1 -0
  44. package/packages/core/src/events/globs.ts +47 -0
  45. package/packages/core/src/events/payloads.ts +52 -2
  46. package/packages/core/src/events/repository.ts +52 -1
  47. package/packages/core/src/events/sessions.ts +69 -10
  48. package/packages/core/src/events/workspace-remote.ts +12 -0
  49. package/packages/core/src/integration/harness.ts +2 -1
  50. package/packages/core/src/kernel/auto-merge-default.ts +123 -0
  51. package/packages/core/src/kernel/phases.ts +16 -2
  52. package/packages/core/src/kernel/reducer.ts +40 -1
  53. package/packages/core/src/operator/annotations.ts +6 -0
  54. package/packages/core/src/operator/control.ts +19 -5
  55. package/packages/core/src/operator/index.ts +71 -6
  56. package/packages/core/src/operator/query.ts +208 -19
  57. package/packages/core/src/operator/registry.ts +55 -10
  58. package/packages/core/src/operator/requests.ts +9 -6
  59. package/packages/core/src/operator/sandbox-host.ts +140 -0
  60. package/packages/core/src/operator/sandbox.ts +247 -14
  61. package/packages/core/src/operator/ticket-source-backend.ts +47 -0
  62. package/packages/core/src/orchestrator/approvals.ts +115 -0
  63. package/packages/core/src/orchestrator/conversation.ts +167 -0
  64. package/packages/core/src/orchestrator/model.ts +86 -0
  65. package/packages/core/src/orchestrator/prompt.ts +61 -0
  66. package/packages/core/src/orchestrator/tools.ts +117 -0
  67. package/packages/core/src/orchestrator/turn-runner.ts +457 -0
  68. package/packages/core/src/plugin-sdk/index.ts +51 -1
  69. package/packages/core/src/plugins/load.ts +135 -7
  70. package/packages/core/src/plugins/manifest.ts +183 -10
  71. package/packages/core/src/plugins/registry.ts +73 -4
  72. package/packages/core/src/ports/forge/create.ts +1 -1
  73. package/packages/core/src/ports/forge/fake.ts +31 -0
  74. package/packages/core/src/ports/forge/github-transport.ts +13 -2
  75. package/packages/core/src/ports/forge/github.ts +13 -2
  76. package/packages/core/src/ports/forge/local-git.ts +31 -1
  77. package/packages/core/src/ports/runner/claude.ts +2 -2
  78. package/packages/core/src/ports/runner/codex.ts +6 -2
  79. package/packages/core/src/ports/runner/fake.ts +7 -1
  80. package/packages/core/src/ports/runner/pi.ts +8 -6
  81. package/packages/core/src/ports/runner/provider-error.ts +13 -3
  82. package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
  83. package/packages/core/src/ports/types.ts +17 -2
  84. package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
  85. package/packages/core/src/ports/workspace/create.ts +74 -39
  86. package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
  87. package/packages/core/src/ports/workspace/fake.ts +169 -4
  88. package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
  89. package/packages/core/src/ports/workspace/github-origin.ts +36 -0
  90. package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
  91. package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
  92. package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
  93. package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
  94. package/packages/core/src/processes/build-child.ts +17 -2
  95. package/packages/core/src/processes/build-execution-state.ts +37 -0
  96. package/packages/core/src/processes/build-runner.ts +54 -3
  97. package/packages/core/src/processes/dispatcher.ts +247 -41
  98. package/packages/core/src/processes/harvest-child.ts +5 -0
  99. package/packages/core/src/processes/harvest-runner.ts +66 -19
  100. package/packages/core/src/processes/harvest.ts +196 -24
  101. package/packages/core/src/processes/orchestrator-tick.ts +319 -0
  102. package/packages/core/src/processes/sandbox-state.ts +10 -0
  103. package/packages/core/src/store/adapter.ts +32 -0
  104. package/packages/core/src/store/build-scope.ts +64 -0
  105. package/packages/core/src/store/contract.ts +868 -0
  106. package/packages/core/src/store/digest.ts +49 -0
  107. package/packages/core/src/store/local/schema.ts +29 -2
  108. package/packages/core/src/store/local/store.ts +338 -4
  109. package/packages/core/src/store/memory.ts +179 -0
  110. package/packages/core/src/store/new-build.ts +17 -0
  111. package/packages/core/src/store/phase-session.ts +71 -0
  112. package/packages/core/src/store/remote/client.ts +260 -1
  113. package/packages/core/src/store/remote/index.ts +12 -2
  114. package/packages/core/src/store/remote/protocol.ts +100 -12
  115. package/packages/core/src/store/remote/version.ts +6 -2
  116. package/packages/core/src/store/repo-state-events.ts +140 -0
  117. package/packages/core/src/store/retention.ts +7 -0
  118. package/packages/core/src/store/session-handle.ts +64 -0
  119. package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
  120. package/packages/core/src/store/streams/session-writer.ts +42 -2
  121. package/packages/core/src/store/streams/wait.ts +4 -5
  122. package/packages/core/src/store/ticket-assets.ts +425 -0
  123. package/packages/core/src/store/types.ts +122 -1
  124. package/packages/core/src/testing/index.ts +59 -0
  125. package/skills/code-review/SKILL.md +5 -1
  126. package/skills/guide/SKILL.md +126 -33
  127. package/skills/guide/references/operator-api.md +2 -2
  128. package/skills/guide/references/plugin-authoring.md +17 -0
  129. package/skills/guide/references/remote-store-protocol.md +169 -16
  130. package/skills/guide/references/remote-ticket-protocol.md +1 -1
  131. package/skills/guide/references/setup.md +12 -9
  132. package/skills/implement/SKILL.md +3 -0
  133. package/skills/operate/SKILL.md +126 -0
  134. package/skills/plan/SKILL.md +4 -0
  135. package/skills/plan-review/SKILL.md +5 -1
  136. package/skills/spec/SKILL.md +46 -3
  137. package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
  138. package/packages/core/src/cli/store-opening.contract.ts +0 -289
  139. package/packages/core/src/cli/testkit.ts +0 -194
  140. package/packages/core/src/markdown.ts +0 -67
  141. package/packages/core/src/operator/client.ts +0 -311
  142. package/packages/core/src/operator/server.ts +0 -616
  143. package/packages/core/src/operator/sessions.ts +0 -223
  144. package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
  145. package/packages/core/src/ports/tickets/remote-server.ts +0 -185
  146. package/packages/core/src/store/remote/server.ts +0 -923
  147. package/packages/core/src/testing/store-failures.ts +0 -32
@@ -20,9 +20,20 @@ import {
20
20
  updateTicket,
21
21
  } from '../ports/tickets/operations'
22
22
  import type { Ticket, TicketSource, TicketUpdate } from '../ports/types'
23
- import type { Exec } from '../ports/workspace/git-worktree'
23
+ import { reduceBuild } from '../kernel/reducer'
24
+ import type { BuildStore } from '../store/types'
25
+ import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
26
+ import type { TicketAssetSummary } from '../store/ticket-assets'
24
27
  import { parseArgs, stringFlag } from './args'
25
28
  import { resolveMainRepo, resolveRepoStatePaths } from './repo-state'
29
+ import { withSessionlessStore, type StoreOpener } from './store-opening'
30
+ import {
31
+ buildAssetInput,
32
+ formatAssetSize,
33
+ ticketAssetNote,
34
+ walkAssetPath,
35
+ writeAssetTo,
36
+ } from './ticket-assets'
26
37
 
27
38
  export type TicketSourceFactory = (
28
39
  config: TicketsConfig,
@@ -41,6 +52,11 @@ export interface TicketCommandOpts {
41
52
  stdout: (line: string) => void
42
53
  /** Injectable for tests; defaults to the real adapter factory. */
43
54
  sourceFactory?: TicketSourceFactory
55
+ /** Store seam for the ticket-asset commands and `show`'s asset list. */
56
+ openStore?: StoreOpener
57
+ /** Explicit `--store` for the store-backed commands; precedence is applied
58
+ * by repo-state.ts. */
59
+ storeRef?: string
44
60
  }
45
61
 
46
62
  export interface TicketCreateOpts extends TicketCommandOpts {
@@ -88,6 +104,35 @@ export interface TicketShowOpts extends TicketCommandOpts {
88
104
  json?: boolean
89
105
  }
90
106
 
107
+ export interface TicketAttachOpts extends TicketCommandOpts {
108
+ id: string
109
+ kind: string
110
+ path: string
111
+ /** Defaults to the path's basename. */
112
+ name?: string
113
+ json?: boolean
114
+ /** Warning sink (active-build notice); stdout stays the command's result. */
115
+ stderr?: (line: string) => void
116
+ }
117
+
118
+ export interface TicketAssetGetOpts extends TicketCommandOpts {
119
+ id: string
120
+ kind: string
121
+ name: string
122
+ dest: string
123
+ rev?: number
124
+ json?: boolean
125
+ }
126
+
127
+ export interface TicketAssetRmOpts extends TicketCommandOpts {
128
+ id: string
129
+ kind: string
130
+ name: string
131
+ json?: boolean
132
+ /** Warning sink (active-build notice); stdout stays the command's result. */
133
+ stderr?: (line: string) => void
134
+ }
135
+
91
136
  export interface TicketMoveOpts extends TicketCommandOpts {
92
137
  id: string
93
138
  /** Source-local workflow state; validation belongs to the adapter. */
@@ -95,7 +140,17 @@ export interface TicketMoveOpts extends TicketCommandOpts {
95
140
  json?: boolean
96
141
  }
97
142
 
98
- type TicketCommandName = 'create' | 'update' | 'block' | 'unblock' | 'list' | 'show' | 'move'
143
+ type TicketCommandName =
144
+ | 'create'
145
+ | 'update'
146
+ | 'block'
147
+ | 'unblock'
148
+ | 'list'
149
+ | 'show'
150
+ | 'move'
151
+ | 'attach'
152
+ | 'asset get'
153
+ | 'asset rm'
99
154
 
100
155
  export interface ResolvedTicketCommand {
101
156
  config: Config
@@ -173,7 +228,11 @@ function ticketSummary(ticket: Ticket): string {
173
228
  return fields.join(' ')
174
229
  }
175
230
 
176
- function ticketDetail(ticket: Ticket): string[] {
231
+ function assetLine(asset: TicketAssetSummary): string {
232
+ return ` ${asset.kind}/${asset.name} rev ${asset.revision} ${formatAssetSize(asset.size)}`
233
+ }
234
+
235
+ function ticketDetail(ticket: Ticket, assets: TicketAssetSummary[] = []): string[] {
177
236
  const lines = [
178
237
  `ticket ${ticket.ref.source}:${ticket.ref.id}`,
179
238
  ` title: ${ticket.title}`,
@@ -184,6 +243,7 @@ function ticketDetail(ticket: Ticket): string[] {
184
243
  lines.push(` blocked by: ${ticket.blockedBy.join(', ')}`)
185
244
  }
186
245
  if (ticket.ref.url !== undefined) lines.push(` url: ${ticket.ref.url}`)
246
+ if (assets.length > 0) lines.push(' assets:', ...assets.map(assetLine))
187
247
  lines.push(' body:')
188
248
  return lines
189
249
  }
@@ -291,16 +351,169 @@ export async function abTicketList(opts: TicketListOpts): Promise<void> {
291
351
  export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
292
352
  const { source } = await resolveTicketCommand(opts, 'show')
293
353
  const ticket = await requireTicket(source, opts.id)
354
+ // An asset-listing failure fails the command rather than hiding the assets.
355
+ const assets = await withTicketStore(opts, ({ store, repo }) =>
356
+ store.listTicketAssets(repo, opts.id),
357
+ )
294
358
  if (opts.json === true) {
295
- opts.stdout(JSON.stringify(ticket, null, 2))
359
+ opts.stdout(
360
+ JSON.stringify(
361
+ {
362
+ ...ticket,
363
+ assets: assets.map(({ kind, name, revision, size, layout, fileCount }) => ({
364
+ kind,
365
+ name,
366
+ revision,
367
+ size,
368
+ layout,
369
+ fileCount,
370
+ })),
371
+ },
372
+ null,
373
+ 2,
374
+ ),
375
+ )
296
376
  return
297
377
  }
298
- for (const line of ticketDetail(ticket)) opts.stdout(line)
378
+ for (const line of ticketDetail(ticket, assets)) opts.stdout(line)
299
379
  // Keep the adapter-provided body untouched. It may itself be multiline and
300
380
  // may intentionally end (or not end) with a newline.
301
381
  opts.stdout(ticket.body)
302
382
  }
303
383
 
384
+ function withTicketStore<T>(
385
+ opts: TicketCommandOpts,
386
+ use: (context: { store: BuildStore; repo: string }) => Promise<T>,
387
+ ): Promise<T> {
388
+ return withSessionlessStore(
389
+ {
390
+ targetRepo: opts.targetRepo,
391
+ env: opts.env,
392
+ exec: opts.exec ?? spawnExec,
393
+ ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
394
+ ...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
395
+ },
396
+ use,
397
+ )
398
+ }
399
+
400
+ /** Slugs of this repo's non-terminal builds for the ticket. A build freezes its
401
+ * ticket assets at the claim (SPEC §6.3), so a change to them does not reach
402
+ * it. The notice is advisory: a failed lookup yields none and never blocks. */
403
+ async function activeBuildsFor(store: BuildStore, repo: string, id: string): Promise<string[]> {
404
+ try {
405
+ const active: string[] = []
406
+ for (const record of await store.listBuilds()) {
407
+ if (record.repo !== repo || record.ticket?.id !== id) continue
408
+ const { status } = reduceBuild(await store.getEvents(record.slug))
409
+ if (status !== 'done' && status !== 'aborted') active.push(record.slug)
410
+ }
411
+ return active
412
+ } catch {
413
+ return []
414
+ }
415
+ }
416
+
417
+ function warnActiveBuilds(
418
+ opts: { stderr?: (line: string) => void; id: string },
419
+ slugs: string[],
420
+ ): void {
421
+ for (const slug of slugs) {
422
+ opts.stderr?.(
423
+ `warning: build ${slug} is active for ticket ${opts.id}; it keeps the assets it froze at ` +
424
+ `the claim unless its spec is revised from the ticket (ab answer ${slug} --revise-spec-from-ticket)`,
425
+ )
426
+ }
427
+ }
428
+
429
+ /** `ab ticket attach <id> <kind> <path>` — store a file or folder as a ticket
430
+ * asset, then leave a note on the ticket in its source. Order matters: the
431
+ * source is asked first, so an unknown id stores nothing; the note follows the
432
+ * store write, so it never points at nothing. */
433
+ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
434
+ const { source } = await resolveTicketCommand(opts, 'attach')
435
+ await requireTicket(source, opts.id)
436
+ const walk = await walkAssetPath(opts.path)
437
+ const name = opts.name ?? walk.basename
438
+ const stored = await withTicketStore(opts, async ({ store, repo }) => {
439
+ const limits = await store.ticketAssetLimits(repo)
440
+ const input = await buildAssetInput(walk, { kind: opts.kind, name }, limits)
441
+ const live = await store.listTicketAssets(repo, opts.id)
442
+ const replacing = live.some((asset) => asset.kind === opts.kind && asset.name === name)
443
+ const meta = await store.putTicketAsset(repo, opts.id, input)
444
+ return { meta, replacing, active: await activeBuildsFor(store, repo, opts.id) }
445
+ })
446
+ const { meta } = stored
447
+ warnActiveBuilds(opts, stored.active)
448
+ try {
449
+ await source.comment(opts.id, ticketAssetNote(stored.replacing ? 'replaced' : 'attached', meta))
450
+ } catch (error) {
451
+ throw new Error(
452
+ `ticket asset ${meta.kind}/${meta.name} was stored as revision ${meta.revision}, but the note on ` +
453
+ `${source.name}:${opts.id} failed: ${error instanceof Error ? error.message : String(error)} ` +
454
+ '(re-running attach stores another revision)',
455
+ )
456
+ }
457
+ if (opts.json === true) {
458
+ opts.stdout(JSON.stringify(meta, null, 2))
459
+ return
460
+ }
461
+ opts.stdout(
462
+ `ticket asset attached: ${source.name}:${opts.id} ${meta.kind}/${meta.name} revision ${meta.revision} ` +
463
+ `(${formatAssetSize(meta.size)})`,
464
+ )
465
+ }
466
+
467
+ /** `ab ticket asset get <id> <kind> <name> <dest>` — exact bytes at the
468
+ * latest or a named revision. */
469
+ export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void> {
470
+ const asset = await withTicketStore(opts, ({ store, repo }) =>
471
+ store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev),
472
+ )
473
+ if (asset === null) {
474
+ const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
475
+ throw new Error(
476
+ `ticket ${opts.id} has no asset ${opts.kind}/${opts.name}${revision}` +
477
+ (opts.rev === undefined ? ' (it may have been removed — try --rev <n>)' : ''),
478
+ )
479
+ }
480
+ const written = await writeAssetTo(asset, opts.dest)
481
+ if (opts.json === true) {
482
+ opts.stdout(JSON.stringify({ ...asset.meta, path: written }, null, 2))
483
+ return
484
+ }
485
+ opts.stdout(
486
+ `ticket asset downloaded: ${opts.kind}/${opts.name} revision ${asset.meta.revision} → ${written}`,
487
+ )
488
+ }
489
+
490
+ /** `ab ticket asset rm <id> <kind> <name>` — remove from the current assets
491
+ * (earlier revisions stay retrievable) and note the removal on the ticket. */
492
+ export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
493
+ const { source } = await resolveTicketCommand(opts, 'asset rm')
494
+ const { removed, active } = await withTicketStore(opts, async ({ store, repo }) => ({
495
+ removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
496
+ active: await activeBuildsFor(store, repo, opts.id),
497
+ }))
498
+ if (removed === null) {
499
+ throw new Error(`ticket ${opts.id} has no current asset ${opts.kind}/${opts.name} to remove`)
500
+ }
501
+ warnActiveBuilds(opts, active)
502
+ try {
503
+ await source.comment(opts.id, ticketAssetNote('removed', removed))
504
+ } catch (error) {
505
+ throw new Error(
506
+ `ticket asset ${opts.kind}/${opts.name} was removed, but the note on ${source.name}:${opts.id} ` +
507
+ `failed: ${error instanceof Error ? error.message : String(error)}`,
508
+ )
509
+ }
510
+ if (opts.json === true) {
511
+ opts.stdout(JSON.stringify(removed, null, 2))
512
+ return
513
+ }
514
+ opts.stdout(`ticket asset removed: ${source.name}:${opts.id} ${opts.kind}/${opts.name}`)
515
+ }
516
+
304
517
  /** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
305
518
  export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
306
519
  const { source } = await resolveTicketCommand(opts, 'move')
@@ -319,7 +532,13 @@ const UPDATE_USAGE =
319
532
  const BLOCK_USAGE = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
320
533
  const UNBLOCK_USAGE = 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
321
534
  const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
322
- const SHOW_USAGE = 'usage: ab ticket show <id> [--json] (§8.8)'
535
+ const SHOW_USAGE = 'usage: ab ticket show <id> [--store <ref>] [--json] (§8.8)'
536
+ const ATTACH_USAGE =
537
+ 'usage: ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json] (§8.8)'
538
+ const ASSET_GET_USAGE =
539
+ 'usage: ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json] (§8.8)'
540
+ const ASSET_RM_USAGE =
541
+ 'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
323
542
  const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
324
543
  export const TICKET_USAGE = [
325
544
  CREATE_USAGE,
@@ -329,6 +548,9 @@ export const TICKET_USAGE = [
329
548
  LIST_USAGE,
330
549
  SHOW_USAGE,
331
550
  MOVE_USAGE,
551
+ ATTACH_USAGE,
552
+ ASSET_GET_USAGE,
553
+ ASSET_RM_USAGE,
332
554
  ].join('\n')
333
555
 
334
556
  function commaList(value: string): string[] {
@@ -444,19 +666,97 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
444
666
  }
445
667
 
446
668
  case 'show': {
447
- const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
669
+ const parsed = parseArgs(args, { json: 'boolean', store: 'value' }, TICKET_USAGE)
448
670
  const [id, ...extra] = parsed.positionals
449
671
  if (id === undefined || id.trim() === '' || extra.length > 0) {
450
672
  throw new Error(TICKET_USAGE)
451
673
  }
674
+ const store = stringFlag(parsed, 'store')
452
675
  await abTicketShow({
453
676
  ...opts,
677
+ ...(store !== undefined ? { storeRef: store } : {}),
454
678
  id,
455
679
  json: parsed.flags.has('json'),
456
680
  })
457
681
  return
458
682
  }
459
683
 
684
+ case 'attach': {
685
+ const parsed = parseArgs(
686
+ args,
687
+ { name: 'value', store: 'value', json: 'boolean' },
688
+ TICKET_USAGE,
689
+ )
690
+ const [id, kind, path, ...extra] = parsed.positionals
691
+ if (!id?.trim() || !kind?.trim() || !path?.trim() || extra.length > 0) {
692
+ throw new Error(TICKET_USAGE)
693
+ }
694
+ const name = stringFlag(parsed, 'name')
695
+ if (name !== undefined && name.trim() === '') throw new Error(TICKET_USAGE)
696
+ const store = stringFlag(parsed, 'store')
697
+ await abTicketAttach({
698
+ ...opts,
699
+ ...(store !== undefined ? { storeRef: store } : {}),
700
+ id,
701
+ kind,
702
+ path,
703
+ ...(name !== undefined ? { name } : {}),
704
+ json: parsed.flags.has('json'),
705
+ })
706
+ return
707
+ }
708
+
709
+ case 'asset': {
710
+ const [action, ...assetArgs] = args
711
+ if (action === 'get') {
712
+ const parsed = parseArgs(
713
+ assetArgs,
714
+ { rev: 'value', store: 'value', json: 'boolean' },
715
+ TICKET_USAGE,
716
+ )
717
+ const [id, kind, name, dest, ...extra] = parsed.positionals
718
+ if (!id?.trim() || !kind?.trim() || !name?.trim() || !dest?.trim() || extra.length > 0) {
719
+ throw new Error(TICKET_USAGE)
720
+ }
721
+ const rawRev = stringFlag(parsed, 'rev')
722
+ if (rawRev !== undefined && !/^\d+$/.test(rawRev)) {
723
+ throw new Error(
724
+ `--rev must be a nonnegative integer, got "${rawRev}" — ${ASSET_GET_USAGE}`,
725
+ )
726
+ }
727
+ const store = stringFlag(parsed, 'store')
728
+ await abTicketAssetGet({
729
+ ...opts,
730
+ ...(store !== undefined ? { storeRef: store } : {}),
731
+ id,
732
+ kind,
733
+ name,
734
+ dest,
735
+ ...(rawRev !== undefined ? { rev: Number(rawRev) } : {}),
736
+ json: parsed.flags.has('json'),
737
+ })
738
+ return
739
+ }
740
+ if (action === 'rm') {
741
+ const parsed = parseArgs(assetArgs, { store: 'value', json: 'boolean' }, TICKET_USAGE)
742
+ const [id, kind, name, ...extra] = parsed.positionals
743
+ if (!id?.trim() || !kind?.trim() || !name?.trim() || extra.length > 0) {
744
+ throw new Error(TICKET_USAGE)
745
+ }
746
+ const store = stringFlag(parsed, 'store')
747
+ await abTicketAssetRm({
748
+ ...opts,
749
+ ...(store !== undefined ? { storeRef: store } : {}),
750
+ id,
751
+ kind,
752
+ name,
753
+ json: parsed.flags.has('json'),
754
+ })
755
+ return
756
+ }
757
+ throw new Error(TICKET_USAGE)
758
+ }
759
+
460
760
  case 'move': {
461
761
  const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
462
762
  const [id, state, ...extra] = parsed.positionals
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * The command shares `ab watch`'s machinery — target selection, ambient
11
11
  * scoping (via the extracted `assertReadScope`), the cursor codec, the record
12
- * projection, the poll cadence, and the read-failure policy — but ends on the
12
+ * projection, the cadence, and the read-failure policy — but ends on the
13
13
  * first satisfied condition instead of streaming. Conditions are derived from
14
14
  * the same reducer that produces `ab build status` (`status`, `prState`,
15
15
  * `phase`), so "blocked" means an open escalation exactly as it does
@@ -17,6 +17,17 @@
17
17
  * wait`, a state condition that already holds when the command starts returns
18
18
  * immediately with the build's latest event.
19
19
  *
20
+ * Cadence follows the store kind, exactly as `ab watch` does since AUT-334
21
+ * (verified and corrected for `ab wait` in AUT-368). Against a local store
22
+ * the loop is the sequential interval tick. Against an `http(s)` store every
23
+ * tracked build long-polls instead: one held `getEvents` request per stream
24
+ * is in flight (tasks run concurrently), `--interval` is the floor between
25
+ * request starts (elapsed request time counts toward the gap), and a hold's
26
+ * window is the remote default (25 s, whole seconds, under the store's
27
+ * ceiling) capped at the remaining `--timeout` budget. Every stop — a
28
+ * satisfied condition, an all-terminal named set, the timeout, an interrupt —
29
+ * cancels the in-flight holds promptly instead of waiting out a full window.
30
+ *
20
31
  * Read-only: it appends no event, claims or renews no lease, and creates no
21
32
  * build or repository record. Output carries no ANSI, ever, in either form.
22
33
  */
@@ -26,7 +37,13 @@ import type { BuildState, PrLifecycle } from '../kernel/reducer'
26
37
  import { reduceBuild } from '../kernel/reducer'
27
38
  import { isPhase, type BuildStatus, type Phase } from '../ontology'
28
39
  import type { Exec } from '../ports/workspace/git-worktree'
29
- import { buildInRepository, normalizeGitRemoteUrl } from './repo-state'
40
+ import {
41
+ createRemotePollRunner,
42
+ defaultDelay,
43
+ makeFailureStreak,
44
+ type RemotePollReadOpts,
45
+ } from './remote-poll'
46
+ import { buildInRepository, isRemoteStoreRef, normalizeGitRemoteUrl } from './repo-state'
30
47
  import type { BuildRecord } from '../store/types'
31
48
  import { withAmbientReadStore, type StoreOpener } from './store-opening'
32
49
  import {
@@ -245,23 +262,6 @@ interface WaitStream {
245
262
  status: BuildStatus
246
263
  }
247
264
 
248
- function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
249
- return new Promise((resolve) => {
250
- if (signal?.aborted === true) {
251
- resolve()
252
- return
253
- }
254
- const finish = (): void => {
255
- clearTimeout(timer)
256
- signal?.removeEventListener('abort', onAbort)
257
- resolve()
258
- }
259
- const timer = setTimeout(finish, ms)
260
- const onAbort = (): void => finish()
261
- signal?.addEventListener('abort', onAbort, { once: true })
262
- })
263
- }
264
-
265
265
  /**
266
266
  * `ab wait` — block until a targeted build satisfies a condition, then print
267
267
  * one record and exit 0; exit 2 when every named build went terminal without
@@ -386,6 +386,12 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
386
386
  if (streams.has(slug)) return
387
387
  const record = await store.getBuild(slug)
388
388
  if (record === null) return
389
+ // AUT-545: this full-log read is deliberate, accepted growth — the
390
+ // retained prefix is consumed at full replay depth by every later
391
+ // reduction (approvals, findings, answered escalations, PR facts,
392
+ // observations), so bounding it would need reducer-partition store
393
+ // machinery out of AUT-545's scope. Required for `--since` replay and
394
+ // the registration state check over the full reduction.
389
395
  const events = await store.getEvents(slug)
390
396
  const stream = registerStream(slug, events, slugs.includes(slug))
391
397
  if (resumeSeq !== undefined) {
@@ -419,8 +425,24 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
419
425
  * build to appear.
420
426
  */
421
427
  const discoverBuilds = async (): Promise<void> => {
428
+ // AUT-545: the terminal filter is the AUT-487 digest, not a full-log read —
429
+ // one flat batch read replaces a full getEvents per candidate build, per
430
+ // discovery pass; `terminal` is reduceBuild's exact terminal rule
431
+ // (store/digest.ts, pinned by contract). The per-build full read below
432
+ // remains deliberate for builds the digest cannot retire: its retained
433
+ // prefix is consumed at full replay depth by every later reduction
434
+ // (approvals, findings, answered escalations, PR facts, observations), so
435
+ // bounding it would need reducer-partition store machinery out of
436
+ // AUT-545's scope. The reduceBuild re-check stays: it is the
437
+ // legacy-record fallback (a record whose `repo` predates origin
438
+ // normalization is in `mine` via `repoOrigin` but absent from the digest
439
+ // map, which keys on `record.repo`) and it keeps this filter unable to
440
+ // diverge from reduceBuild.
441
+ const digests = await store.getRepoBuildDigests(repo)
422
442
  for (const record of (await store.listBuilds()).filter(mine)) {
423
443
  if (streams.has(record.slug)) continue
444
+ const digest = digests.get(record.slug)
445
+ if (digest !== undefined && digest.terminal !== undefined) continue
424
446
  const events = await store.getEvents(record.slug)
425
447
  const status = reduceBuild(events).status
426
448
  if (!NONTERMINAL_STATUSES.includes(status)) continue
@@ -429,8 +451,11 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
429
451
  }
430
452
  }
431
453
 
432
- const pollBuild = async (stream: WaitStream): Promise<void> => {
433
- const fresh = await store.getEvents(stream.slug, stream.lastSeq)
454
+ const pollBuild = async (
455
+ stream: WaitStream,
456
+ readOpts?: RemotePollReadOpts,
457
+ ): Promise<void> => {
458
+ const fresh = await store.getEvents(stream.slug, stream.lastSeq, readOpts)
434
459
  for (const event of fresh) {
435
460
  stream.events.push(event)
436
461
  stream.lastSeq = event.seq
@@ -482,13 +507,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
482
507
  /** One poll cycle: discovery, then per-stream reads. A failed read is
483
508
  * reported once per failure streak, advances nothing, and is retried at
484
509
  * the next interval; a fully successful cycle re-arms the report. */
485
- let readFailureReported = false
486
- const reportReadFailure = (error: unknown): void => {
487
- if (readFailureReported) return
488
- readFailureReported = true
489
- const message = error instanceof Error ? error.message : String(error)
490
- opts.stderr(`ab wait: a store read failed (${message}); retrying at the next interval`)
491
- }
510
+ const tickStreak = makeFailureStreak('wait', opts.stderr)
492
511
 
493
512
  const tick = async (): Promise<void> => {
494
513
  let allReadsOk = true
@@ -497,7 +516,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
497
516
  await discoverBuilds()
498
517
  } catch (error) {
499
518
  allReadsOk = false
500
- reportReadFailure(error)
519
+ tickStreak.onFailure(error)
501
520
  }
502
521
  }
503
522
  for (const stream of [...streams.values()]) {
@@ -506,10 +525,10 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
506
525
  await pollBuild(stream)
507
526
  } catch (error) {
508
527
  allReadsOk = false
509
- reportReadFailure(error)
528
+ tickStreak.onFailure(error)
510
529
  }
511
530
  }
512
- if (allReadsOk) readFailureReported = false
531
+ if (allReadsOk) tickStreak.onSuccess()
513
532
  }
514
533
 
515
534
  const finishSatisfied = (): number =>
@@ -561,17 +580,106 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
561
580
  const timeoutText = opts.timeout ?? '30m'
562
581
  const deadline = timeoutMs === 0 ? Number.POSITIVE_INFINITY : now().getTime() + timeoutMs
563
582
  const aborted = (): boolean => opts.signal?.aborted === true
564
- while (!hasMatch()) {
565
- if (aborted() || now().getTime() >= deadline) break
566
- await sleep(interval, opts.signal)
567
- if (aborted()) break
568
- await tick()
569
- if (!hasMatch() && namedAllTerminal()) {
570
- const terminal = pickTerminalRecord()
571
- return finish(null, terminal.event, terminal.state)
583
+
584
+ if (!isRemoteStoreRef(storeRef)) {
585
+ // Local cadence: one sequential tick per interval.
586
+ while (!hasMatch()) {
587
+ if (aborted() || now().getTime() >= deadline) break
588
+ await sleep(interval, opts.signal)
589
+ if (aborted()) break
590
+ await tick()
591
+ if (!hasMatch() && namedAllTerminal()) {
592
+ const terminal = pickTerminalRecord()
593
+ return finish(null, terminal.event, terminal.state)
594
+ }
572
595
  }
596
+ } else {
597
+ // Remote cadence (AUT-334, verified and corrected in AUT-368): one
598
+ // long-poll task per tracked stream, running concurrently — a quiet
599
+ // stream keeps one held `getEvents` request in flight, so an appended
600
+ // event is seen within about a second of its append instead of at the
601
+ // next interval tick. Gap-fill spaces each stream's request starts at
602
+ // least `interval` apart (elapsed request time counts toward the
603
+ // gap), so a server that answers immediately still sees today's
604
+ // request rate. Discovery keeps its interval cadence. The pre-launch
605
+ // guard mirrors the local loop's top-of-cycle checks: a satisfied
606
+ // match, an abort, an elapsed deadline, or an all-terminal named set
607
+ // ends the wait before any held request (an already-satisfied wait
608
+ // issues zero held reads).
609
+ // Every stop is prompt: a satisfied condition, an all-terminal named
610
+ // set, an external abort, or an elapsed --timeout cancels the
611
+ // in-flight held reads (via `requestStop`) and wakes the gap sleeps
612
+ // (via the runner's stop controller) instead of waiting out a full
613
+ // hold. The held read's own bound is additionally capped at the
614
+ // wait's remaining time budget, so a hold can never outlive the
615
+ // deadline.
616
+ const shouldStop =
617
+ hasMatch() || aborted() || now().getTime() >= deadline || namedAllTerminal()
618
+ const remoteRunner = createRemotePollRunner({
619
+ command: 'wait',
620
+ stderr: opts.stderr,
621
+ now,
622
+ sleep,
623
+ intervalMs: interval,
624
+ deadlineMs: deadline,
625
+ aborted,
626
+ shouldStop: () =>
627
+ hasMatch() || aborted() || now().getTime() >= deadline || namedAllTerminal(),
628
+ drain: 'quiesce',
629
+ })
630
+ const requestStop = (): void => remoteRunner.requestStop()
631
+
632
+ const launch = (stream: WaitStream): void => {
633
+ remoteRunner.launch(
634
+ stream.slug,
635
+ (readOpts) => pollBuild(stream, readOpts),
636
+ () => hasMatch() || namedAllTerminal(),
637
+ )
638
+ }
639
+
640
+ // An external abort (SIGINT) is a stop like any other: it cancels
641
+ // the in-flight held reads and wakes the gap sleeps.
642
+ opts.signal?.addEventListener('abort', requestStop, { once: true })
643
+
644
+ if (!shouldStop) {
645
+ for (const stream of streams.values()) launch(stream)
646
+
647
+ if (slugs.length === 0) {
648
+ // Discovery keeps its interval cadence, launching a new per-
649
+ // stream task for each newly discovered nonterminal build. A
650
+ // discovered build that already satisfies ends the wait. The
651
+ // launch loop stays unconditional, after the catch — exactly
652
+ // where the inline loop ran: streams registered before a
653
+ // mid-discovery throw still get tasks whose polls then run
654
+ // independently.
655
+ void remoteRunner.runDiscovery({
656
+ step: async (): Promise<void> => {
657
+ await discoverBuilds()
658
+ },
659
+ launchPending: (): void => {
660
+ for (const stream of streams.values()) launch(stream)
661
+ },
662
+ endCheck: () => hasMatch(),
663
+ })
664
+ }
665
+ }
666
+
667
+ await remoteRunner.drain()
668
+ opts.signal?.removeEventListener('abort', requestStop)
573
669
  }
670
+
574
671
  if (hasMatch()) return finishSatisfied()
672
+ if (namedAllTerminal()) {
673
+ // Decision 5, loop form: every named build went terminal without
674
+ // satisfying anything. The local loop checks this after each tick;
675
+ // on the remote path the runner records only that a stop was
676
+ // requested — its `shouldStop`/`endCheck` callbacks re-derive the
677
+ // conditions live — so after the quiesce drain this decision re-reads
678
+ // the same live predicates (`hasMatch`, `namedAllTerminal`, `aborted`,
679
+ // the deadline) to pick the exit.
680
+ const terminal = pickTerminalRecord()
681
+ return finish(null, terminal.event, terminal.state)
682
+ }
575
683
  if (aborted()) {
576
684
  opts.stderr('ab wait: interrupted — no condition was satisfied before the signal')
577
685
  if (json) opts.stdout(JSON.stringify({ cursor: encodeCurrent() }))