@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
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
* normal runner and dispatcher consume them.
|
|
8
8
|
*/
|
|
9
9
|
import type { AbEvent } from '../events/catalog'
|
|
10
|
+
import type { PinnedAsset } from '../events/payloads'
|
|
10
11
|
import { humanActor, KERNEL, type Via } from '../events/envelope'
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
reduceBuild,
|
|
14
|
+
discardInFlight,
|
|
15
|
+
type BuildState,
|
|
16
|
+
type OpenEscalation,
|
|
17
|
+
} from '../kernel/reducer'
|
|
12
18
|
import type { ArtifactRef, BuildOutcome, BuildStatus, TicketRef } from '../ontology'
|
|
13
19
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
14
20
|
import { specConformance } from '../spec-standard'
|
|
21
|
+
import { samplePinnedAssets } from '../store/ticket-assets'
|
|
15
22
|
import type { BuildStore } from '../store/types'
|
|
16
23
|
import { withSessionlessStore, type StoreOpener } from './store-opening'
|
|
17
24
|
|
|
@@ -107,6 +114,7 @@ export type BuildControlErrorCode =
|
|
|
107
114
|
| 'missing-authorization'
|
|
108
115
|
| 'no-longer-active'
|
|
109
116
|
| 'abort-pending'
|
|
117
|
+
| 'discard-pending'
|
|
110
118
|
| 'review-round-ceiling-unavailable'
|
|
111
119
|
| 'incompatible-answer-options'
|
|
112
120
|
|
|
@@ -226,6 +234,78 @@ function hasPendingAbort(state: BuildState): boolean {
|
|
|
226
234
|
return state.pendingCommands.some((command) => command.command === 'abort')
|
|
227
235
|
}
|
|
228
236
|
|
|
237
|
+
function discardPending(slug: string): BuildControlError {
|
|
238
|
+
return new BuildControlError(
|
|
239
|
+
'discard-pending',
|
|
240
|
+
`build "${slug}" has an outstanding discard request; auto-merge consent was not recorded. ` +
|
|
241
|
+
'The discard settles only when the build terminalizes — nothing was appended. ' +
|
|
242
|
+
'If the discard raced with runner attachment and you still want the change, ' +
|
|
243
|
+
'merge the PR manually.',
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Record or withdraw per-build auto-merge consent — the write path for
|
|
248
|
+
* `auto-merge-on`, `auto-merge-off`, and `toggle-auto-merge`.
|
|
249
|
+
*
|
|
250
|
+
* Ruling (AUT-445): recording consent on a build with an outstanding discard
|
|
251
|
+
* request is rejected — the fan-out's discard exclusion
|
|
252
|
+
* (`autoMergeDefaultEligible`'s discard clause, AUT-418) applied to explicit
|
|
253
|
+
* consent. Both controls call the same `discardInFlight` predicate from
|
|
254
|
+
* kernel/reducer.ts, so they cannot diverge silently. Withdrawing consent (OFF)
|
|
255
|
+
* is deliberately still allowed: `checkPr` honors revocation before anything
|
|
256
|
+
* else, so an OFF strictly shrinks the merge set, and it is the only way to
|
|
257
|
+
* withdraw consent recorded before the discard (a fan-out or claim-time seed) —
|
|
258
|
+
* refusing it would leave that discard-contradicting consent driving the merge
|
|
259
|
+
* anyway. The fan-out is direction-blind because it is a queue cleaner that
|
|
260
|
+
* withdraws nothing that survives the settlement; this guard is directional on
|
|
261
|
+
* purpose, and the divergence is stated here rather than left accidental.
|
|
262
|
+
*
|
|
263
|
+
* The guard runs inside a compare-and-append loop (the `appendIfCurrent` idiom
|
|
264
|
+
* the fan-out's dispatcher loop and `bulkControlRepository` already use), so a
|
|
265
|
+
* discard request that lands between the guard's read and this append cannot
|
|
266
|
+
* slip past it: the CAS misses, the loop re-reduces the log, and the guard
|
|
267
|
+
* fires on the fresh state. The toggle's direction is re-resolved from the
|
|
268
|
+
* re-read state each attempt. `activeState` runs before the guard on every
|
|
269
|
+
* attempt: a queued build (where the discard normally sits) still reports the
|
|
270
|
+
* existing `inactive` error. */
|
|
271
|
+
async function appendConsentCommand(
|
|
272
|
+
store: BuildStore,
|
|
273
|
+
slug: string,
|
|
274
|
+
user: string,
|
|
275
|
+
action: 'auto-merge-on' | 'auto-merge-off' | 'toggle-auto-merge',
|
|
276
|
+
via?: Via,
|
|
277
|
+
): Promise<BuildControlResult> {
|
|
278
|
+
while (true) {
|
|
279
|
+
const state = reduceBuild(await store.getEvents(slug))
|
|
280
|
+
activeState(slug, state)
|
|
281
|
+
const command =
|
|
282
|
+
action === 'toggle-auto-merge'
|
|
283
|
+
? state.autoMerge.requested
|
|
284
|
+
? 'auto-merge-off'
|
|
285
|
+
: 'auto-merge-on'
|
|
286
|
+
: action
|
|
287
|
+
if (command === 'auto-merge-on' && discardInFlight(state)) throw discardPending(slug)
|
|
288
|
+
const actor = humanActor(user, via)
|
|
289
|
+
// Written out rather than selected into a variable so `appendIfCurrent`'s
|
|
290
|
+
// `T extends EventType` infers a concrete payload type in each branch.
|
|
291
|
+
const appended =
|
|
292
|
+
command === 'auto-merge-on'
|
|
293
|
+
? await store.appendIfCurrent(slug, state.lastSeq, {
|
|
294
|
+
actor,
|
|
295
|
+
type: 'build.auto-merge-requested',
|
|
296
|
+
payload: {},
|
|
297
|
+
})
|
|
298
|
+
: await store.appendIfCurrent(slug, state.lastSeq, {
|
|
299
|
+
actor,
|
|
300
|
+
type: 'build.auto-merge-cancelled',
|
|
301
|
+
payload: {},
|
|
302
|
+
})
|
|
303
|
+
if (appended !== null) return { kind: 'command', slug, command, event: appended }
|
|
304
|
+
// The stream advanced under us (a racing discard, a fan-out write, a
|
|
305
|
+
// runner acknowledgement): loop to re-read and re-check before writing.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
229
309
|
function abortPending(slug: string): BuildControlError {
|
|
230
310
|
return new BuildControlError(
|
|
231
311
|
'abort-pending',
|
|
@@ -254,6 +334,9 @@ export type ReviseDecision =
|
|
|
254
334
|
| {
|
|
255
335
|
kind: 'complete'
|
|
256
336
|
artifact: ArtifactRef
|
|
337
|
+
/** The assets sampled with the authorization; absent for a supplied body
|
|
338
|
+
* and for authorizations recorded before assets were pinned. */
|
|
339
|
+
assets?: PinnedAsset[]
|
|
257
340
|
open: OpenEscalation[]
|
|
258
341
|
escalationSeq: number
|
|
259
342
|
}
|
|
@@ -289,7 +372,13 @@ export function decideRevise(
|
|
|
289
372
|
}
|
|
290
373
|
}
|
|
291
374
|
const escalationSeq = Math.max(...[...open, ...pending].map((item) => item.seq))
|
|
292
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
kind: 'complete',
|
|
377
|
+
artifact: authorized.artifact,
|
|
378
|
+
...(authorized.assets !== undefined ? { assets: authorized.assets } : {}),
|
|
379
|
+
open,
|
|
380
|
+
escalationSeq,
|
|
381
|
+
}
|
|
293
382
|
}
|
|
294
383
|
if (open.length === 0) {
|
|
295
384
|
const paused =
|
|
@@ -425,9 +514,14 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
425
514
|
switch (opts.action.kind) {
|
|
426
515
|
case 'pause':
|
|
427
516
|
case 'resume':
|
|
517
|
+
return appendCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
|
|
518
|
+
|
|
428
519
|
case 'auto-merge-on':
|
|
429
520
|
case 'auto-merge-off':
|
|
430
|
-
|
|
521
|
+
case 'toggle-auto-merge':
|
|
522
|
+
// Consent has its own guarded, compare-and-append write path; the
|
|
523
|
+
// outer `activeState` above only short-circuits the common inactive case.
|
|
524
|
+
return appendConsentCommand(opts.store, opts.slug, user, opts.action.kind, opts.via)
|
|
431
525
|
|
|
432
526
|
case 'dashboard-pause': {
|
|
433
527
|
const pendingPause = state.pendingCommands.some((command) => command.command === 'pause')
|
|
@@ -464,15 +558,6 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
464
558
|
return appendCommand(opts.store, opts.slug, user, 'resume', opts.via)
|
|
465
559
|
}
|
|
466
560
|
|
|
467
|
-
case 'toggle-auto-merge':
|
|
468
|
-
return appendCommand(
|
|
469
|
-
opts.store,
|
|
470
|
-
opts.slug,
|
|
471
|
-
user,
|
|
472
|
-
state.autoMerge.requested ? 'auto-merge-off' : 'auto-merge-on',
|
|
473
|
-
opts.via,
|
|
474
|
-
)
|
|
475
|
-
|
|
476
561
|
case 'answer': {
|
|
477
562
|
const captured =
|
|
478
563
|
opts.action.escalationIds === undefined ? undefined : new Set(opts.action.escalationIds)
|
|
@@ -510,11 +595,16 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
510
595
|
if (initial.kind === 'refuse') throwRefusal(initial)
|
|
511
596
|
|
|
512
597
|
let artifact: ArtifactRef
|
|
598
|
+
// From-ticket revisions re-pin the ticket's assets; a supplied body
|
|
599
|
+
// leaves the pinned set alone (`undefined`). Sampled once, with the
|
|
600
|
+
// body, and persisted on the answers so a retry reuses it.
|
|
601
|
+
let assets: PinnedAsset[] | undefined
|
|
513
602
|
let authorizedEarlier = initial.kind === 'complete'
|
|
514
603
|
if (initial.kind === 'complete') {
|
|
515
604
|
// Completion rule: recorded authorization wins before the caller's
|
|
516
605
|
// lazy body source is opened, regardless of remaining blockers.
|
|
517
606
|
artifact = initial.artifact
|
|
607
|
+
assets = initial.assets
|
|
518
608
|
} else {
|
|
519
609
|
let body: string
|
|
520
610
|
const source = opts.action.resolve.body
|
|
@@ -534,6 +624,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
534
624
|
)
|
|
535
625
|
}
|
|
536
626
|
body = await opts.readTicketBody(record.ticket)
|
|
627
|
+
assets = await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
|
|
537
628
|
}
|
|
538
629
|
const conformance = specConformance(body)
|
|
539
630
|
if (!conformance.conforms) {
|
|
@@ -564,6 +655,7 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
564
655
|
if (decision.kind === 'refuse') throwRefusal(decision)
|
|
565
656
|
if (decision.kind === 'complete') {
|
|
566
657
|
artifact = decision.artifact
|
|
658
|
+
assets = decision.assets
|
|
567
659
|
authorizedEarlier = true
|
|
568
660
|
}
|
|
569
661
|
const answer = guidance === '' ? SPEC_REVISION_ANSWER : guidance
|
|
@@ -580,13 +672,18 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
|
|
|
580
672
|
answer,
|
|
581
673
|
resolution: 'revise-spec',
|
|
582
674
|
artifact,
|
|
675
|
+
...(assets !== undefined ? { assets } : {}),
|
|
583
676
|
},
|
|
584
677
|
})
|
|
585
678
|
}
|
|
586
679
|
await opts.store.append(opts.slug, {
|
|
587
680
|
actor: KERNEL,
|
|
588
681
|
type: 'spec.revised',
|
|
589
|
-
payload: {
|
|
682
|
+
payload: {
|
|
683
|
+
artifact,
|
|
684
|
+
escalation: decision.escalationSeq,
|
|
685
|
+
...(assets !== undefined ? { assets } : {}),
|
|
686
|
+
},
|
|
590
687
|
})
|
|
591
688
|
if (alsoPaused) {
|
|
592
689
|
await opts.store.append(opts.slug, {
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from '../ports/workspace/phase-scratch'
|
|
45
45
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
46
46
|
import type { BuildStore } from '../store/types'
|
|
47
|
+
import { writeAssetTo } from './ticket-assets'
|
|
47
48
|
import type { CliEnv } from './env'
|
|
48
49
|
|
|
49
50
|
export interface ContextDeps {
|
|
@@ -81,6 +82,16 @@ export interface ContextManifest {
|
|
|
81
82
|
notesPath?: string
|
|
82
83
|
/** relPath (under .ab/) → source artifact ref, or 'derived'. */
|
|
83
84
|
materialized: Record<string, MaterializedEntry>
|
|
85
|
+
/** The ticket assets this build froze (SPEC §6.3), each written to `path`
|
|
86
|
+
* (relative to the workspace). Omitted when the build pinned none. */
|
|
87
|
+
assets?: {
|
|
88
|
+
kind: string
|
|
89
|
+
name: string
|
|
90
|
+
revision: number
|
|
91
|
+
layout: 'file' | 'tree'
|
|
92
|
+
size: number
|
|
93
|
+
path: string
|
|
94
|
+
}[]
|
|
84
95
|
/** From the latest `implement.completed` (§8.3 code-review/verify inputs). */
|
|
85
96
|
commitRange?: CommitRange
|
|
86
97
|
/** Reconcile: `{baseSha}` freshly recorded by this attempt's phase start (§15.7). */
|
|
@@ -276,6 +287,41 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
|
|
|
276
287
|
}
|
|
277
288
|
}
|
|
278
289
|
|
|
290
|
+
if (inputs.assets === true && state.pinnedAssets.length > 0) {
|
|
291
|
+
// The pin, not the ticket's current state: the revisions `build.created`
|
|
292
|
+
// (or the latest re-pinning `spec.revised`) recorded, fetched by explicit
|
|
293
|
+
// revision so a later attach/replace/remove cannot change what a phase sees.
|
|
294
|
+
const root = locate('assets')
|
|
295
|
+
manifest.assets = []
|
|
296
|
+
for (const pinned of state.pinnedAssets) {
|
|
297
|
+
const asset = await store.getPinnedTicketAsset(
|
|
298
|
+
build,
|
|
299
|
+
pinned.kind,
|
|
300
|
+
pinned.name,
|
|
301
|
+
pinned.revision,
|
|
302
|
+
)
|
|
303
|
+
if (asset === null) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`pinned ticket asset ${pinned.kind}/${pinned.name} revision ${pinned.revision} is missing from the store`,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
const relPath = `${root}/${pinned.kind}/${pinned.name}`
|
|
309
|
+
const target = join(abDir, relPath)
|
|
310
|
+
// A file asset lands inside its directory as `<filename>`; a tree asset
|
|
311
|
+
// keeps its relative tree, empty directories included.
|
|
312
|
+
await mkdir(target, { recursive: true })
|
|
313
|
+
await writeAssetTo(asset, target)
|
|
314
|
+
manifest.assets.push({
|
|
315
|
+
kind: pinned.kind,
|
|
316
|
+
name: pinned.name,
|
|
317
|
+
revision: pinned.revision,
|
|
318
|
+
layout: pinned.layout,
|
|
319
|
+
size: pinned.size,
|
|
320
|
+
path: `.ab/${relPath}`,
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
279
325
|
if (inputs.spec === true) {
|
|
280
326
|
// §6.3 immutability: the spec a phase sees is the rev the spec.* events
|
|
281
327
|
// anchor (spec.imported/authored/revised → state.specRev), never the
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import type { AbEvent } from '../../events/catalog'
|
|
39
39
|
import type { RepositoryEvent } from '../../events/repository'
|
|
40
40
|
import type { Config } from '../../config/schema'
|
|
41
|
+
import type { PipelineSourceMeta } from '../../config/pipeline-source'
|
|
41
42
|
import type { BuildState, PhaseContext, PrLifecycle } from '../../kernel/reducer'
|
|
42
43
|
import { currentAutoMergeDeferral } from '../../kernel/auto-merge'
|
|
43
44
|
import { decideNext } from '../../kernel/engine'
|
|
@@ -104,6 +105,12 @@ export interface PipelineStep {
|
|
|
104
105
|
/** Absent ⇒ the step has never run in the current spec scope ⇒ no time is
|
|
105
106
|
* shown (AC 6). The renderer composes the elapsed segment from this. */
|
|
106
107
|
timing?: StepTiming
|
|
108
|
+
/** Display-only, like `qualifier`, and never consulted for routing: the
|
|
109
|
+
* parked merge step's no-consent explanation — why nothing will happen
|
|
110
|
+
* without operator action, and which control unblocks it. Attached when the
|
|
111
|
+
* build is current at merge with effective auto-merge off; the terminal
|
|
112
|
+
* row/detail and the web row/detail render the same words. */
|
|
113
|
+
reason?: string
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
export type DashboardSelection =
|
|
@@ -155,6 +162,11 @@ export interface DashboardBuild {
|
|
|
155
162
|
pr?: { url: string; state: PrLifecycle }
|
|
156
163
|
/** Chronological session history projected from the retained raw log. */
|
|
157
164
|
sessions?: DashboardSession[]
|
|
165
|
+
/** Which `autobuild.toml` the dispatcher pinned this build's pipeline from,
|
|
166
|
+
* and the effective-config revision it was deposited under (SPEC §16.1).
|
|
167
|
+
* Present once an effective-config artifact exists. */
|
|
168
|
+
pipelineSource?: PipelineSourceMeta
|
|
169
|
+
effectiveConfigRev?: number
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
export type DashboardView =
|
|
@@ -329,6 +341,18 @@ export function autoMergeDisplay(state: BuildState): AutoMergeDisplay {
|
|
|
329
341
|
return 'cancelling'
|
|
330
342
|
}
|
|
331
343
|
|
|
344
|
+
/** The shared no-consent wording for a build parked on an open PR. One string
|
|
345
|
+
* so the terminal row/detail and the web row/detail say the same words, and
|
|
346
|
+
* so it names the per-build control that unblocks the parked build. */
|
|
347
|
+
export function autoMergeConsentReason(slug: string): string {
|
|
348
|
+
return (
|
|
349
|
+
`no auto-merge consent has been requested — request it with m on the build row or ` +
|
|
350
|
+
'`ab auto-merge ' +
|
|
351
|
+
slug +
|
|
352
|
+
' on`'
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
332
356
|
/**
|
|
333
357
|
* One step, built through one helper so the precedence rule cannot be applied
|
|
334
358
|
* inconsistently: **`current > done > provisional > pending`**. In particular,
|
|
@@ -342,6 +366,7 @@ interface StepExtra {
|
|
|
342
366
|
qualifier?: 'failed' | 'skipped' | 'waiting'
|
|
343
367
|
count?: number
|
|
344
368
|
timing?: StepTiming
|
|
369
|
+
reason?: string
|
|
345
370
|
}
|
|
346
371
|
|
|
347
372
|
function step(label: string, done: boolean, current: boolean, extra: StepExtra = {}): PipelineStep {
|
|
@@ -357,6 +382,7 @@ function step(label: string, done: boolean, current: boolean, extra: StepExtra =
|
|
|
357
382
|
...(extra.qualifier !== undefined ? { qualifier: extra.qualifier } : {}),
|
|
358
383
|
...(extra.count !== undefined ? { count: extra.count } : {}),
|
|
359
384
|
...(extra.timing !== undefined ? { timing: extra.timing } : {}),
|
|
385
|
+
...(extra.reason !== undefined ? { reason: extra.reason } : {}),
|
|
360
386
|
}
|
|
361
387
|
}
|
|
362
388
|
|
|
@@ -490,6 +516,14 @@ function timingFor(
|
|
|
490
516
|
|
|
491
517
|
/**
|
|
492
518
|
* `record` + `state` → one dashboard row, or `null` only for terminal builds.
|
|
519
|
+
*
|
|
520
|
+
* `config` is the dispatcher's live snapshot; `pinnedConfig` (SPEC §16.1) is
|
|
521
|
+
* the build's own pipeline — its deposited effective-config artifact's
|
|
522
|
+
* build-owned sections composed over `config`'s deployment-owned ones. A
|
|
523
|
+
* build's verify/finalize universe and next-action are decided from the
|
|
524
|
+
* pinned pipeline: the live snapshot describes the base branch, which a
|
|
525
|
+
* pinned build may never execute (the AUT-366 class). Absent artifact ⇒ the
|
|
526
|
+
* live config, the pre-pin behavior.
|
|
493
527
|
*/
|
|
494
528
|
export function projectBuild(
|
|
495
529
|
record: BuildRecord,
|
|
@@ -497,7 +531,9 @@ export function projectBuild(
|
|
|
497
531
|
config: Config,
|
|
498
532
|
events: AbEvent[],
|
|
499
533
|
streams?: readonly StreamRecord[],
|
|
534
|
+
pinnedConfig?: Config,
|
|
500
535
|
): DashboardBuild | null {
|
|
536
|
+
const pipeline = pinnedConfig ?? config
|
|
501
537
|
const status = effectiveStatus(state)
|
|
502
538
|
if (!isVisible(status)) return null
|
|
503
539
|
const abortProgress =
|
|
@@ -663,7 +699,7 @@ export function projectBuild(
|
|
|
663
699
|
const verifySkipped = (s: string): boolean =>
|
|
664
700
|
cycle.some((r) => r.step === s && r.outcome === 'skipped')
|
|
665
701
|
const verifySatisfied = (s: string): boolean => verifyPassed(s) || verifySkipped(s)
|
|
666
|
-
const verifyDrained = !cycleFailed &&
|
|
702
|
+
const verifyDrained = !cycleFailed && pipeline.verify.steps.every(verifySatisfied)
|
|
667
703
|
|
|
668
704
|
// A loop is settled only if its standing approval survived the last restart
|
|
669
705
|
// AND no later producer round reopened it. `approved` alone is a full-log
|
|
@@ -713,7 +749,7 @@ export function projectBuild(
|
|
|
713
749
|
// Finalize ran for the CURRENT spec — NOT `prState !== undefined`, which a
|
|
714
750
|
// spec restart never resets while the engine re-runs finalize from scratch.
|
|
715
751
|
const finalizeDone = state.finalizeCompletedSeq > restartSince
|
|
716
|
-
const postStepsDrained =
|
|
752
|
+
const postStepsDrained = pipeline.finalize.steps.every((s) =>
|
|
717
753
|
finalizeSteps.some((f) => f.step === s),
|
|
718
754
|
)
|
|
719
755
|
|
|
@@ -746,7 +782,7 @@ export function projectBuild(
|
|
|
746
782
|
}),
|
|
747
783
|
]
|
|
748
784
|
|
|
749
|
-
for (const s of
|
|
785
|
+
for (const s of pipeline.verify.steps) {
|
|
750
786
|
const phase = verifyPhase(s)
|
|
751
787
|
const current = at(phase)
|
|
752
788
|
const stepResults = cycle.filter((r) => r.step === s)
|
|
@@ -787,7 +823,7 @@ export function projectBuild(
|
|
|
787
823
|
timing: timingFor(intervals, 'finalize', restartSince, frozenNow),
|
|
788
824
|
}),
|
|
789
825
|
)
|
|
790
|
-
for (const s of
|
|
826
|
+
for (const s of pipeline.finalize.steps) {
|
|
791
827
|
// Post-steps have no `.started` event, so they carry no timing.
|
|
792
828
|
const done = finalizeSteps.find((f) => f.step === s)
|
|
793
829
|
steps.push(
|
|
@@ -839,6 +875,17 @@ export function projectBuild(
|
|
|
839
875
|
? { accumulatedMs: Math.max(0, frozenNow - lastMs) }
|
|
840
876
|
: { accumulatedMs: 0, runningSince: lastMs }
|
|
841
877
|
steps.push(step('merge', false, mergeCurrent, { qualifier: 'waiting', timing: mergeTiming }))
|
|
878
|
+
const autoMerge = autoMergeDisplay(state)
|
|
879
|
+
// The parked no-consent reason: a merge-current build whose effective
|
|
880
|
+
// auto-merge state is off will never merge without operator action, and the
|
|
881
|
+
// step must say so (and name the control) wherever the pipeline renders.
|
|
882
|
+
// Display-only; the deferral `mergeWaitReason` stays in `blockers`.
|
|
883
|
+
if (mergeCurrent && autoMerge === 'off') {
|
|
884
|
+
steps[steps.length - 1] = {
|
|
885
|
+
...steps[steps.length - 1]!,
|
|
886
|
+
reason: autoMergeConsentReason(record.slug),
|
|
887
|
+
}
|
|
888
|
+
}
|
|
842
889
|
|
|
843
890
|
const setupError =
|
|
844
891
|
state.infrastructureFailure !== undefined
|
|
@@ -855,7 +902,7 @@ export function projectBuild(
|
|
|
855
902
|
: `exit status ${state.setupFailure.exitStatus}`
|
|
856
903
|
}): ${state.setupFailure.output || '(no output)'}`
|
|
857
904
|
|
|
858
|
-
const decision = decideNext(events,
|
|
905
|
+
const decision = decideNext(events, pipeline)
|
|
859
906
|
const mergeWaitReason =
|
|
860
907
|
decision.kind === 'wait' && decision.reason === 'awaiting-pr'
|
|
861
908
|
? currentAutoMergeDeferral(events, state)
|
|
@@ -875,7 +922,7 @@ export function projectBuild(
|
|
|
875
922
|
...(state.reviewRoundCeilings.plan !== undefined || state.reviewRoundCeilings.code !== undefined
|
|
876
923
|
? { reviewRoundCeilings: { ...state.reviewRoundCeilings } }
|
|
877
924
|
: {}),
|
|
878
|
-
autoMerge
|
|
925
|
+
autoMerge,
|
|
879
926
|
...(state.pr !== undefined && state.prState !== undefined
|
|
880
927
|
? { pr: { url: state.pr.url, state: state.prState } }
|
|
881
928
|
: {}),
|
|
@@ -1084,7 +1131,10 @@ interface RepositoryHarvestProjection {
|
|
|
1084
1131
|
harvest?: DashboardHarvest
|
|
1085
1132
|
}
|
|
1086
1133
|
|
|
1087
|
-
|
|
1134
|
+
/** The repository-journal half of a legacy dashboard frame (AUT-545): the
|
|
1135
|
+
* harvest projection and the pause flag exactly as `buildDashboardFromProjected`
|
|
1136
|
+
* derives them, exposed for tests that pin the bounded read's equivalence. */
|
|
1137
|
+
export function projectRepositoryHarvest(events: RepositoryEvent[]): RepositoryHarvestProjection {
|
|
1088
1138
|
const state = reduceHarvest(events)
|
|
1089
1139
|
const harvest = projectHarvestRun(events, state)
|
|
1090
1140
|
return {
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import type { Config } from '../../config/schema'
|
|
2
|
+
import { composeBuildConfig } from '../../config/live'
|
|
3
|
+
import type { PipelineSourceMeta } from '../../config/pipeline-source'
|
|
2
4
|
import type { AbEvent } from '../../events/catalog'
|
|
3
5
|
import { reduceBuild, type BuildState } from '../../kernel/reducer'
|
|
4
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
BUILD_EFFECTIVE_CONFIG_ARTIFACT,
|
|
8
|
+
parseBuildConfigMetadata,
|
|
9
|
+
parseEffectiveBuildConfig,
|
|
10
|
+
} from '../../processes/build-execution-state'
|
|
11
|
+
import type { Artifact, BuildRecord, StreamRecord, StreamScope } from '../../store/types'
|
|
5
12
|
import { projectBuild, type DashboardBuild } from './model'
|
|
6
13
|
|
|
7
14
|
/** The read-only BuildStore surface needed to construct dashboard build rows.
|
|
8
15
|
* `listStreams` is optional: when present, build-scope records enrich each
|
|
9
|
-
* row's session history with authoritative stream ids and statuses (SPEC §9).
|
|
16
|
+
* row's session history with authoritative stream ids and statuses (SPEC §9).
|
|
17
|
+
* `getArtifact` is optional: when present, the effective-config artifact's
|
|
18
|
+
* metadata decorates each row with its pinned pipeline source and revision
|
|
19
|
+
* (SPEC §16.1). */
|
|
10
20
|
export interface DashboardBuildReader {
|
|
11
21
|
listBuilds(): Promise<BuildRecord[]>
|
|
12
22
|
getEvents(slug: string, sinceSeq?: number): Promise<AbEvent[]>
|
|
13
23
|
listStreams?(scope: StreamScope): Promise<StreamRecord[]>
|
|
24
|
+
getArtifact?(slug: string, kind: string): Promise<Artifact | null>
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
export interface DashboardPollSnapshot {
|
|
@@ -109,6 +120,60 @@ export class DashboardBuildPollCache {
|
|
|
109
120
|
return snapshot.revision === this.committedRevision
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
/** Effective-config artifact facts for one build (SPEC §16.1), when the
|
|
124
|
+
* reader exposes the artifact primitive: the metadata projection plus the
|
|
125
|
+
* artifact's parsed content. A malformed artifact still yields its metadata
|
|
126
|
+
* (config absent); all failures are display-only. */
|
|
127
|
+
private async readEffectiveConfig(
|
|
128
|
+
slug: string,
|
|
129
|
+
): Promise<
|
|
130
|
+
{ revision?: number; pipelineSource?: PipelineSourceMeta; config?: Config } | undefined
|
|
131
|
+
> {
|
|
132
|
+
const getArtifact = this.reader.getArtifact
|
|
133
|
+
if (getArtifact === undefined) return undefined
|
|
134
|
+
try {
|
|
135
|
+
const artifact = await getArtifact.call(this.reader, slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
136
|
+
if (artifact === null) return undefined
|
|
137
|
+
let config: Config | undefined
|
|
138
|
+
try {
|
|
139
|
+
config = parseEffectiveBuildConfig(artifact)
|
|
140
|
+
} catch {
|
|
141
|
+
config = undefined
|
|
142
|
+
}
|
|
143
|
+
return { ...parseBuildConfigMetadata(artifact), config }
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Project one build row against its PINNED pipeline (SPEC §16.1) and
|
|
150
|
+
* attach the effective-config metadata. The row's verify/finalize steps and
|
|
151
|
+
* next-action must describe the pipeline the build actually runs — the
|
|
152
|
+
* artifact's build-owned sections — never the dispatcher's live base-branch
|
|
153
|
+
* snapshot, which a pinned build may never execute. Deployment-owned
|
|
154
|
+
* sections (roles/policy) still come from the live snapshot, so a reload
|
|
155
|
+
* that reaches the build is reflected. A build with no artifact yet (queued
|
|
156
|
+
* or pre-pin) projects from the live config, the pre-pin behavior. */
|
|
157
|
+
private async projectRow(
|
|
158
|
+
record: BuildRecord,
|
|
159
|
+
state: BuildState,
|
|
160
|
+
events: AbEvent[],
|
|
161
|
+
config: Config,
|
|
162
|
+
): Promise<DashboardBuild | null> {
|
|
163
|
+
const [streams, artifact] = await Promise.all([
|
|
164
|
+
this.readStreams(record.slug),
|
|
165
|
+
this.readEffectiveConfig(record.slug),
|
|
166
|
+
])
|
|
167
|
+
const pinnedConfig =
|
|
168
|
+
artifact?.config !== undefined ? composeBuildConfig(artifact.config, config) : undefined
|
|
169
|
+
const build = projectBuild(record, state, config, events, streams, pinnedConfig)
|
|
170
|
+
if (build !== null && artifact !== undefined) {
|
|
171
|
+
if (artifact.revision !== undefined) build.effectiveConfigRev = artifact.revision
|
|
172
|
+
if (artifact.pipelineSource !== undefined) build.pipelineSource = artifact.pipelineSource
|
|
173
|
+
}
|
|
174
|
+
return build
|
|
175
|
+
}
|
|
176
|
+
|
|
112
177
|
refresh(
|
|
113
178
|
config: Config = this.config,
|
|
114
179
|
configRevision = this.configRevision,
|
|
@@ -146,13 +211,7 @@ export class DashboardBuildPollCache {
|
|
|
146
211
|
configChanged
|
|
147
212
|
? {
|
|
148
213
|
...current,
|
|
149
|
-
build:
|
|
150
|
-
record,
|
|
151
|
-
current.state,
|
|
152
|
-
config,
|
|
153
|
-
current.events,
|
|
154
|
-
await this.readStreams(record.slug),
|
|
155
|
-
),
|
|
214
|
+
build: await this.projectRow(record, current.state, current.events, config),
|
|
156
215
|
}
|
|
157
216
|
: current,
|
|
158
217
|
)
|
|
@@ -169,7 +228,7 @@ export class DashboardBuildPollCache {
|
|
|
169
228
|
kind: 'live',
|
|
170
229
|
events,
|
|
171
230
|
state,
|
|
172
|
-
build:
|
|
231
|
+
build: await this.projectRow(record, state, events, config),
|
|
173
232
|
})
|
|
174
233
|
}
|
|
175
234
|
|
|
@@ -250,7 +250,8 @@ function renderStep(step: PipelineStep, color: boolean, now: number): string {
|
|
|
250
250
|
parts.push(`${formatDuration(elapsedMs)}${count}`)
|
|
251
251
|
}
|
|
252
252
|
const note = parts.length > 0 ? `(${parts.join(', ')})` : ''
|
|
253
|
-
const
|
|
253
|
+
const reason = step.reason !== undefined ? ` — ${step.reason}` : ''
|
|
254
|
+
const text = `${GLYPH[step.state]} ${step.label}${note}${reason}`
|
|
254
255
|
const painted = paint(text, STEP_COLOR[step.state], color)
|
|
255
256
|
return step.state === 'current' ? paint(painted, 'bold', color) : painted
|
|
256
257
|
}
|
|
@@ -307,6 +308,7 @@ function renderBuild(
|
|
|
307
308
|
widths: Widths,
|
|
308
309
|
selected: boolean,
|
|
309
310
|
selecting: boolean,
|
|
311
|
+
defaultAutoMerge: boolean,
|
|
310
312
|
): string[] {
|
|
311
313
|
const { color, width, now } = opts
|
|
312
314
|
|
|
@@ -329,10 +331,22 @@ function renderBuild(
|
|
|
329
331
|
// right-justifies the status word so it is never truncated and always ends at
|
|
330
332
|
// the frame's right edge (AC 3, AC 5).
|
|
331
333
|
const rightTokens: string[] = []
|
|
332
|
-
|
|
334
|
+
// The state is ALWAYS drawn — including `off` — so consent is never conveyed
|
|
335
|
+
// by the absence of a token alone. `off` turns red while the repository
|
|
336
|
+
// default reads ON: the default-on/build-off mismatch an operator must see
|
|
337
|
+
// without opening the detail view.
|
|
338
|
+
{
|
|
333
339
|
const autoColor: ColorName =
|
|
334
|
-
build.autoMerge === 'enabled'
|
|
335
|
-
|
|
340
|
+
build.autoMerge === 'enabled'
|
|
341
|
+
? 'green'
|
|
342
|
+
: build.autoMerge === 'requested'
|
|
343
|
+
? 'cyan'
|
|
344
|
+
: build.autoMerge === 'cancelling'
|
|
345
|
+
? 'yellow'
|
|
346
|
+
: defaultAutoMerge
|
|
347
|
+
? 'red'
|
|
348
|
+
: 'dim'
|
|
349
|
+
rightTokens.push(paint(`auto merge ${build.autoMerge}`, autoColor, color))
|
|
336
350
|
}
|
|
337
351
|
if (build.pr !== undefined) rightTokens.push(link(build.pr.url, `PR ${build.pr.state}`, color))
|
|
338
352
|
// A repository hold changes no build lifecycle state. Keep QUEUED literal
|
|
@@ -578,7 +592,8 @@ function detailStep(step: PipelineStep, opts: RenderOpts): string {
|
|
|
578
592
|
notes.push(formatDuration(elapsed))
|
|
579
593
|
}
|
|
580
594
|
const note = notes.length > 0 ? ` (${notes.join(', ')})` : ''
|
|
581
|
-
|
|
595
|
+
const reason = step.reason !== undefined ? ` — ${step.reason}` : ''
|
|
596
|
+
return paint(`${GLYPH[step.state]} ${step.label}${note}${reason}`, STEP_COLOR[step.state], color)
|
|
582
597
|
}
|
|
583
598
|
|
|
584
599
|
function sessionLines(session: DashboardSession, selected: boolean, opts: RenderOpts): string[] {
|
|
@@ -639,6 +654,19 @@ function detailBody(
|
|
|
639
654
|
if (ceiling !== undefined) body.push(` ${ceiling}`)
|
|
640
655
|
if (build.pr !== undefined)
|
|
641
656
|
body.push(` PR ${build.pr.state} ${link(build.pr.url, build.pr.url, color)}`)
|
|
657
|
+
if (build.pipelineSource !== undefined || build.effectiveConfigRev !== undefined) {
|
|
658
|
+
const commit =
|
|
659
|
+
build.pipelineSource?.commit !== undefined
|
|
660
|
+
? build.pipelineSource.commit.slice(0, 7)
|
|
661
|
+
: 'unknown'
|
|
662
|
+
const source =
|
|
663
|
+
build.pipelineSource !== undefined
|
|
664
|
+
? `autobuild.toml@${commit} (${build.pipelineSource.ref})`
|
|
665
|
+
: 'autobuild.toml@unknown (pre-pin)'
|
|
666
|
+
const rev =
|
|
667
|
+
build.effectiveConfigRev !== undefined ? ` config rev ${build.effectiveConfigRev}` : ''
|
|
668
|
+
body.push(` pipeline ${source}${rev}`)
|
|
669
|
+
}
|
|
642
670
|
if (build.abortProgress !== undefined) {
|
|
643
671
|
body.push('', paint('Abort progress', 'bold', color))
|
|
644
672
|
body.push(...wrappedText(build.abortProgress, width, ' '))
|
|
@@ -1449,6 +1477,7 @@ function renderDashboardContent(model: DashboardModel, opts: RenderOpts): string
|
|
|
1449
1477
|
widths,
|
|
1450
1478
|
sameSelection(selection, model.selection),
|
|
1451
1479
|
selecting,
|
|
1480
|
+
model.defaultAutoMerge,
|
|
1452
1481
|
),
|
|
1453
1482
|
},
|
|
1454
1483
|
]
|