@defrex/autobuild 0.9.0 → 0.10.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 +10 -0
- package/docs/architecture.md +109 -0
- package/docs/configuration.md +24 -3
- package/docs/remote-store-protocol.md +35 -0
- package/docs/website.md +4 -0
- package/package.json +6 -4
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +85 -147
- package/packages/core/src/cli/dashboard/poll.ts +7 -2
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +80 -37
- package/packages/core/src/cli/help.ts +15 -1
- package/packages/core/src/cli/init-validation.ts +18 -0
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +1 -1
- package/packages/core/src/cli/ticket.ts +146 -1
- package/packages/core/src/cli/wait.ts +10 -2
- package/packages/core/src/cli/watch.ts +10 -2
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/pipeline-source.ts +17 -5
- package/packages/core/src/config/schema.ts +21 -0
- package/packages/core/src/integration/harness.ts +113 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +43 -9
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +216 -74
- package/packages/core/src/operator/query.ts +68 -28
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +32 -1
- package/packages/core/src/processes/build-execution-state.ts +31 -15
- package/packages/core/src/processes/build-runner.ts +184 -90
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +270 -176
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +44 -3
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +26 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +10 -1
- package/packages/core/src/store/build-scope.ts +26 -0
- package/packages/core/src/store/contract.ts +337 -0
- package/packages/core/src/store/digest.ts +71 -18
- package/packages/core/src/store/local/schema.ts +17 -0
- package/packages/core/src/store/local/store.ts +111 -4
- package/packages/core/src/store/memory.ts +68 -1
- package/packages/core/src/store/phase-session.ts +23 -0
- package/packages/core/src/store/remote/client.ts +50 -1
- package/packages/core/src/store/remote/index.ts +2 -0
- package/packages/core/src/store/remote/protocol.ts +15 -0
- package/packages/core/src/store/repo-state-events.ts +72 -7
- package/packages/core/src/store/session-handle.ts +30 -0
- package/packages/core/src/store/session-reducer.ts +52 -11
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/ticket-assets.ts +41 -8
- package/packages/core/src/store/types.ts +38 -0
- package/skills/guide/SKILL.md +26 -7
- package/skills/guide/references/remote-store-protocol.md +35 -0
- package/skills/tickets/SKILL.md +57 -5
|
@@ -140,6 +140,7 @@ import {
|
|
|
140
140
|
type LaunchRunnerResult,
|
|
141
141
|
type TickReport,
|
|
142
142
|
} from '../processes/dispatcher'
|
|
143
|
+
import { RepoViewStore } from '../processes/repo-view'
|
|
143
144
|
import {
|
|
144
145
|
BuildControlError,
|
|
145
146
|
buildControlUser,
|
|
@@ -597,6 +598,8 @@ interface ActiveBuildExecution {
|
|
|
597
598
|
* running, so the completion chain must append no `execution.ended`, release
|
|
598
599
|
* no lease, settle no publication, and record no wait failure. */
|
|
599
600
|
detaching?: boolean
|
|
601
|
+
/** A wait failure being recorded; teardown lets it finish rather than drop it. */
|
|
602
|
+
recording?: Promise<void>
|
|
600
603
|
}
|
|
601
604
|
|
|
602
605
|
/** The dispatch loop owns deterministic decisions and supervises one
|
|
@@ -604,6 +607,10 @@ interface ActiveBuildExecution {
|
|
|
604
607
|
* Store; child completion is liveness evidence used for reaping/single-flight. */
|
|
605
608
|
class DispatchLoop {
|
|
606
609
|
private readonly dispatcher: Dispatcher
|
|
610
|
+
/** The incremental repository view (AUT-647) the dispatcher and this
|
|
611
|
+
* frontend's tick-path journal reads and writes share. Resident for a
|
|
612
|
+
* watch loop; `--once` builds it cold for its single tick. */
|
|
613
|
+
private readonly repoView: RepoViewStore
|
|
607
614
|
private readonly host = hostname()
|
|
608
615
|
private readonly maxHarvestRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS
|
|
609
616
|
/** In-flight build and harvest runs (fire-and-forget) — awaited before a
|
|
@@ -749,6 +756,8 @@ class DispatchLoop {
|
|
|
749
756
|
private readonly liveConfig: LiveConfig,
|
|
750
757
|
private readonly wiring: DispatchWiring,
|
|
751
758
|
private readonly opts: DispatchOpts,
|
|
759
|
+
/** The view `abDispatch` already used for its lifecycle appends. */
|
|
760
|
+
view?: RepoViewStore,
|
|
752
761
|
) {
|
|
753
762
|
const config = liveConfig.current().config
|
|
754
763
|
// Minted exactly once per invocation: `wiring.ids` is `randomIds()` and a
|
|
@@ -785,8 +794,16 @@ class DispatchLoop {
|
|
|
785
794
|
return result.text
|
|
786
795
|
}
|
|
787
796
|
|
|
797
|
+
// `abDispatch` hands over a view it already used for the lifecycle appends,
|
|
798
|
+
// so those rows are folded, not re-read, and one flush covers them all.
|
|
799
|
+
this.repoView =
|
|
800
|
+
view ??
|
|
801
|
+
new RepoViewStore(wiring.store, {
|
|
802
|
+
repo: this.repoIdentity,
|
|
803
|
+
resident: opts.once !== true,
|
|
804
|
+
})
|
|
788
805
|
this.dispatcher = new Dispatcher({
|
|
789
|
-
store:
|
|
806
|
+
store: this.repoView,
|
|
790
807
|
tickets: wiring.tickets,
|
|
791
808
|
workspaces: wiring.workspaces,
|
|
792
809
|
...(wiring.retiredWorkspaces === undefined
|
|
@@ -839,7 +856,7 @@ class DispatchLoop {
|
|
|
839
856
|
// the record's `branch` (when present) is the same fact. Using the event
|
|
840
857
|
// keeps this read to the events the caller already needs and avoids an
|
|
841
858
|
// extra build-record lookup on the hot launch path.
|
|
842
|
-
const events = await this.
|
|
859
|
+
const events = await this.repoView.getEvents(slug)
|
|
843
860
|
const workspace = selectOpenWorkspace(events)
|
|
844
861
|
const source = await resolvePipelineSource({
|
|
845
862
|
slug,
|
|
@@ -1019,7 +1036,7 @@ class DispatchLoop {
|
|
|
1019
1036
|
}
|
|
1020
1037
|
|
|
1021
1038
|
private async appendStatus(event: RepositoryEventWrite): Promise<void> {
|
|
1022
|
-
await this.
|
|
1039
|
+
await this.repoView.appendRepo(this.repoIdentity, event)
|
|
1023
1040
|
}
|
|
1024
1041
|
|
|
1025
1042
|
private async refreshConfig(): Promise<void> {
|
|
@@ -1084,9 +1101,10 @@ class DispatchLoop {
|
|
|
1084
1101
|
}
|
|
1085
1102
|
|
|
1086
1103
|
private async readDispatchSettings(): Promise<ReturnType<typeof reduceDispatchSettings>> {
|
|
1087
|
-
//
|
|
1088
|
-
|
|
1089
|
-
|
|
1104
|
+
// The view's bounded subset (AUT-489): the settings reducer consumes
|
|
1105
|
+
// durable types only. `refresh()` ran first in the tick, so this is the
|
|
1106
|
+
// delta-current journal, never a store read.
|
|
1107
|
+
return reduceDispatchSettings(this.repoView.recordedJournal())
|
|
1090
1108
|
}
|
|
1091
1109
|
|
|
1092
1110
|
private dispatcherTick(resumeCurrent: boolean): Promise<Awaited<ReturnType<Dispatcher['tick']>>> {
|
|
@@ -1127,6 +1145,7 @@ class DispatchLoop {
|
|
|
1127
1145
|
|
|
1128
1146
|
// Sample inside the serialized tick, not at process startup. Every
|
|
1129
1147
|
// dispatcher therefore gates claims from the latest repository facts.
|
|
1148
|
+
await this.repoView.refresh()
|
|
1130
1149
|
const settings = await this.readDispatchSettings()
|
|
1131
1150
|
const readyObservation =
|
|
1132
1151
|
settings.intake || (!this.dashboard && this.opts.kernelRunId === undefined)
|
|
@@ -1134,6 +1153,7 @@ class DispatchLoop {
|
|
|
1134
1153
|
: await this.dispatcher.observeReady()
|
|
1135
1154
|
const report = await this.dispatcher.tick({
|
|
1136
1155
|
resumeCurrent,
|
|
1156
|
+
viewRefreshed: true,
|
|
1137
1157
|
acceptNewWork: settings.intake,
|
|
1138
1158
|
defaultAutoMerge: settings.defaultAutoMerge,
|
|
1139
1159
|
autoMergeUser: buildControlUser(this.opts.env),
|
|
@@ -2286,10 +2306,11 @@ class DispatchLoop {
|
|
|
2286
2306
|
// the pure Store-read pressure check; the guest rescans authoritatively
|
|
2287
2307
|
// once provisioned, so a threshold crossing between gate and guest scan
|
|
2288
2308
|
// is never suppressed by this check.
|
|
2289
|
-
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2292
|
-
|
|
2309
|
+
// The view's bounded subset (AUT-489): the harvest control decision
|
|
2310
|
+
// reduces durable types only. Take the journal delta first — guests and
|
|
2311
|
+
// other dispatchers write harvest facts between ticks.
|
|
2312
|
+
await this.repoView.refreshJournal()
|
|
2313
|
+
const events = this.repoView.recordedJournal()
|
|
2293
2314
|
const state = reduceHarvest(events)
|
|
2294
2315
|
const control = decideHarvestControl(state, this.maxHarvestRecoveryAttempts)
|
|
2295
2316
|
const resumePending =
|
|
@@ -2331,7 +2352,7 @@ class DispatchLoop {
|
|
|
2331
2352
|
)
|
|
2332
2353
|
}
|
|
2333
2354
|
this.hostedHarvest = { execution, handle, detached }
|
|
2334
|
-
const started = await
|
|
2355
|
+
const started = await this.repoView.appendRepo(repo, {
|
|
2335
2356
|
actor: DISPATCHER,
|
|
2336
2357
|
type: 'harvest.execution.started',
|
|
2337
2358
|
payload: {
|
|
@@ -2406,8 +2427,10 @@ class DispatchLoop {
|
|
|
2406
2427
|
startedSeq: number,
|
|
2407
2428
|
): Promise<HarvestRunnerResult> {
|
|
2408
2429
|
const { store } = this.wiring
|
|
2409
|
-
// Bounded
|
|
2410
|
-
|
|
2430
|
+
// Bounded subset (AUT-489): classifyHarvestOutcome reduces harvest facts
|
|
2431
|
+
// only. A guest wrote the facts through its own handle, so read the delta.
|
|
2432
|
+
await this.repoView.refreshJournal()
|
|
2433
|
+
const events = this.repoView.recordedJournal()
|
|
2411
2434
|
const record = await store.getRepo(repo)
|
|
2412
2435
|
return classifyHarvestOutcome(events, {
|
|
2413
2436
|
executionStartedSeq: startedSeq,
|
|
@@ -2433,7 +2456,7 @@ class DispatchLoop {
|
|
|
2433
2456
|
branch: '',
|
|
2434
2457
|
}
|
|
2435
2458
|
const reap = await this.reapHostedWorkspace(workspaceHandle)
|
|
2436
|
-
await this.
|
|
2459
|
+
await this.repoView.appendRepo(repo, {
|
|
2437
2460
|
actor: DISPATCHER,
|
|
2438
2461
|
type: 'harvest.execution.released',
|
|
2439
2462
|
payload: {
|
|
@@ -2590,9 +2613,11 @@ class DispatchLoop {
|
|
|
2590
2613
|
}
|
|
2591
2614
|
|
|
2592
2615
|
private async settlePendingPublication(slug: string): Promise<void> {
|
|
2616
|
+
// The guest appended through its own handle; settle against fresh state.
|
|
2617
|
+
await this.repoView.refreshBuild(slug)
|
|
2593
2618
|
await settleWorkspacePublication(
|
|
2594
2619
|
{
|
|
2595
|
-
store: this.
|
|
2620
|
+
store: this.repoView,
|
|
2596
2621
|
storeRef: this.wiring.storeRef,
|
|
2597
2622
|
publication: this.wiring.workspaces.publication,
|
|
2598
2623
|
forge: this.wiring.forge,
|
|
@@ -2615,11 +2640,13 @@ class DispatchLoop {
|
|
|
2615
2640
|
cleanupPending: boolean
|
|
2616
2641
|
identity?: BuildExecutionHandle['identity']
|
|
2617
2642
|
}): Promise<void> {
|
|
2643
|
+
// The failed executor may have appended through its own handle.
|
|
2644
|
+
await this.repoView.refreshBuild(input.slug)
|
|
2618
2645
|
await appendInfrastructureFailure(
|
|
2619
|
-
{ store: this.
|
|
2646
|
+
{ store: this.repoView, ids: this.wiring.ids },
|
|
2620
2647
|
{
|
|
2621
2648
|
slug: input.slug,
|
|
2622
|
-
events: await this.
|
|
2649
|
+
events: await this.repoView.getEvents(input.slug),
|
|
2623
2650
|
maxAttempts: this.currentConfig().config.policy.maxInfrastructureAttempts,
|
|
2624
2651
|
provider: input.identity?.provider ?? this.wiring.workspaces.name,
|
|
2625
2652
|
workspaceRef: input.workspaceRef,
|
|
@@ -2650,11 +2677,7 @@ class DispatchLoop {
|
|
|
2650
2677
|
|
|
2651
2678
|
try {
|
|
2652
2679
|
await this.publishBuildConfig(slug)
|
|
2653
|
-
leaseClaimed = await this.
|
|
2654
|
-
slug,
|
|
2655
|
-
instance,
|
|
2656
|
-
BUILD_EXECUTION_LEASE_TTL_MS,
|
|
2657
|
-
)
|
|
2680
|
+
leaseClaimed = await this.repoView.claimLease(slug, instance, BUILD_EXECUTION_LEASE_TTL_MS)
|
|
2658
2681
|
if (!leaseClaimed) {
|
|
2659
2682
|
this.activeBuildRuns.delete(slug)
|
|
2660
2683
|
await this.appendStatus({
|
|
@@ -2665,7 +2688,7 @@ class DispatchLoop {
|
|
|
2665
2688
|
this.failureNotice(`build ${slug} already held by another runner — skipped`)
|
|
2666
2689
|
return 'already-active'
|
|
2667
2690
|
}
|
|
2668
|
-
const launchEvents = await this.
|
|
2691
|
+
const launchEvents = await this.repoView.getEvents(slug)
|
|
2669
2692
|
let workspaceRef: string | undefined
|
|
2670
2693
|
let workspaceProvider: string | undefined
|
|
2671
2694
|
for (const event of launchEvents) {
|
|
@@ -2709,7 +2732,7 @@ class DispatchLoop {
|
|
|
2709
2732
|
provider: owning.name,
|
|
2710
2733
|
workspaceRef,
|
|
2711
2734
|
}
|
|
2712
|
-
await this.
|
|
2735
|
+
await this.repoView.append(slug, {
|
|
2713
2736
|
actor: DISPATCHER,
|
|
2714
2737
|
type: 'execution.started',
|
|
2715
2738
|
payload: { ...identity, instance },
|
|
@@ -2724,7 +2747,7 @@ class DispatchLoop {
|
|
|
2724
2747
|
// supervisor settles the execution from the Store.
|
|
2725
2748
|
if (active.detaching) return
|
|
2726
2749
|
try {
|
|
2727
|
-
await this.
|
|
2750
|
+
await this.repoView.append(slug, {
|
|
2728
2751
|
actor: DISPATCHER,
|
|
2729
2752
|
type: 'execution.ended',
|
|
2730
2753
|
payload: {
|
|
@@ -2746,7 +2769,8 @@ class DispatchLoop {
|
|
|
2746
2769
|
return
|
|
2747
2770
|
}
|
|
2748
2771
|
|
|
2749
|
-
|
|
2772
|
+
await this.repoView.refreshBuild(slug)
|
|
2773
|
+
const state = reduceBuild(await this.repoView.getEvents(slug))
|
|
2750
2774
|
if (diagnostic === null && exit.exitCode === 0) {
|
|
2751
2775
|
await this.appendStatus({
|
|
2752
2776
|
actor: DISPATCHER,
|
|
@@ -2772,7 +2796,7 @@ class DispatchLoop {
|
|
|
2772
2796
|
})
|
|
2773
2797
|
this.warn(`build ${slug} runner failed: ${detail}`)
|
|
2774
2798
|
} finally {
|
|
2775
|
-
await this.
|
|
2799
|
+
await this.repoView.releaseLease(slug, instance)
|
|
2776
2800
|
await this.settlePendingPublication(slug)
|
|
2777
2801
|
}
|
|
2778
2802
|
},
|
|
@@ -2783,7 +2807,7 @@ class DispatchLoop {
|
|
|
2783
2807
|
// A rejected executor completion cannot prove the remote VM was
|
|
2784
2808
|
// stopped. Keep the lease until expiry: recovery fences and reaps
|
|
2785
2809
|
// the exact recorded identity before authorizing a replacement.
|
|
2786
|
-
|
|
2810
|
+
active.recording = this.recordInfrastructureFailure({
|
|
2787
2811
|
slug,
|
|
2788
2812
|
instance,
|
|
2789
2813
|
workspaceRef,
|
|
@@ -2792,6 +2816,7 @@ class DispatchLoop {
|
|
|
2792
2816
|
cleanupPending: true,
|
|
2793
2817
|
identity,
|
|
2794
2818
|
})
|
|
2819
|
+
await active.recording
|
|
2795
2820
|
},
|
|
2796
2821
|
)
|
|
2797
2822
|
.finally(() => {
|
|
@@ -2810,7 +2835,9 @@ class DispatchLoop {
|
|
|
2810
2835
|
this.activeBuildRuns.delete(slug)
|
|
2811
2836
|
}
|
|
2812
2837
|
if (leaseClaimed) {
|
|
2813
|
-
|
|
2838
|
+
// The runner appended through its own handle: take the delta.
|
|
2839
|
+
await this.repoView.refreshBuild(slug)
|
|
2840
|
+
const events = await this.repoView.getEvents(slug)
|
|
2814
2841
|
let workspaceRef = ''
|
|
2815
2842
|
for (const event of events) {
|
|
2816
2843
|
if (event.type === 'workspace.provisioned') workspaceRef = event.payload.ref
|
|
@@ -2829,7 +2856,7 @@ class DispatchLoop {
|
|
|
2829
2856
|
// Local start failures have no possible remote holder. Remote failures
|
|
2830
2857
|
// retain the lease until its TTL fences an ambiguous start.
|
|
2831
2858
|
if (this.wiring.workspaces.recovery === undefined) {
|
|
2832
|
-
await this.
|
|
2859
|
+
await this.repoView.releaseLease(slug, instance)
|
|
2833
2860
|
}
|
|
2834
2861
|
}
|
|
2835
2862
|
await this.appendStatus({
|
|
@@ -2925,6 +2952,9 @@ class DispatchLoop {
|
|
|
2925
2952
|
} catch {
|
|
2926
2953
|
// Detach is best-effort: an unresolvable wait still exits.
|
|
2927
2954
|
}
|
|
2955
|
+
// A wait failure already being recorded is durable work, not
|
|
2956
|
+
// supervision: let it land before the process exits.
|
|
2957
|
+
await entry.recording?.catch(() => {})
|
|
2928
2958
|
this.activeBuildRuns.delete(slug)
|
|
2929
2959
|
if (entry.settled !== undefined) {
|
|
2930
2960
|
this.inFlight.delete(entry.settled)
|
|
@@ -3409,7 +3439,7 @@ class DispatchLoop {
|
|
|
3409
3439
|
const holder = record?.lease?.holder
|
|
3410
3440
|
if (holder === undefined || this.yieldedTo === holder) return
|
|
3411
3441
|
this.yieldedTo = holder
|
|
3412
|
-
await this.
|
|
3442
|
+
await this.repoView.appendRepo(this.repoIdentity, {
|
|
3413
3443
|
actor: DISPATCHER,
|
|
3414
3444
|
type: 'dispatcher.tick-yielded',
|
|
3415
3445
|
payload: {
|
|
@@ -3890,23 +3920,32 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3890
3920
|
// another dispatcher cannot clobber the latest operator choice with a value
|
|
3891
3921
|
// it inferred at startup. Fresh-repository fallbacks live in the reducer.
|
|
3892
3922
|
await wiring.store.ensureRepo(state.repo)
|
|
3923
|
+
// Every own lifecycle append goes through one view, restored from snapshots
|
|
3924
|
+
// before the first of them, so a quiet invocation reads none of its own
|
|
3925
|
+
// rows back and the final flush covers `run-stopped` too.
|
|
3926
|
+
const view = new RepoViewStore(wiring.store, {
|
|
3927
|
+
// The loop's store-keyed identity (an explicit `repo` pins it).
|
|
3928
|
+
repo: resolvedOpts.repo ?? resolvedOpts.targetRepo,
|
|
3929
|
+
resident: resolvedOpts.once !== true,
|
|
3930
|
+
})
|
|
3931
|
+
await view.startJournal()
|
|
3893
3932
|
const actor = humanActor(buildControlUser(resolvedOpts.env))
|
|
3894
3933
|
if (resolvedOpts.intake !== undefined) {
|
|
3895
|
-
await
|
|
3934
|
+
await view.appendRepo(state.repo, {
|
|
3896
3935
|
actor,
|
|
3897
3936
|
type: 'dispatcher.intake-set',
|
|
3898
3937
|
payload: { enabled: resolvedOpts.intake, run: runId },
|
|
3899
3938
|
})
|
|
3900
3939
|
}
|
|
3901
3940
|
if (resolvedOpts.defaultAutoMerge !== undefined) {
|
|
3902
|
-
await
|
|
3941
|
+
await view.appendRepo(state.repo, {
|
|
3903
3942
|
actor,
|
|
3904
3943
|
type: 'dispatcher.auto-merge-default-set',
|
|
3905
3944
|
payload: { enabled: resolvedOpts.defaultAutoMerge, run: runId },
|
|
3906
3945
|
})
|
|
3907
3946
|
}
|
|
3908
3947
|
|
|
3909
|
-
await
|
|
3948
|
+
await view.appendRepoWithArtifacts(
|
|
3910
3949
|
state.repo,
|
|
3911
3950
|
[
|
|
3912
3951
|
{
|
|
@@ -3931,10 +3970,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3931
3970
|
},
|
|
3932
3971
|
)
|
|
3933
3972
|
|
|
3934
|
-
const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts)
|
|
3973
|
+
const loop = new DispatchLoop(liveConfig, wiring, resolvedOpts, view)
|
|
3935
3974
|
try {
|
|
3936
3975
|
await loop.run()
|
|
3937
|
-
await
|
|
3976
|
+
await view.appendRepo(state.repo, {
|
|
3938
3977
|
actor: DISPATCHER,
|
|
3939
3978
|
type: 'dispatcher.run-stopped',
|
|
3940
3979
|
payload: {
|
|
@@ -3946,7 +3985,7 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3946
3985
|
})
|
|
3947
3986
|
} catch (error) {
|
|
3948
3987
|
try {
|
|
3949
|
-
await
|
|
3988
|
+
await view.appendRepo(state.repo, {
|
|
3950
3989
|
actor: DISPATCHER,
|
|
3951
3990
|
type: 'dispatcher.run-stopped',
|
|
3952
3991
|
payload: {
|
|
@@ -3961,6 +4000,10 @@ export async function abDispatch(opts: DispatchOpts): Promise<void> {
|
|
|
3961
4000
|
}
|
|
3962
4001
|
throw error
|
|
3963
4002
|
} finally {
|
|
4003
|
+
// Every append this invocation made, `run-stopped` included, is in the
|
|
4004
|
+
// view; leave the reduced state behind so the next cold process reads
|
|
4005
|
+
// only what is newer than it.
|
|
4006
|
+
await view.persistSnapshots({ force: true })
|
|
3964
4007
|
if (resolvedOpts.wire === undefined) await wiring.store.close()
|
|
3965
4008
|
}
|
|
3966
4009
|
}
|
|
@@ -165,6 +165,8 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
165
165
|
' ab ticket list [--state <state>] [--labels a,b] [--json]',
|
|
166
166
|
' ab ticket show <id> [--store <ref>] [--json]',
|
|
167
167
|
' ab ticket move <id> <state> [--json]',
|
|
168
|
+
' ab ticket states [--json]',
|
|
169
|
+
' ab ticket state create <name> [--about <text>] [--json]',
|
|
168
170
|
' ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json]',
|
|
169
171
|
' ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json]',
|
|
170
172
|
' ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json]',
|
|
@@ -194,9 +196,21 @@ export const HELP_CATALOG: readonly HelpEntry[] = [
|
|
|
194
196
|
'its source naming the asset and how to download it. Assets over the store',
|
|
195
197
|
'limit (25 MiB by default, lower on a hosted deployment) are refused.',
|
|
196
198
|
'',
|
|
199
|
+
'states lists every state of the source: lifecycle states first (triage, ready,',
|
|
200
|
+
'doing, done), then custom ones alphabetically, each with the roles it plays',
|
|
201
|
+
'under the current config (create, ready, claimed, triage, proposal, done, or',
|
|
202
|
+
'none), its ticket count, and its purpose when set. --json emits one bare',
|
|
203
|
+
'array of {name, roles, tickets, about?}. state create makes a state, e.g.',
|
|
204
|
+
'icebox, and is idempotent: an existing state is reported, and --about (one',
|
|
205
|
+
'line) records or replaces its purpose. A name is one to forty lowercase',
|
|
206
|
+
'letters, digits, and hyphens, starting with a letter, and may not match an',
|
|
207
|
+
'existing state case-insensitively. There is no rename or delete. Only the file',
|
|
208
|
+
'source supports both; others exit nonzero naming the source. A state with no',
|
|
209
|
+
'purpose and no tickets is an empty directory that git does not track.',
|
|
210
|
+
'',
|
|
197
211
|
'Ticket ids are source-local (for example AUT-8 or file-1). For block and',
|
|
198
212
|
'unblock, the first id is always the ticket being changed. State names and',
|
|
199
|
-
'unknown-id errors come from the configured source.',
|
|
213
|
+
'unknown-id errors come from the configured source; ab ticket states lists them.',
|
|
200
214
|
]),
|
|
201
215
|
},
|
|
202
216
|
{
|
|
@@ -22,6 +22,7 @@ import type { VercelSandboxFacade } from '../ports/workspace/vercel-sandbox'
|
|
|
22
22
|
import { diagnosePlugins, loadPlugins } from '../plugins/load'
|
|
23
23
|
import { materializePluginRuntimes } from '../plugins/runtimes'
|
|
24
24
|
import { createTicketSource } from '../ports/tickets/create'
|
|
25
|
+
import { LinearTicketSource } from '../ports/tickets/linear'
|
|
25
26
|
import { inspectLocalStoreSnapshot } from '../store/local/store'
|
|
26
27
|
import type { StoreOpener } from './store-opening'
|
|
27
28
|
import { openProductionStore } from './store-opening'
|
|
@@ -402,6 +403,7 @@ export async function validateInitReadiness(opts: {
|
|
|
402
403
|
providerConfig !== undefined ? (builtinCaps?.guestEnvNames?.(providerConfig) ?? []) : [],
|
|
403
404
|
)
|
|
404
405
|
let caps: WorkspaceProviderCapabilities | undefined
|
|
406
|
+
let assigneeLine: string | undefined
|
|
405
407
|
try {
|
|
406
408
|
hostPreflight(config, opts.env)
|
|
407
409
|
// Ticket acquisition is a host responsibility for both workspace providers.
|
|
@@ -460,7 +462,22 @@ export async function validateInitReadiness(opts: {
|
|
|
460
462
|
await ticketSource.listReady({
|
|
461
463
|
...(config.tickets.readyLabels !== undefined ? { labels: config.tickets.readyLabels } : {}),
|
|
462
464
|
state: config.tickets.readyState,
|
|
465
|
+
...(config.tickets.readyAssignee !== undefined
|
|
466
|
+
? { assignee: config.tickets.readyAssignee }
|
|
467
|
+
: {}),
|
|
463
468
|
})
|
|
469
|
+
const assignee = config.tickets.readyAssignee
|
|
470
|
+
if (assignee !== undefined && ticketSource instanceof LinearTicketSource) {
|
|
471
|
+
const user = await ticketSource.resolveAssignee(assignee)
|
|
472
|
+
if (user === null) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
assignee === 'me'
|
|
475
|
+
? '[tickets].readyAssignee = "me" but LINEAR_API_KEY did not resolve to a Linear user — check the key'
|
|
476
|
+
: `[tickets].readyAssignee "${assignee}" matches no user in the Linear workspace — check the address or use "me"`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
assigneeLine = `Ticket assignee gate: ${assignee} → ${user.name} <${user.email}>`
|
|
480
|
+
}
|
|
464
481
|
}
|
|
465
482
|
} catch (error) {
|
|
466
483
|
throw new Error(redact(error))
|
|
@@ -586,6 +603,7 @@ export async function validateInitReadiness(opts: {
|
|
|
586
603
|
stdout(`Readiness: ${report.context} (${report.provider})`)
|
|
587
604
|
stdout(`Store: ${state.storeRef}`)
|
|
588
605
|
stdout(`Forge: ${config.forge}`)
|
|
606
|
+
if (assigneeLine !== undefined) stdout(assigneeLine)
|
|
589
607
|
// Same raw `[workspace.config]` fallback guestEnvNames gets (f_55ba7591): a
|
|
590
608
|
// provider that declares describeEnvironment without a configSchema still
|
|
591
609
|
// receives a defined argument — the raw table — never undefined.
|
|
@@ -21,13 +21,25 @@ export type InstallScope = 'local' | 'global'
|
|
|
21
21
|
* forge dependency, or a version range satisfied from the npm registry. */
|
|
22
22
|
export type InstallChannel = 'github' | 'npm'
|
|
23
23
|
|
|
24
|
+
/** The owning manifest sections a self-update target may be declared in. */
|
|
25
|
+
export type DependencySection = 'dependencies' | 'devDependencies'
|
|
26
|
+
|
|
27
|
+
/** Searched in this order; when a manifest declares the package in both, the
|
|
28
|
+
* first wins (the runtime declaration, which a plain `bun add` would rewrite). */
|
|
29
|
+
const DEPENDENCY_SECTIONS: readonly DependencySection[] = ['dependencies', 'devDependencies']
|
|
30
|
+
const SEARCHED_SECTIONS = DEPENDENCY_SECTIONS.join(' or ')
|
|
31
|
+
|
|
24
32
|
interface ManagedInstallationBase extends DistributionIdentity {
|
|
25
33
|
sourceCheckout: false
|
|
26
34
|
ownerRoot: string
|
|
27
35
|
ownerManifest: string
|
|
28
36
|
ownerLock: string
|
|
29
|
-
/** The owning manifest's direct dependency value
|
|
37
|
+
/** The owning manifest's direct dependency value in `section`, byte for byte. */
|
|
30
38
|
dependency: string
|
|
39
|
+
/** The manifest section the dependency was chosen from. */
|
|
40
|
+
section: DependencySection
|
|
41
|
+
/** The manifest also declares the package in the other section. */
|
|
42
|
+
dual: boolean
|
|
31
43
|
scope: InstallScope
|
|
32
44
|
}
|
|
33
45
|
|
|
@@ -180,9 +192,27 @@ async function parseJsonc(path: string): Promise<Record<string, unknown>> {
|
|
|
180
192
|
}
|
|
181
193
|
}
|
|
182
194
|
|
|
183
|
-
function lockDependency(
|
|
195
|
+
function lockDependency(
|
|
196
|
+
lock: Record<string, unknown>,
|
|
197
|
+
packageName: string,
|
|
198
|
+
section: DependencySection,
|
|
199
|
+
): unknown {
|
|
184
200
|
const workspace = object(object(lock.workspaces)?.[''])
|
|
185
|
-
return object(workspace?.
|
|
201
|
+
return object(workspace?.[section])?.[packageName]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Pick the section that declares the package: a pure function of the
|
|
205
|
+
* manifest, in fixed `DEPENDENCY_SECTIONS` order. */
|
|
206
|
+
function declaredSection(
|
|
207
|
+
manifest: Record<string, unknown>,
|
|
208
|
+
packageName: string,
|
|
209
|
+
): { section: DependencySection; value: string; dual: boolean } | undefined {
|
|
210
|
+
const declared = DEPENDENCY_SECTIONS.flatMap((section) => {
|
|
211
|
+
const value = object(manifest[section])?.[packageName]
|
|
212
|
+
return typeof value === 'string' ? [{ section, value }] : []
|
|
213
|
+
})
|
|
214
|
+
const first = declared[0]
|
|
215
|
+
return first === undefined ? undefined : { ...first, dual: declared.length > 1 }
|
|
186
216
|
}
|
|
187
217
|
|
|
188
218
|
function lockPackageRecord(
|
|
@@ -235,21 +265,29 @@ export async function inspectInstallation(options: {
|
|
|
235
265
|
try {
|
|
236
266
|
const manifest = await parseJson(ownerManifest)
|
|
237
267
|
const lock = await parseJsonc(ownerLock)
|
|
238
|
-
const
|
|
268
|
+
const declared = declaredSection(manifest, identity.packageName)
|
|
269
|
+
if (declared === undefined) {
|
|
270
|
+
return {
|
|
271
|
+
kind: 'unknown',
|
|
272
|
+
identity,
|
|
273
|
+
reason: `${ownerManifest} does not declare ${identity.packageName} under ${SEARCHED_SECTIONS} as a direct github: dependency or an npm registry version range satisfied by ${identity.version}`,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const { section, value: dependency, dual } = declared
|
|
239
277
|
const repository = githubDependency(dependency)
|
|
240
278
|
const registry = repository === undefined && registryDependency(dependency, identity.version)
|
|
241
|
-
if (
|
|
279
|
+
if (repository === undefined && !registry) {
|
|
242
280
|
return {
|
|
243
281
|
kind: 'unknown',
|
|
244
282
|
identity,
|
|
245
|
-
reason: `${ownerManifest}
|
|
283
|
+
reason: `${ownerManifest} declares ${identity.packageName} under ${section} as "${dependency}", which is neither a direct github: dependency nor an npm registry version range satisfied by ${identity.version}`,
|
|
246
284
|
}
|
|
247
285
|
}
|
|
248
|
-
if (lockDependency(lock, identity.packageName) !== dependency) {
|
|
286
|
+
if (lockDependency(lock, identity.packageName, section) !== dependency) {
|
|
249
287
|
return {
|
|
250
288
|
kind: 'unknown',
|
|
251
289
|
identity,
|
|
252
|
-
reason: `${ownerLock} does not agree with the direct
|
|
290
|
+
reason: `${ownerLock} does not agree with the direct ${section} entry in ${ownerManifest}`,
|
|
253
291
|
}
|
|
254
292
|
}
|
|
255
293
|
const scope: InstallScope = (await samePath(join(options.globalBin, 'ab'), identity.binaryPath))
|
|
@@ -280,6 +318,8 @@ export async function inspectInstallation(options: {
|
|
|
280
318
|
ownerManifest,
|
|
281
319
|
ownerLock,
|
|
282
320
|
dependency,
|
|
321
|
+
section,
|
|
322
|
+
dual,
|
|
283
323
|
scope,
|
|
284
324
|
},
|
|
285
325
|
}
|
|
@@ -320,6 +360,8 @@ export async function inspectInstallation(options: {
|
|
|
320
360
|
owner: repository.owner,
|
|
321
361
|
repository: repository.repository,
|
|
322
362
|
dependency,
|
|
363
|
+
section,
|
|
364
|
+
dual,
|
|
323
365
|
scope,
|
|
324
366
|
},
|
|
325
367
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
1
2
|
import semver from 'semver'
|
|
2
3
|
import type { ExecResult } from '../ports/workspace/git-worktree'
|
|
3
4
|
import { registryBaseUrl, registryVersionUrl } from '../registry'
|
|
@@ -230,6 +231,36 @@ export function updateDependency(install: ManagedInstallation, version: string):
|
|
|
230
231
|
: `${install.packageName}@${version}`
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
/** The specifier written into package.json for a resolved version: what
|
|
235
|
+
* `bun add <pkg>@<version>` writes (the exact version) for the registry, the tag for a forge. */
|
|
236
|
+
function manifestSpecifier(install: ManagedInstallation, version: string): string {
|
|
237
|
+
return install.channel === 'github'
|
|
238
|
+
? `github:${install.owner}/${install.repository}#v${version}`
|
|
239
|
+
: version
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Set the package's specifier in every section that declares it, preserving
|
|
243
|
+
* key order, indentation, and the trailing newline. Returns the new bytes. */
|
|
244
|
+
export function rewriteDeclaredSpecifiers(
|
|
245
|
+
manifestText: string,
|
|
246
|
+
packageName: string,
|
|
247
|
+
specifier: string,
|
|
248
|
+
): string {
|
|
249
|
+
const manifest = JSON.parse(manifestText) as Record<string, unknown>
|
|
250
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
251
|
+
const entries = manifest[section]
|
|
252
|
+
if (
|
|
253
|
+
typeof entries === 'object' &&
|
|
254
|
+
entries !== null &&
|
|
255
|
+
typeof (entries as Record<string, unknown>)[packageName] === 'string'
|
|
256
|
+
) {
|
|
257
|
+
;(entries as Record<string, unknown>)[packageName] = specifier
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const indent = /^([ \t]+)"/m.exec(manifestText)?.[1] ?? 2
|
|
261
|
+
return JSON.stringify(manifest, null, indent) + (manifestText.endsWith('\n') ? '\n' : '')
|
|
262
|
+
}
|
|
263
|
+
|
|
233
264
|
/** Silently determine whether this installation has a newer published release.
|
|
234
265
|
* This read-only courtesy intentionally collapses every unsupported install,
|
|
235
266
|
* command failure, malformed response, and cancellation to no result. */
|
|
@@ -244,7 +275,9 @@ export async function availableRelease(
|
|
|
244
275
|
const identity = await readDistributionIdentity(distRoot)
|
|
245
276
|
if (identity.sourceCheckout || signalAborted(options.signal)) return undefined
|
|
246
277
|
|
|
247
|
-
const globalBin = await command(['bun', 'pm', 'bin', '-g'], {
|
|
278
|
+
const globalBin = await command(['bun', 'pm', 'bin', '-g'], {
|
|
279
|
+
signal: options.signal,
|
|
280
|
+
})
|
|
248
281
|
if (
|
|
249
282
|
globalBin.exitCode !== 0 ||
|
|
250
283
|
globalBin.stdout.trim() === '' ||
|
|
@@ -369,11 +402,53 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
|
|
|
369
402
|
options.stdout(
|
|
370
403
|
`Updating Autobuild to v${resolved} in ${install.ownerRoot}; Bun will rewrite ${install.ownerManifest} and ${install.ownerLock}.`,
|
|
371
404
|
)
|
|
372
|
-
|
|
405
|
+
if (install.dual) {
|
|
406
|
+
// Bun cannot reconcile two differing specifiers through `bun add`: it
|
|
407
|
+
// exits 0 yet keeps the old version. Bring both entries to the new
|
|
408
|
+
// specifier and let `bun install` apply it.
|
|
409
|
+
options.stdout(
|
|
410
|
+
`${install.packageName} is declared in both dependencies and devDependencies; updating both entries.`,
|
|
411
|
+
)
|
|
412
|
+
updateCommand = ['bun', 'install', '--cwd', install.ownerRoot]
|
|
413
|
+
} else {
|
|
414
|
+
updateCommand = [
|
|
415
|
+
'bun',
|
|
416
|
+
'add',
|
|
417
|
+
'--cwd',
|
|
418
|
+
install.ownerRoot,
|
|
419
|
+
...(install.section === 'devDependencies' ? ['-d'] : []),
|
|
420
|
+
dependency,
|
|
421
|
+
]
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let originalManifest: string | undefined
|
|
426
|
+
if (install.scope === 'local' && install.dual) {
|
|
427
|
+
try {
|
|
428
|
+
originalManifest = await readFile(install.ownerManifest, 'utf8')
|
|
429
|
+
await writeFile(
|
|
430
|
+
install.ownerManifest,
|
|
431
|
+
rewriteDeclaredSpecifiers(
|
|
432
|
+
originalManifest,
|
|
433
|
+
install.packageName,
|
|
434
|
+
manifestSpecifier(install, resolved),
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
} catch (error) {
|
|
438
|
+
const reason = `rewriting ${install.ownerManifest}: ${error instanceof Error ? error.message : String(error)}`
|
|
439
|
+
if (explicit) {
|
|
440
|
+
options.stderr(`self-update failed: ${reason}`)
|
|
441
|
+
return { kind: 'failed' }
|
|
442
|
+
}
|
|
443
|
+
return warn(options, reason)
|
|
444
|
+
}
|
|
373
445
|
}
|
|
374
446
|
|
|
375
447
|
const update = await invoke(updateCommand, {})
|
|
376
448
|
if (update.exitCode !== 0) {
|
|
449
|
+
if (originalManifest !== undefined) {
|
|
450
|
+
await writeFile(install.ownerManifest, originalManifest).catch(() => undefined)
|
|
451
|
+
}
|
|
377
452
|
const reason = commandFailure(`installing v${resolved}`, update)
|
|
378
453
|
if (explicit) {
|
|
379
454
|
options.stderr(`self-update failed: ${reason}`)
|
|
@@ -205,7 +205,7 @@ function leaseInfo(record: BuildRecord, now: Date): LeaseInfo {
|
|
|
205
205
|
* The summary over an ALREADY-reduced state — so `detail` reduces the log
|
|
206
206
|
* once and builds both projections from it, rather than reducing twice.
|
|
207
207
|
*/
|
|
208
|
-
function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
|
|
208
|
+
export function summarizeFrom(record: BuildRecord, state: BuildState, now: Date): BuildSummary {
|
|
209
209
|
// `phase`, `round`, and `attempt` describe ONE phase occurrence, so they come
|
|
210
210
|
// from ONE source: the in-flight phase, else the last completed one — the
|
|
211
211
|
// same `currentPhase ?? lastCompletedPhase` the reducer uses to derive
|