@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.
- package/README.md +1 -1
- package/SPEC.md +313 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +57 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +151 -25
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +169 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +10 -0
- package/package.json +43 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/model.ts +57 -7
- package/packages/core/src/cli/dashboard/poll.ts +69 -10
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch.ts +320 -46
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +47 -8
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +248 -222
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/status.ts +149 -27
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +307 -7
- package/packages/core/src/cli/wait.ts +147 -39
- package/packages/core/src/cli/watch.ts +128 -166
- package/packages/core/src/config/live.ts +22 -0
- package/packages/core/src/config/pipeline-source.ts +376 -0
- package/packages/core/src/config/schema.ts +230 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/auto-merge-default.ts +123 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/reducer.ts +40 -1
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +208 -19
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/types.ts +17 -2
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +37 -0
- package/packages/core/src/processes/build-runner.ts +54 -3
- package/packages/core/src/processes/dispatcher.ts +247 -41
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/orchestrator-tick.ts +319 -0
- package/packages/core/src/processes/sandbox-state.ts +10 -0
- package/packages/core/src/store/adapter.ts +32 -0
- package/packages/core/src/store/build-scope.ts +64 -0
- package/packages/core/src/store/contract.ts +868 -0
- package/packages/core/src/store/digest.ts +49 -0
- package/packages/core/src/store/local/schema.ts +29 -2
- package/packages/core/src/store/local/store.ts +338 -4
- package/packages/core/src/store/memory.ts +179 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +71 -0
- package/packages/core/src/store/remote/client.ts +260 -1
- package/packages/core/src/store/remote/index.ts +12 -2
- package/packages/core/src/store/remote/protocol.ts +100 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +140 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +64 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +28 -9
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +425 -0
- package/packages/core/src/store/types.ts +122 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +126 -33
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +169 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { Actor } from '../events/envelope'
|
|
11
11
|
import type { AbEvent } from '../events/catalog'
|
|
12
|
-
import { normalizeVerifyCompletion, type EventPayload } from '../events/payloads'
|
|
12
|
+
import { normalizeVerifyCompletion, type EventPayload, type PinnedAsset } from '../events/payloads'
|
|
13
13
|
import type {
|
|
14
14
|
ArtifactRef,
|
|
15
15
|
BuildOutcome,
|
|
@@ -52,6 +52,8 @@ export interface AnsweredEscalation extends OpenEscalation {
|
|
|
52
52
|
resolution: EscalationResolution
|
|
53
53
|
/** Exact replacement spec revision authorized by a `revise-spec` answer. */
|
|
54
54
|
artifact?: ArtifactRef
|
|
55
|
+
/** Ticket assets sampled with a revise-from-ticket authorization. */
|
|
56
|
+
assets?: PinnedAsset[]
|
|
55
57
|
/** Absolute review budget accepted with a review-round-limit answer. */
|
|
56
58
|
reviewRoundCeiling?: number
|
|
57
59
|
/** seq of the `escalation.answered` event. */
|
|
@@ -130,6 +132,11 @@ export interface AutoMergeProjection {
|
|
|
130
132
|
/** Latest human command. False with no commandSeq means the default: off. */
|
|
131
133
|
requested: boolean
|
|
132
134
|
commandSeq?: number
|
|
135
|
+
/** The repository seq of the `dispatcher.auto-merge-default-set` fact the
|
|
136
|
+
* latest answering command (claim-time seed or fan-out) sampled, when one
|
|
137
|
+
* did. A per-build command with no `defaultSeq` preserves the previous
|
|
138
|
+
* value, so the default fan-out can tell an override from staleness. */
|
|
139
|
+
defaultSeq?: number
|
|
133
140
|
/** Latest recorded external application. It may acknowledge an older
|
|
134
141
|
* command; such a stale fact never changes `requested`/`commandSeq`. */
|
|
135
142
|
applied?: { enabled: boolean; commandSeq: number }
|
|
@@ -194,6 +201,9 @@ export interface BuildState {
|
|
|
194
201
|
lastEvent?: AbEvent
|
|
195
202
|
/** 0 for an empty log; `runner.attached {resumedFromSeq}` cites this. */
|
|
196
203
|
lastSeq: number
|
|
204
|
+
/** Ticket assets the build froze: set by `build.created`, replaced by a
|
|
205
|
+
* `spec.revised` that carries `assets` (§6.3). Empty for historical logs. */
|
|
206
|
+
pinnedAssets: PinnedAsset[]
|
|
197
207
|
/** Latest spec artifact rev — `spec.imported`/`spec.authored` set it,
|
|
198
208
|
* `spec.revised` bumps it (§6.3: rev N+1 restarts the build from plan). */
|
|
199
209
|
specRev?: number
|
|
@@ -279,6 +289,16 @@ export interface BuildState {
|
|
|
279
289
|
failures: Record<string, number>
|
|
280
290
|
}
|
|
281
291
|
|
|
292
|
+
/** Whether the build carries an outstanding `discardRequest`. Present exactly
|
|
293
|
+
* while the build is non-terminal and its discard is unsettled; the reducer
|
|
294
|
+
* settles it only by terminal completion. This is the single predicate shared
|
|
295
|
+
* by the auto-merge default fan-out (`autoMergeDefaultEligible`, AUT-418) and
|
|
296
|
+
* the per-build consent guard (`controlBuild`'s `discard-pending` refusal), so
|
|
297
|
+
* the two controls' discard exclusions cannot diverge silently. */
|
|
298
|
+
export function discardInFlight(state: Pick<BuildState, 'discardRequest'>): boolean {
|
|
299
|
+
return state.discardRequest !== undefined
|
|
300
|
+
}
|
|
301
|
+
|
|
282
302
|
export function reduceBuild(events: AbEvent[]): BuildState {
|
|
283
303
|
let attached = false
|
|
284
304
|
let pausedFlag = false
|
|
@@ -295,6 +315,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
295
315
|
const autoMerge: AutoMergeProjection = { requested: false }
|
|
296
316
|
let specRev: number | undefined
|
|
297
317
|
let restartSince = 0
|
|
318
|
+
let pinnedAssets: PinnedAsset[] = []
|
|
298
319
|
let finalizeCompletedSeq = 0
|
|
299
320
|
const finalizeSteps: BuildState['finalizeSteps'] = []
|
|
300
321
|
const plan: BuildState['plan'] = { round: 0, approved: false }
|
|
@@ -347,6 +368,8 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
347
368
|
// Facts the projection does not need (workspace liveness is the
|
|
348
369
|
// dispatcher's concern; finalize post-steps are failure-tolerant §5).
|
|
349
370
|
case 'build.created':
|
|
371
|
+
pinnedAssets = event.payload.assets ?? []
|
|
372
|
+
break
|
|
350
373
|
case 'workspace.provisioned':
|
|
351
374
|
case 'workspace.released':
|
|
352
375
|
case 'publication.requested':
|
|
@@ -443,10 +466,23 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
443
466
|
case 'build.auto-merge-requested':
|
|
444
467
|
autoMerge.requested = true
|
|
445
468
|
autoMerge.commandSeq = event.seq
|
|
469
|
+
// A fan-out (or seeded) command carries the default fact it answers; a
|
|
470
|
+
// bare per-build command must preserve the previous provenance.
|
|
471
|
+
if (event.payload.defaultSeq !== undefined) autoMerge.defaultSeq = event.payload.defaultSeq
|
|
446
472
|
break
|
|
447
473
|
case 'build.auto-merge-cancelled':
|
|
448
474
|
autoMerge.requested = false
|
|
449
475
|
autoMerge.commandSeq = event.seq
|
|
476
|
+
if (event.payload.defaultSeq !== undefined) autoMerge.defaultSeq = event.payload.defaultSeq
|
|
477
|
+
break
|
|
478
|
+
case 'build.auto-merge-default-observed':
|
|
479
|
+
// A no-op fan-out still advances the build's provenance. Without it, a
|
|
480
|
+
// matching default fact would stay strictly ahead of a bare per-build
|
|
481
|
+
// command and be re-applied on the next tick, reverting the operator's
|
|
482
|
+
// explicit choice (f_28b3fba1).
|
|
483
|
+
if (event.payload.defaultSeq > (autoMerge.defaultSeq ?? 0)) {
|
|
484
|
+
autoMerge.defaultSeq = event.payload.defaultSeq
|
|
485
|
+
}
|
|
450
486
|
break
|
|
451
487
|
case 'build.paused':
|
|
452
488
|
pausedFlag = true
|
|
@@ -467,6 +503,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
467
503
|
break
|
|
468
504
|
case 'spec.revised':
|
|
469
505
|
specRev = event.payload.artifact.rev
|
|
506
|
+
if (event.payload.assets !== undefined) pinnedAssets = event.payload.assets
|
|
470
507
|
// §6.3: rev N+1 restarts the build from plan. The restart boundary
|
|
471
508
|
// invalidates every approval and result at or before it, so it is also
|
|
472
509
|
// a verify cycle boundary. Finalize post-steps remain in full-log
|
|
@@ -661,6 +698,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
661
698
|
answer: event.payload.answer,
|
|
662
699
|
resolution: event.payload.resolution,
|
|
663
700
|
...(event.payload.artifact !== undefined ? { artifact: event.payload.artifact } : {}),
|
|
701
|
+
...(event.payload.assets !== undefined ? { assets: event.payload.assets } : {}),
|
|
664
702
|
...(event.payload.reviewRoundCeiling !== undefined
|
|
665
703
|
? { reviewRoundCeiling: event.payload.reviewRoundCeiling }
|
|
666
704
|
: {}),
|
|
@@ -725,6 +763,7 @@ export function reduceBuild(events: AbEvent[]): BuildState {
|
|
|
725
763
|
finalizeSteps,
|
|
726
764
|
lastEvent,
|
|
727
765
|
lastSeq: lastEvent?.seq ?? 0,
|
|
766
|
+
pinnedAssets,
|
|
728
767
|
specRev,
|
|
729
768
|
restartSince,
|
|
730
769
|
plan,
|
|
@@ -182,6 +182,12 @@ export const OPERATOR_TOOL_ANNOTATIONS = {
|
|
|
182
182
|
idempotentHint: false,
|
|
183
183
|
approval: 'default',
|
|
184
184
|
},
|
|
185
|
+
'sandbox.publish': {
|
|
186
|
+
readOnlyHint: false,
|
|
187
|
+
destructiveHint: true,
|
|
188
|
+
idempotentHint: false,
|
|
189
|
+
approval: 'default',
|
|
190
|
+
},
|
|
185
191
|
} as const satisfies Record<string, ToolAnnotationsTableEntry>
|
|
186
192
|
|
|
187
193
|
export type OperatorToolName = keyof typeof OPERATOR_TOOL_ANNOTATIONS
|
|
@@ -29,19 +29,27 @@ export async function setRepositorySetting(opts: {
|
|
|
29
29
|
enabled: boolean
|
|
30
30
|
/** Delegated-write attribution marker threaded onto the event's actor. */
|
|
31
31
|
via?: Via
|
|
32
|
+
/** Dispatcher invocation that performed the write. Present on the
|
|
33
|
+
* dashboard/launch-flag write paths so the two are distinguishable in the
|
|
34
|
+
* journal; optional so the operator API (which has no run) is unchanged. */
|
|
35
|
+
run?: string
|
|
32
36
|
}): Promise<{ enabled: boolean; event: RepositoryEventEnvelope }> {
|
|
33
37
|
await opts.store.ensureRepo(opts.repo)
|
|
38
|
+
const payload = {
|
|
39
|
+
enabled: opts.enabled,
|
|
40
|
+
...(opts.run !== undefined ? { run: opts.run } : {}),
|
|
41
|
+
}
|
|
34
42
|
const event =
|
|
35
43
|
opts.setting === 'intake'
|
|
36
44
|
? await opts.store.appendRepo(opts.repo, {
|
|
37
45
|
actor: humanActor(opts.user, opts.via),
|
|
38
46
|
type: 'dispatcher.intake-set',
|
|
39
|
-
payload
|
|
47
|
+
payload,
|
|
40
48
|
})
|
|
41
49
|
: await opts.store.appendRepo(opts.repo, {
|
|
42
50
|
actor: humanActor(opts.user, opts.via),
|
|
43
51
|
type: 'dispatcher.auto-merge-default-set',
|
|
44
|
-
payload
|
|
52
|
+
payload,
|
|
45
53
|
})
|
|
46
54
|
return { enabled: opts.enabled, event }
|
|
47
55
|
}
|
|
@@ -52,9 +60,12 @@ export async function toggleRepositorySetting(opts: {
|
|
|
52
60
|
user: string
|
|
53
61
|
setting: RepositorySetting
|
|
54
62
|
via?: Via
|
|
63
|
+
/** Dispatcher invocation that performed the write (see setRepositorySetting). */
|
|
64
|
+
run?: string
|
|
55
65
|
}): Promise<{ enabled: boolean; event: RepositoryEventEnvelope }> {
|
|
56
66
|
await opts.store.ensureRepo(opts.repo)
|
|
57
|
-
|
|
67
|
+
// Bounded read (AUT-489): the settings reducer consumes durable types only.
|
|
68
|
+
const settings = reduceDispatchSettings(await opts.store.getRepoStateEvents(opts.repo))
|
|
58
69
|
const enabled = opts.setting === 'intake' ? !settings.intake : !settings.defaultAutoMerge
|
|
59
70
|
return setRepositorySetting({ ...opts, enabled })
|
|
60
71
|
}
|
|
@@ -66,7 +77,8 @@ export async function toggleHarvestGate(opts: {
|
|
|
66
77
|
via?: Via
|
|
67
78
|
}): Promise<{ command: 'pause' | 'resume'; event: RepositoryEventEnvelope }> {
|
|
68
79
|
await opts.store.ensureRepo(opts.repo)
|
|
69
|
-
|
|
80
|
+
// Bounded read (AUT-489): the harvest reducer consumes durable types only.
|
|
81
|
+
const state = reduceHarvest(await opts.store.getRepoStateEvents(opts.repo))
|
|
70
82
|
const pending = state.pendingCommands.at(-1)
|
|
71
83
|
const requestedPaused = pending === undefined ? state.paused : pending.command === 'pause'
|
|
72
84
|
const command = requestedPaused ? 'resume' : 'pause'
|
|
@@ -87,7 +99,9 @@ export async function controlHarvestRun(opts: {
|
|
|
87
99
|
via?: Via
|
|
88
100
|
}): Promise<{ action: HarvestRunAction; event: RepositoryEventEnvelope }> {
|
|
89
101
|
await opts.store.ensureRepo(opts.repo)
|
|
90
|
-
|
|
102
|
+
// Bounded read (AUT-489): the harvest reducer and the dashboard harvest
|
|
103
|
+
// projection consume durable types only.
|
|
104
|
+
const events = await opts.store.getRepoStateEvents(opts.repo)
|
|
91
105
|
const state = reduceHarvest(events)
|
|
92
106
|
const projected = projectHarvest(events)
|
|
93
107
|
if (projected === undefined || projected.run !== opts.run) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// Core's operator surface: the tool registry behind `ab mcp`, the CLI-facing
|
|
2
|
+
// control/query/tickets/requests primitives, and the operator wire protocol.
|
|
3
|
+
// The operator HTTP server and client ship in
|
|
4
|
+
// `@defrex/autobuild-hosted-store-service` (see its `operator-api` subpath).
|
|
5
5
|
export {
|
|
6
6
|
answerRequestSchema,
|
|
7
7
|
buildControlRequestSchema,
|
|
@@ -9,6 +9,10 @@ export {
|
|
|
9
9
|
bulkControlRequestSchema,
|
|
10
10
|
harvestControlRequestSchema,
|
|
11
11
|
operatorErrorSchema,
|
|
12
|
+
sessionApprovalRequestSchema,
|
|
13
|
+
sessionCreateRequestSchema,
|
|
14
|
+
sessionMessageRequestSchema,
|
|
15
|
+
sessionWakeRequestSchema,
|
|
12
16
|
settingRequestSchema,
|
|
13
17
|
ticketBlockerRequestSchema,
|
|
14
18
|
ticketCreateRequestSchema,
|
|
@@ -19,6 +23,10 @@ export type {
|
|
|
19
23
|
OperatorAnswerRequest,
|
|
20
24
|
OperatorBuildControlRequest,
|
|
21
25
|
OperatorErrorBody,
|
|
26
|
+
OperatorSessionApprovalRequest,
|
|
27
|
+
OperatorSessionCreateRequest,
|
|
28
|
+
OperatorSessionMessageRequest,
|
|
29
|
+
OperatorSessionWakeRequest,
|
|
22
30
|
OperatorTicketBlockerRequest,
|
|
23
31
|
OperatorTicketCreateRequest,
|
|
24
32
|
OperatorTicketMoveRequest,
|
|
@@ -29,8 +37,32 @@ export type {
|
|
|
29
37
|
OperatorBuildView,
|
|
30
38
|
OperatorDashboardSnapshot,
|
|
31
39
|
} from './query'
|
|
32
|
-
export {
|
|
33
|
-
|
|
40
|
+
export {
|
|
41
|
+
controlHarvestRun,
|
|
42
|
+
OperatorControlError,
|
|
43
|
+
setRepositorySetting,
|
|
44
|
+
toggleHarvestGate,
|
|
45
|
+
toggleRepositorySetting,
|
|
46
|
+
} from './control'
|
|
47
|
+
export {
|
|
48
|
+
getHarvestStatus,
|
|
49
|
+
getOperatorBuild,
|
|
50
|
+
getOperatorDashboard,
|
|
51
|
+
getRepositoryStatus,
|
|
52
|
+
listOperatorBuilds,
|
|
53
|
+
OperatorQueryError,
|
|
54
|
+
OrchestratorConfigError,
|
|
55
|
+
orchestratorConfig,
|
|
56
|
+
} from './query'
|
|
57
|
+
export {
|
|
58
|
+
orchestratorWakeGlobs,
|
|
59
|
+
ORCHESTRATOR_ROUTE_LIMIT_SECONDS,
|
|
60
|
+
type OrchestratorConfig,
|
|
61
|
+
} from '../config/schema'
|
|
62
|
+
export type { OrchestratorTurnRunner } from '../orchestrator/turn-runner'
|
|
63
|
+
export { createOrchestratorTurnRunner } from '../orchestrator/turn-runner'
|
|
64
|
+
export { randomIds } from '../ids'
|
|
65
|
+
export type { Config } from '../config/schema'
|
|
34
66
|
export {
|
|
35
67
|
OPERATOR_NOTES_ARTIFACT,
|
|
36
68
|
TOOLS,
|
|
@@ -59,4 +91,37 @@ export type {
|
|
|
59
91
|
OperatorTicketDetail,
|
|
60
92
|
OperatorTicketQueue,
|
|
61
93
|
} from './tickets'
|
|
94
|
+
export {
|
|
95
|
+
getOperatorTicket,
|
|
96
|
+
listOperatorTickets,
|
|
97
|
+
mutateOperatorTicket,
|
|
98
|
+
} from './tickets'
|
|
99
|
+
export type { OperatorSandboxService } from './sandbox'
|
|
62
100
|
export * from './presentation'
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Hosted operator-server composition. The moved server (in
|
|
104
|
+
* `@defrex/autobuild-hosted-store-service`) builds its routes from these core
|
|
105
|
+
* primitives; they are re-exported here so the hosted package needs no core
|
|
106
|
+
* internals. Deliberately not a general public API — do not grow it for
|
|
107
|
+
* core-internal convenience.
|
|
108
|
+
*/
|
|
109
|
+
export {
|
|
110
|
+
BuildControlError,
|
|
111
|
+
controlBuild,
|
|
112
|
+
type BuildControlAction,
|
|
113
|
+
type BuildControlResult,
|
|
114
|
+
} from '../cli/build-control'
|
|
115
|
+
export {
|
|
116
|
+
BulkWalkError,
|
|
117
|
+
bulkControlRepository,
|
|
118
|
+
type BulkControlSummary,
|
|
119
|
+
} from '../cli/bulk-control'
|
|
120
|
+
export { effectiveStatus } from '../cli/dashboard/model'
|
|
121
|
+
export { reduceBuild } from '../kernel/reducer'
|
|
122
|
+
export { TicketOperationError } from '../ports/tickets/operations'
|
|
123
|
+
export { humanActor, type Via } from '../events/envelope'
|
|
124
|
+
export type { HarvestStatusView } from '../cli/harvest'
|
|
125
|
+
export type { RepositoryStatus } from '../cli/repository-status'
|
|
126
|
+
export type { BuildSummary } from '../cli/status'
|
|
127
|
+
export type { SessionRecord } from '../store/types'
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
+
import { composeBuildConfig } from '../config/live'
|
|
2
|
+
import type { PipelineSourceMeta } from '../config/pipeline-source'
|
|
1
3
|
import { configSchema, type Config } from '../config/schema'
|
|
4
|
+
import {
|
|
5
|
+
BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
6
|
+
parseBuildConfigMetadata,
|
|
7
|
+
parseEffectiveBuildConfig,
|
|
8
|
+
} from '../processes/build-execution-state'
|
|
9
|
+
import type { Artifact } from '../store/types'
|
|
2
10
|
import { detail, statusFilter, summarize, type BuildDetail, type BuildSummary } from '../cli/status'
|
|
3
11
|
import { projectRepositoryStatus, type RepositoryStatus } from '../cli/repository-status'
|
|
4
12
|
import { projectHarvestStatus, type HarvestStatusView } from '../cli/harvest'
|
|
5
13
|
import {
|
|
6
14
|
buildDashboardFromProjected,
|
|
15
|
+
effectiveStatus,
|
|
7
16
|
projectBuild,
|
|
8
17
|
type DashboardBuild,
|
|
9
18
|
type DashboardModel,
|
|
10
19
|
} from '../cli/dashboard/model'
|
|
11
|
-
import { reduceBuild } from '../kernel/reducer'
|
|
20
|
+
import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
21
|
+
import { DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT } from '../store/retention'
|
|
12
22
|
import { reduceDispatchStatus } from '../kernel/dispatch-status'
|
|
13
|
-
import {
|
|
23
|
+
import { readRepoEventsIfRecorded, unclaimedObservationCount } from '../processes/harvest'
|
|
24
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
14
25
|
import type { BuildStore, Clock } from '../store/types'
|
|
15
26
|
|
|
16
27
|
export type BuildListScope = 'active' | 'queued' | 'all'
|
|
@@ -25,10 +36,6 @@ export class OperatorQueryError extends Error {
|
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
38
|
|
|
28
|
-
async function repoEvents(store: BuildStore, repo: string) {
|
|
29
|
-
return (await store.getRepo(repo)) === null ? [] : store.getRepoEvents(repo)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
39
|
export async function listOperatorBuilds(opts: {
|
|
33
40
|
store: BuildStore
|
|
34
41
|
repo: string
|
|
@@ -36,9 +43,40 @@ export async function listOperatorBuilds(opts: {
|
|
|
36
43
|
now: Date
|
|
37
44
|
}): Promise<BuildSummary[]> {
|
|
38
45
|
const statuses = new Set(statusFilter(opts.scope === 'all', opts.scope === 'queued'))
|
|
46
|
+
// Records are read and held before the digest read: builds are never
|
|
47
|
+
// deleted, so every record iterated below is guaranteed an entry in the
|
|
48
|
+
// digest map fetched after it — a build created between the two reads
|
|
49
|
+
// appears only in the digest map, which is harmless — and the completeness
|
|
50
|
+
// check can only ever catch a genuine adapter bug, never a concurrent
|
|
51
|
+
// dispatch. The reverse order would let a concurrent creation abort the
|
|
52
|
+
// whole listing (AUT-488 finding f_3cb67aba).
|
|
53
|
+
const records = await opts.store.listBuilds()
|
|
54
|
+
// One digest read gates the per-build history reads (AUT-488): the active
|
|
55
|
+
// and queued scopes never show a terminal build, so a build whose digest
|
|
56
|
+
// already carries a terminal fact skips its `getEvents` round trip and the
|
|
57
|
+
// listing's store cost stays flat as finished builds accumulate. The `--all`
|
|
58
|
+
// scope includes terminal statuses, so it takes no digest read and skips
|
|
59
|
+
// nothing — byte-for-byte the old loop.
|
|
60
|
+
const digests = opts.scope === 'all' ? undefined : await opts.store.getRepoBuildDigests(opts.repo)
|
|
39
61
|
const output: BuildSummary[] = []
|
|
40
|
-
for (const record of
|
|
62
|
+
for (const record of records) {
|
|
41
63
|
if (record.repo !== opts.repo) continue
|
|
64
|
+
if (digests !== undefined) {
|
|
65
|
+
// Completeness is contractual (one entry per repo build); a missing
|
|
66
|
+
// entry is an adapter bug and must fail loudly rather than silently
|
|
67
|
+
// drop the build from the listing.
|
|
68
|
+
const digest = digests.get(record.slug)
|
|
69
|
+
if (digest === undefined) {
|
|
70
|
+
throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
|
|
71
|
+
}
|
|
72
|
+
// The digest's `terminal` follows `reduceBuild`'s terminal rule exactly
|
|
73
|
+
// (in-order overwrite of `build.completed`/`build.aborted`, never
|
|
74
|
+
// cleared — pinned against `reduceBuild` in store/digest.test.ts), so
|
|
75
|
+
// `terminal !== undefined` is exactly reduced status `done`/`aborted`,
|
|
76
|
+
// which the active and queued status sets never include. Skipping the
|
|
77
|
+
// history read therefore cannot change the emitted summaries.
|
|
78
|
+
if (digest.terminal !== undefined) continue
|
|
79
|
+
}
|
|
42
80
|
const projected = summarize(record, await opts.store.getEvents(record.slug), opts.now)
|
|
43
81
|
if (statuses.has(projected.status)) output.push(projected)
|
|
44
82
|
}
|
|
@@ -59,10 +97,10 @@ export async function effectiveConfig(
|
|
|
59
97
|
repo: string,
|
|
60
98
|
): Promise<{
|
|
61
99
|
config: Config
|
|
62
|
-
repositoryEvents:
|
|
100
|
+
repositoryEvents: RepositoryEvent[]
|
|
63
101
|
status: ReturnType<typeof reduceDispatchStatus>
|
|
64
102
|
}> {
|
|
65
|
-
const repositoryEvents = await
|
|
103
|
+
const repositoryEvents = await readRepoEventsIfRecorded(store, repo)
|
|
66
104
|
let latestRun: string | undefined
|
|
67
105
|
for (const event of repositoryEvents) {
|
|
68
106
|
if (event.type === 'dispatcher.run-started') latestRun = event.payload.run
|
|
@@ -112,6 +150,65 @@ export interface OperatorBuildView {
|
|
|
112
150
|
dashboardRow: DashboardBuild | null
|
|
113
151
|
}
|
|
114
152
|
|
|
153
|
+
interface PinnedProjection {
|
|
154
|
+
config: Config
|
|
155
|
+
revision?: number
|
|
156
|
+
pipelineSource?: PipelineSourceMeta
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read one build's pinned effective-config artifact (SPEC §16.1): its parsed
|
|
160
|
+
* build-owned sections composed over the live snapshot's deployment-owned
|
|
161
|
+
* ones, plus the artifact's metadata projection. Every row must be projected
|
|
162
|
+
* against the pipeline the build actually runs — the artifact's build-owned
|
|
163
|
+
* sections — never the dispatcher's live base-branch snapshot, which a pinned
|
|
164
|
+
* build may never execute. All read failures are display-only: an absent or
|
|
165
|
+
* malformed artifact degrades to the live config, the pre-pin behavior. */
|
|
166
|
+
async function readPinnedConfig(
|
|
167
|
+
store: BuildStore,
|
|
168
|
+
slug: string,
|
|
169
|
+
live: Config,
|
|
170
|
+
): Promise<PinnedProjection> {
|
|
171
|
+
let artifact: Artifact | null
|
|
172
|
+
try {
|
|
173
|
+
artifact = await store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
174
|
+
} catch {
|
|
175
|
+
artifact = null
|
|
176
|
+
}
|
|
177
|
+
if (artifact === null) return { config: live }
|
|
178
|
+
let config: Config
|
|
179
|
+
try {
|
|
180
|
+
config = composeBuildConfig(parseEffectiveBuildConfig(artifact), live)
|
|
181
|
+
} catch {
|
|
182
|
+
config = live
|
|
183
|
+
}
|
|
184
|
+
return { config, ...parseBuildConfigMetadata(artifact) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The shared null-row gate (AUT-486 snapshot, AUT-496 getOperatorBuild,
|
|
188
|
+
* AUT-487 digest): `effectiveStatus` maps only `done` outside the visible set
|
|
189
|
+
* (cli/dashboard/model.ts `isVisible`), so `projectBuild` returns null
|
|
190
|
+
* exactly for this state, and a pinned effective-config read for such a
|
|
191
|
+
* build could never be surfaced. One predicate so every consumer cannot
|
|
192
|
+
* diverge from each other or from the row projection: the digest's `terminal`
|
|
193
|
+
* is a third consumer of the same gate — `terminal === 'done'` is exactly
|
|
194
|
+
* `reduceBuild(...).status === 'done'` — and the snapshot uses it to skip a
|
|
195
|
+
* finished build's history and pinned-config reads without weakening this
|
|
196
|
+
* predicate as the authority. If cli/dashboard/model.ts ever adds a
|
|
197
|
+
* non-visible status, this predicate must follow — the snapshot
|
|
198
|
+
* byte-identical differential test and the done/aborted query tests pin the
|
|
199
|
+
* coupling. */
|
|
200
|
+
function projectsNoDashboardRow(state: BuildState): boolean {
|
|
201
|
+
return effectiveStatus(state) === 'done'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Attach the effective-config metadata (SPEC §16.1) to a projected row so an
|
|
205
|
+
* operator can see which autobuild.toml the build runs under. */
|
|
206
|
+
function decorateWithPinnedMeta(row: DashboardBuild, pinned: PinnedProjection): DashboardBuild {
|
|
207
|
+
if (pinned.revision !== undefined) row.effectiveConfigRev = pinned.revision
|
|
208
|
+
if (pinned.pipelineSource !== undefined) row.pipelineSource = pinned.pipelineSource
|
|
209
|
+
return row
|
|
210
|
+
}
|
|
211
|
+
|
|
115
212
|
export async function getOperatorBuild(opts: {
|
|
116
213
|
store: BuildStore
|
|
117
214
|
repo: string
|
|
@@ -122,22 +219,32 @@ export async function getOperatorBuild(opts: {
|
|
|
122
219
|
const events = await opts.store.getEvents(opts.slug)
|
|
123
220
|
const state = reduceBuild(events)
|
|
124
221
|
const { config } = await effectiveConfig(opts.store, opts.repo)
|
|
125
|
-
|
|
126
|
-
|
|
222
|
+
let dashboardRow: DashboardBuild | null = null
|
|
223
|
+
if (!projectsNoDashboardRow(state)) {
|
|
224
|
+
const pinned = await readPinnedConfig(opts.store, opts.slug, config)
|
|
225
|
+
const row = projectBuild(record, state, config, events, undefined, pinned.config)
|
|
226
|
+
// Implied by the gate; kept as an explicit check (not an assertion) so the
|
|
227
|
+
// code never lies if `effectiveStatus` and `projectBuild` ever drift.
|
|
228
|
+
if (row !== null) dashboardRow = decorateWithPinnedMeta(row, pinned)
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
detail: detail(record, events, opts.now),
|
|
232
|
+
dashboardRow,
|
|
233
|
+
}
|
|
127
234
|
}
|
|
128
235
|
|
|
129
236
|
export async function getRepositoryStatus(
|
|
130
237
|
store: BuildStore,
|
|
131
238
|
repo: string,
|
|
132
239
|
): Promise<RepositoryStatus> {
|
|
133
|
-
return projectRepositoryStatus(repo, await
|
|
240
|
+
return projectRepositoryStatus(repo, await readRepoEventsIfRecorded(store, repo))
|
|
134
241
|
}
|
|
135
242
|
|
|
136
243
|
export async function getHarvestStatus(
|
|
137
244
|
store: BuildStore,
|
|
138
245
|
repo: string,
|
|
139
246
|
): Promise<HarvestStatusView> {
|
|
140
|
-
return projectHarvestStatus(repo, await
|
|
247
|
+
return projectHarvestStatus(repo, await readRepoEventsIfRecorded(store, repo))
|
|
141
248
|
}
|
|
142
249
|
|
|
143
250
|
export interface OperatorDashboardSnapshot {
|
|
@@ -157,17 +264,50 @@ export async function getOperatorDashboard(opts: {
|
|
|
157
264
|
clock: Clock
|
|
158
265
|
}): Promise<OperatorDashboardSnapshot> {
|
|
159
266
|
const { config, repositoryEvents, status } = await effectiveConfig(opts.store, opts.repo)
|
|
267
|
+
const records = await opts.store.listBuilds()
|
|
268
|
+
// One digest read covers every build of the repository (AUT-487): the
|
|
269
|
+
// row gate (a `done` digest means no row) and the observation count no
|
|
270
|
+
// longer need per-build histories, so the snapshot's store traffic stays
|
|
271
|
+
// flat as finished builds accumulate. Row-rendering builds still get their
|
|
272
|
+
// full history below, and the digest itself is derived from the event log
|
|
273
|
+
// on every call — nothing is persisted or cached between snapshots.
|
|
274
|
+
const digests = await opts.store.getRepoBuildDigests(opts.repo)
|
|
160
275
|
const projected: DashboardBuild[] = []
|
|
161
276
|
let activeCount = 0
|
|
162
|
-
for (const record of
|
|
277
|
+
for (const record of records) {
|
|
163
278
|
if (record.repo !== opts.repo) continue
|
|
279
|
+
const digest = digests.get(record.slug)
|
|
280
|
+
// Completeness is contractual (one entry per repo build); a missing entry
|
|
281
|
+
// is an adapter bug and must fail loudly rather than silently drop the
|
|
282
|
+
// build from the active count or the row list.
|
|
283
|
+
if (digest === undefined) {
|
|
284
|
+
throw new Error(`getRepoBuildDigests is missing an entry for build "${record.slug}"`)
|
|
285
|
+
}
|
|
286
|
+
// Reduced status is `done`/`aborted` exactly when a terminal fact exists,
|
|
287
|
+
// so `terminal === undefined` is exactly the old active test — no history
|
|
288
|
+
// read needed for the count.
|
|
289
|
+
if (digest.terminal === undefined) activeCount += 1
|
|
290
|
+
if (digest.terminal === 'done') continue
|
|
291
|
+
// Both query surfaces share the `projectsNoDashboardRow` gate (AUT-486
|
|
292
|
+
// snapshot, AUT-496 detail query): a build that projects no row —
|
|
293
|
+
// `effectiveStatus` maps only `done` outside the visible set — never
|
|
294
|
+
// reads its pinned effective-config artifact, because the read result
|
|
295
|
+
// could never be surfaced. The digest already excluded `done` builds
|
|
296
|
+
// above; the gate stays as the authority for the row projection so the
|
|
297
|
+
// digest and the reducer cannot silently diverge. Every row-rendering
|
|
298
|
+
// build still gets its full history and pinned pipeline.
|
|
164
299
|
const events = await opts.store.getEvents(record.slug)
|
|
165
300
|
const state = reduceBuild(events)
|
|
166
|
-
if (state
|
|
167
|
-
const
|
|
168
|
-
|
|
301
|
+
if (projectsNoDashboardRow(state)) continue
|
|
302
|
+
const pinned = await readPinnedConfig(opts.store, record.slug, config)
|
|
303
|
+
const row = projectBuild(record, state, config, events, undefined, pinned.config)
|
|
304
|
+
if (row !== null) projected.push(decorateWithPinnedMeta(row, pinned))
|
|
169
305
|
}
|
|
170
|
-
|
|
306
|
+
// The unclaimed-observation count comes from the digests and the journal
|
|
307
|
+
// `effectiveConfig` already returned — no per-build history reads (AUT-487).
|
|
308
|
+
// The pure core writes nothing — a snapshot performs no store writes and
|
|
309
|
+
// never creates or locks the repository record.
|
|
310
|
+
const observationCount = unclaimedObservationCount({ digests, harvestEvents: repositoryEvents })
|
|
171
311
|
const warningLines = [
|
|
172
312
|
...status.roleWarnings,
|
|
173
313
|
...(status.warningNotice !== undefined ? [status.warningNotice] : []),
|
|
@@ -179,7 +319,7 @@ export async function getOperatorDashboard(opts: {
|
|
|
179
319
|
queued: status.queued ?? 0,
|
|
180
320
|
activeCount,
|
|
181
321
|
capacity: config.capacity,
|
|
182
|
-
observationCount
|
|
322
|
+
observationCount,
|
|
183
323
|
observationLimit: config.policy.harvestThreshold,
|
|
184
324
|
...(status.availableUpgrade !== undefined
|
|
185
325
|
? { availableUpgrade: status.availableUpgrade }
|
|
@@ -199,3 +339,52 @@ export async function getOperatorDashboard(opts: {
|
|
|
199
339
|
},
|
|
200
340
|
}
|
|
201
341
|
}
|
|
342
|
+
|
|
343
|
+
/** Thrown when a deposited `dispatcher-effective-config` artifact exists but
|
|
344
|
+
* cannot be parsed or validated: the hosted message route maps this to a 500
|
|
345
|
+
* `internal` — a broken config must never silently disable the orchestrator
|
|
346
|
+
* (AUT-342). */
|
|
347
|
+
export class OrchestratorConfigError extends Error {
|
|
348
|
+
constructor(message: string) {
|
|
349
|
+
super(message)
|
|
350
|
+
this.name = 'OrchestratorConfigError'
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The repository's orchestrator configuration, resolved from the latest
|
|
355
|
+
* deposited `dispatcher-effective-config` artifact (AUT-342). Returns null
|
|
356
|
+
* when no artifact exists — the orchestrator is disabled, and message
|
|
357
|
+
* posting behaves exactly as it did before this feature. Throws
|
|
358
|
+
* `OperatorQueryError` (code `effective-config-unavailable`) when an
|
|
359
|
+
* artifact exists but is unreadable or invalid: a deployment that deposited
|
|
360
|
+
* a broken config must fail loudly (a 500 on the message route), never
|
|
361
|
+
* silently disable the orchestrator. */
|
|
362
|
+
export async function orchestratorConfig(store: BuildStore, repo: string): Promise<Config | null> {
|
|
363
|
+
// A repository the store has no record for has no deposited artifact and
|
|
364
|
+
// no dispatcher history: the orchestrator is disabled there.
|
|
365
|
+
if ((await store.getRepo(repo)) === null) return null
|
|
366
|
+
const artifacts = await store.listRepoArtifacts(repo, DISPATCHER_EFFECTIVE_CONFIG_ARTIFACT)
|
|
367
|
+
if (artifacts.length === 0) return null
|
|
368
|
+
const latest = [...artifacts].sort((a, b) => b.revision - a.revision)[0]!
|
|
369
|
+
const artifact = await store.getRepoArtifact(repo, latest.kind, latest.revision)
|
|
370
|
+
if (artifact === null) {
|
|
371
|
+
throw new OrchestratorConfigError(
|
|
372
|
+
`effective config ${latest.kind}@${latest.revision} is not retrievable`,
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
let raw: unknown
|
|
376
|
+
try {
|
|
377
|
+
raw = JSON.parse(new TextDecoder().decode(artifact.content))
|
|
378
|
+
} catch {
|
|
379
|
+
throw new OrchestratorConfigError(
|
|
380
|
+
`effective config ${latest.kind}@${latest.revision} is not valid JSON`,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
const parsed = configSchema.safeParse(raw)
|
|
384
|
+
if (!parsed.success) {
|
|
385
|
+
throw new OrchestratorConfigError(
|
|
386
|
+
`effective config ${latest.kind}@${latest.revision} is invalid: ${parsed.error.message}`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
return parsed.data.orchestrator.enabled ? parsed.data : null
|
|
390
|
+
}
|