@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared behavioral contract for finite sessionless commands that own a
|
|
3
|
+
* BuildStore. Register this against distinct command shells so selection,
|
|
4
|
+
* credentials, production composition, and cleanup cannot drift per command.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test'
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
12
|
+
import { MemoryBuildStore } from '../store/memory'
|
|
13
|
+
import { startStoreServer } from '../store/remote/server'
|
|
14
|
+
import { mintToken } from '../store/remote/token'
|
|
15
|
+
import type { BuildStore } from '../store/types'
|
|
16
|
+
import type { StoreOpener } from './store-opening'
|
|
17
|
+
|
|
18
|
+
const MAIN_REPO = '/main/repo'
|
|
19
|
+
const LINKED_REPO = '/linked/worktree'
|
|
20
|
+
|
|
21
|
+
const mainRepoExec: Exec = async (cmd) =>
|
|
22
|
+
cmd[1] === 'remote'
|
|
23
|
+
? // No origin remote: identity falls back to the resolved checkout path.
|
|
24
|
+
{ stdout: '', stderr: "error: No such remote 'origin'\n", exitCode: 2 }
|
|
25
|
+
: {
|
|
26
|
+
stdout: `${MAIN_REPO}/.git\n${MAIN_REPO}/.git\n${MAIN_REPO}\n`,
|
|
27
|
+
stderr: '',
|
|
28
|
+
exitCode: 0,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const noGit: Exec = async () => ({
|
|
32
|
+
stdout: '',
|
|
33
|
+
stderr: 'not a git repository',
|
|
34
|
+
exitCode: 128,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export interface StoreOpeningCommandOpts {
|
|
38
|
+
targetRepo: string
|
|
39
|
+
env: Record<string, string | undefined>
|
|
40
|
+
exec: Exec
|
|
41
|
+
stdout: (line: string) => void
|
|
42
|
+
storeRef?: string
|
|
43
|
+
openStore?: StoreOpener
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StoreOpeningCommandAdapter {
|
|
47
|
+
run: (opts: StoreOpeningCommandOpts) => Promise<void>
|
|
48
|
+
/** Extract a command-specific marker proving it used canonical main-repo
|
|
49
|
+
* identity (for example a filtered build slug or a projected repo path). */
|
|
50
|
+
canonicalMarker: (stdout: string[]) => string | undefined
|
|
51
|
+
expectedCanonicalMarker: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function trackedStore(
|
|
55
|
+
store: MemoryBuildStore,
|
|
56
|
+
opts: { failOperations?: boolean; onClose: () => void },
|
|
57
|
+
): BuildStore {
|
|
58
|
+
return new Proxy(store, {
|
|
59
|
+
get(target, prop) {
|
|
60
|
+
if (prop === 'close') {
|
|
61
|
+
return async () => opts.onClose()
|
|
62
|
+
}
|
|
63
|
+
const value = Reflect.get(target, prop, target) as unknown
|
|
64
|
+
if (typeof value !== 'function') return value
|
|
65
|
+
if (opts.failOperations === true) {
|
|
66
|
+
return async () => {
|
|
67
|
+
throw new Error('store operation failed')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return value.bind(target) as unknown
|
|
71
|
+
},
|
|
72
|
+
}) as unknown as BuildStore
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function capture(
|
|
76
|
+
adapter: StoreOpeningCommandAdapter,
|
|
77
|
+
opts: Omit<StoreOpeningCommandOpts, 'stdout'>,
|
|
78
|
+
): Promise<string[]> {
|
|
79
|
+
const stdout: string[] = []
|
|
80
|
+
await adapter.run({ ...opts, stdout: (line) => stdout.push(line) })
|
|
81
|
+
return stdout
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function describeStoreOpeningContract(
|
|
85
|
+
name: string,
|
|
86
|
+
adapter: StoreOpeningCommandAdapter,
|
|
87
|
+
): void {
|
|
88
|
+
describe(`sessionless store-opening contract: ${name}`, () => {
|
|
89
|
+
test('shares reference precedence, normalization, URL preservation, and opaque token forwarding', async () => {
|
|
90
|
+
const store = new MemoryBuildStore()
|
|
91
|
+
const opened: Array<{ ref: string; token?: string }> = []
|
|
92
|
+
const openStore: StoreOpener = (ref, token) => {
|
|
93
|
+
opened.push({ ref, ...(token !== undefined ? { token } : {}) })
|
|
94
|
+
return store
|
|
95
|
+
}
|
|
96
|
+
const base = {
|
|
97
|
+
targetRepo: LINKED_REPO,
|
|
98
|
+
exec: mainRepoExec,
|
|
99
|
+
openStore,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await capture(adapter, {
|
|
103
|
+
...base,
|
|
104
|
+
env: { AB_STORE: 'environment' },
|
|
105
|
+
storeRef: 'flag/../selected',
|
|
106
|
+
})
|
|
107
|
+
await capture(adapter, {
|
|
108
|
+
...base,
|
|
109
|
+
env: { AB_STORE: 'environment/../selected-env' },
|
|
110
|
+
})
|
|
111
|
+
await capture(adapter, {
|
|
112
|
+
...base,
|
|
113
|
+
env: { AB_STORE: '/absolute/state' },
|
|
114
|
+
storeRef: ' ',
|
|
115
|
+
})
|
|
116
|
+
await capture(adapter, {
|
|
117
|
+
...base,
|
|
118
|
+
env: { AB_STORE: ' \t' },
|
|
119
|
+
storeRef: '',
|
|
120
|
+
})
|
|
121
|
+
await capture(adapter, {
|
|
122
|
+
...base,
|
|
123
|
+
env: {},
|
|
124
|
+
storeRef: 'http://store.example.test/api',
|
|
125
|
+
})
|
|
126
|
+
await capture(adapter, {
|
|
127
|
+
...base,
|
|
128
|
+
env: {},
|
|
129
|
+
storeRef: 'https://store.example.test/api?x=1',
|
|
130
|
+
})
|
|
131
|
+
await capture(adapter, {
|
|
132
|
+
...base,
|
|
133
|
+
env: { AB_TOKEN: '' },
|
|
134
|
+
storeRef: 'https://store.example.test/empty-token',
|
|
135
|
+
})
|
|
136
|
+
await capture(adapter, {
|
|
137
|
+
...base,
|
|
138
|
+
env: { AB_TOKEN: ' opaque token ' },
|
|
139
|
+
storeRef: 'https://store.example.test/opaque-token',
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(opened).toEqual([
|
|
143
|
+
{ ref: `${MAIN_REPO}/selected` },
|
|
144
|
+
{ ref: `${MAIN_REPO}/selected-env` },
|
|
145
|
+
{ ref: '/absolute/state' },
|
|
146
|
+
{ ref: `${MAIN_REPO}/.autobuild` },
|
|
147
|
+
{ ref: 'http://store.example.test/api' },
|
|
148
|
+
{ ref: 'https://store.example.test/api?x=1' },
|
|
149
|
+
{ ref: 'https://store.example.test/empty-token' },
|
|
150
|
+
{
|
|
151
|
+
ref: 'https://store.example.test/opaque-token',
|
|
152
|
+
token: ' opaque token ',
|
|
153
|
+
},
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('uses canonical main-repository identity', async () => {
|
|
158
|
+
const store = new MemoryBuildStore()
|
|
159
|
+
await store.createBuild({ slug: 'main-build', repo: MAIN_REPO })
|
|
160
|
+
await store.createBuild({ slug: 'other-build', repo: '/other/repo' })
|
|
161
|
+
await store.ensureRepo(MAIN_REPO)
|
|
162
|
+
const stdout = await capture(adapter, {
|
|
163
|
+
targetRepo: LINKED_REPO,
|
|
164
|
+
env: {},
|
|
165
|
+
exec: mainRepoExec,
|
|
166
|
+
openStore: () => store,
|
|
167
|
+
})
|
|
168
|
+
expect(adapter.canonicalMarker(stdout)).toBe(adapter.expectedCanonicalMarker)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('closes exactly once on success, store failure, and output failure', async () => {
|
|
172
|
+
const run = async (mode: 'success' | 'store' | 'output'): Promise<number> => {
|
|
173
|
+
const store = new MemoryBuildStore()
|
|
174
|
+
let closes = 0
|
|
175
|
+
const tracked = trackedStore(store, {
|
|
176
|
+
failOperations: mode === 'store',
|
|
177
|
+
onClose: () => {
|
|
178
|
+
closes += 1
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
const command = adapter.run({
|
|
182
|
+
targetRepo: LINKED_REPO,
|
|
183
|
+
env: {},
|
|
184
|
+
exec: mainRepoExec,
|
|
185
|
+
openStore: () => tracked,
|
|
186
|
+
stdout:
|
|
187
|
+
mode === 'output'
|
|
188
|
+
? () => {
|
|
189
|
+
throw new Error('output failed')
|
|
190
|
+
}
|
|
191
|
+
: () => {},
|
|
192
|
+
})
|
|
193
|
+
if (mode === 'success') await command
|
|
194
|
+
else {
|
|
195
|
+
await expect(command).rejects.toThrow(
|
|
196
|
+
mode === 'store' ? 'store operation failed' : 'output failed',
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
return closes
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
expect(await run('success')).toBe(1)
|
|
203
|
+
expect(await run('store')).toBe(1)
|
|
204
|
+
expect(await run('output')).toBe(1)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('propagates opener failure without phantom cleanup', async () => {
|
|
208
|
+
let closes = 0
|
|
209
|
+
const unopened = new MemoryBuildStore()
|
|
210
|
+
unopened.close = async () => {
|
|
211
|
+
closes += 1
|
|
212
|
+
}
|
|
213
|
+
await expect(
|
|
214
|
+
adapter.run({
|
|
215
|
+
targetRepo: LINKED_REPO,
|
|
216
|
+
env: {},
|
|
217
|
+
exec: mainRepoExec,
|
|
218
|
+
stdout: () => {},
|
|
219
|
+
openStore: () => {
|
|
220
|
+
throw new Error('open failed')
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
).rejects.toThrow('open failed')
|
|
224
|
+
expect(closes).toBe(0)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('production composition opens a local SQLite root', async () => {
|
|
228
|
+
const repo = await mkdtemp(join(tmpdir(), 'ab-store-opening-local-'))
|
|
229
|
+
try {
|
|
230
|
+
await adapter.run({
|
|
231
|
+
targetRepo: repo,
|
|
232
|
+
env: {},
|
|
233
|
+
exec: noGit,
|
|
234
|
+
stdout: () => {},
|
|
235
|
+
storeRef: 'state',
|
|
236
|
+
})
|
|
237
|
+
expect(existsSync(join(repo, 'state', 'autobuild.sqlite'))).toBe(true)
|
|
238
|
+
} finally {
|
|
239
|
+
await rm(repo, { recursive: true, force: true })
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('production composition forwards a token to remote HTTP and rejects missing or invalid credentials', async () => {
|
|
244
|
+
const secret = 'store-opening-secret'
|
|
245
|
+
const now = new Date('2026-07-15T12:00:00.000Z')
|
|
246
|
+
const backing = new MemoryBuildStore({ clock: () => now })
|
|
247
|
+
const server = startStoreServer({
|
|
248
|
+
store: backing,
|
|
249
|
+
secret,
|
|
250
|
+
clock: () => now,
|
|
251
|
+
})
|
|
252
|
+
const token = mintToken(secret, {
|
|
253
|
+
build: '*',
|
|
254
|
+
session: '*',
|
|
255
|
+
exp: now.getTime() + 60_000,
|
|
256
|
+
})
|
|
257
|
+
try {
|
|
258
|
+
await adapter.run({
|
|
259
|
+
targetRepo: LINKED_REPO,
|
|
260
|
+
env: { AB_TOKEN: token },
|
|
261
|
+
exec: mainRepoExec,
|
|
262
|
+
stdout: () => {},
|
|
263
|
+
storeRef: server.url,
|
|
264
|
+
})
|
|
265
|
+
await expect(
|
|
266
|
+
adapter.run({
|
|
267
|
+
targetRepo: LINKED_REPO,
|
|
268
|
+
env: {},
|
|
269
|
+
exec: mainRepoExec,
|
|
270
|
+
stdout: () => {},
|
|
271
|
+
storeRef: server.url,
|
|
272
|
+
}),
|
|
273
|
+
).rejects.toThrow(/missing bearer token/)
|
|
274
|
+
await expect(
|
|
275
|
+
adapter.run({
|
|
276
|
+
targetRepo: LINKED_REPO,
|
|
277
|
+
env: { AB_TOKEN: 'invalid-token' },
|
|
278
|
+
exec: mainRepoExec,
|
|
279
|
+
stdout: () => {},
|
|
280
|
+
storeRef: server.url,
|
|
281
|
+
}),
|
|
282
|
+
).rejects.toThrow(/invalid or expired token/)
|
|
283
|
+
} finally {
|
|
284
|
+
await server.stop()
|
|
285
|
+
await backing.close()
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production BuildStore composition for CLI commands.
|
|
3
|
+
*
|
|
4
|
+
* `repo-state.ts` owns repository identity and reference precedence, while
|
|
5
|
+
* `store-ref.ts` remains the adapter-free local-vs-remote parser. This module
|
|
6
|
+
* joins those seams to the production remote adapter and owns finite-command
|
|
7
|
+
* store lifecycle.
|
|
8
|
+
*/
|
|
9
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
10
|
+
import { RemoteBuildStore } from '../store/remote/client'
|
|
11
|
+
import { scopeLocalStoreToPhaseSession } from '../store/phase-session'
|
|
12
|
+
import type { BuildStore } from '../store/types'
|
|
13
|
+
import {
|
|
14
|
+
resolveAmbientReadSession,
|
|
15
|
+
type AmbientReadSession,
|
|
16
|
+
type CliEnv,
|
|
17
|
+
type HarvestCliEnv,
|
|
18
|
+
} from './env'
|
|
19
|
+
import { isRemoteStoreRef, resolveRepoState, type RepoStatePaths } from './repo-state'
|
|
20
|
+
import { resolveStore } from './store-ref'
|
|
21
|
+
|
|
22
|
+
/** Shared injection seam used by every command that opens a BuildStore. */
|
|
23
|
+
export type StoreOpener = (ref: string, token?: string) => BuildStore
|
|
24
|
+
|
|
25
|
+
function optionalToken(value: string | undefined): string | undefined {
|
|
26
|
+
// Bearer credentials are opaque. Only absence and the exact empty value mean
|
|
27
|
+
// "no token"; nonempty values are forwarded byte-for-byte.
|
|
28
|
+
return value !== undefined && value !== '' ? value : undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Production local/remote composition. The low-level parser stays injectable
|
|
32
|
+
* and does not import the HTTP adapter. */
|
|
33
|
+
export const openProductionStore: StoreOpener = (ref, token) => {
|
|
34
|
+
const forwardedToken = optionalToken(token)
|
|
35
|
+
return resolveStore(ref, {
|
|
36
|
+
remoteFactory: (url, remoteToken) => new RemoteBuildStore({ url, token: remoteToken }),
|
|
37
|
+
...(forwardedToken !== undefined ? { token: forwardedToken } : {}),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Apply validated ambient authority to a selected Store handle. Remote
|
|
42
|
+
* handles retain token-backed server authorization; filesystem handles receive
|
|
43
|
+
* the equivalent in-process scope. Keeping this primitive shared prevents
|
|
44
|
+
* phase commands and ambient-aware query commands from drifting. */
|
|
45
|
+
function scopeSelectedStoreToPhaseSession(
|
|
46
|
+
store: BuildStore,
|
|
47
|
+
ref: string,
|
|
48
|
+
env: AmbientReadSession,
|
|
49
|
+
): BuildStore {
|
|
50
|
+
if (isRemoteStoreRef(ref)) return store
|
|
51
|
+
return scopeLocalStoreToPhaseSession(
|
|
52
|
+
store,
|
|
53
|
+
'build' in env
|
|
54
|
+
? { kind: 'build', id: env.build, session: env.session }
|
|
55
|
+
: { kind: 'repo', id: env.repo, session: env.session },
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Open the Store for a validated phase/Harvest ambient identity. */
|
|
60
|
+
export function openProductionSessionStore(env: CliEnv | HarvestCliEnv): BuildStore {
|
|
61
|
+
return scopeSelectedStoreToPhaseSession(openProductionStore(env.store, env.token), env.store, env)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface OpenedStoreContext extends RepoStatePaths {
|
|
65
|
+
store: BuildStore
|
|
66
|
+
token?: string
|
|
67
|
+
/** The validated ambient phase/Harvest identity this invocation carries, if
|
|
68
|
+
* any — the same value that scoped `store` on the local path. Exposed so a
|
|
69
|
+
* command core can extend own-resource authority to remote handles too,
|
|
70
|
+
* where no session wrapper exists. */
|
|
71
|
+
ambient?: AmbientReadSession
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface OpenStoreForRepoStateOpts {
|
|
75
|
+
env: Record<string, string | undefined>
|
|
76
|
+
openStore?: StoreOpener
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Open from an already canonicalized state selection. Dispatch uses this form
|
|
80
|
+
* so repository topology and relative overrides are decided exactly once. */
|
|
81
|
+
export function openStoreForRepoState(
|
|
82
|
+
state: RepoStatePaths,
|
|
83
|
+
opts: OpenStoreForRepoStateOpts,
|
|
84
|
+
): OpenedStoreContext {
|
|
85
|
+
const token = optionalToken(opts.env.AB_TOKEN)
|
|
86
|
+
const store = (opts.openStore ?? openProductionStore)(state.storeRef, token)
|
|
87
|
+
return {
|
|
88
|
+
...state,
|
|
89
|
+
store,
|
|
90
|
+
...(token !== undefined ? { token } : {}),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SessionlessStoreOpts extends OpenStoreForRepoStateOpts {
|
|
95
|
+
targetRepo: string
|
|
96
|
+
exec: Exec
|
|
97
|
+
/** Explicit --store; precedence is applied by repo-state.ts. */
|
|
98
|
+
storeRef?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Resolve the main checkout and sessionless reference precedence, then open
|
|
102
|
+
* the selected production or injected store. The caller owns the handle. */
|
|
103
|
+
export async function openSessionlessStore(
|
|
104
|
+
opts: SessionlessStoreOpts,
|
|
105
|
+
): Promise<OpenedStoreContext> {
|
|
106
|
+
const state = await resolveRepoState({
|
|
107
|
+
targetRepo: opts.targetRepo,
|
|
108
|
+
exec: opts.exec,
|
|
109
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
110
|
+
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
111
|
+
})
|
|
112
|
+
return openStoreForRepoState(state, opts)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Finite command ownership boundary. Opening failures occur before the
|
|
116
|
+
* try/finally, so only a successfully returned handle is ever closed.
|
|
117
|
+
*
|
|
118
|
+
* Remote store handles do not session-scope (bearer authorization is the
|
|
119
|
+
* server's concern), so a command core that needs own-build authority must
|
|
120
|
+
* consume `ambient` from the context directly rather than rely on the handle
|
|
121
|
+
* enforcing a scope. */
|
|
122
|
+
export async function withSessionlessStore<T>(
|
|
123
|
+
opts: SessionlessStoreOpts,
|
|
124
|
+
use: (context: OpenedStoreContext) => Promise<T> | T,
|
|
125
|
+
): Promise<T> {
|
|
126
|
+
const context = await openSessionlessStore(opts)
|
|
127
|
+
try {
|
|
128
|
+
return await use(context)
|
|
129
|
+
} finally {
|
|
130
|
+
await context.store.close()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Dedicated opener for the finite query commands whose operator-sessionless
|
|
135
|
+
* syntax is also useful inside a phase. Invalid ambient identity fails before
|
|
136
|
+
* repository canonicalization or Store opening; absent identity preserves the
|
|
137
|
+
* ordinary unscoped operator path. */
|
|
138
|
+
export async function openAmbientReadStore(
|
|
139
|
+
opts: SessionlessStoreOpts,
|
|
140
|
+
): Promise<OpenedStoreContext> {
|
|
141
|
+
const ambient = resolveAmbientReadSession(opts.env)
|
|
142
|
+
const state = await resolveRepoState({
|
|
143
|
+
targetRepo: opts.targetRepo,
|
|
144
|
+
exec: opts.exec,
|
|
145
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
146
|
+
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
147
|
+
})
|
|
148
|
+
const context = openStoreForRepoState(state, opts)
|
|
149
|
+
if (ambient === undefined) return context
|
|
150
|
+
return {
|
|
151
|
+
...context,
|
|
152
|
+
ambient,
|
|
153
|
+
store: scopeSelectedStoreToPhaseSession(context.store, state.storeRef, ambient),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function withAmbientReadStore<T>(
|
|
158
|
+
opts: SessionlessStoreOpts,
|
|
159
|
+
use: (context: OpenedStoreContext) => Promise<T> | T,
|
|
160
|
+
): Promise<T> {
|
|
161
|
+
const context = await openAmbientReadStore(opts)
|
|
162
|
+
try {
|
|
163
|
+
return await use(context)
|
|
164
|
+
} finally {
|
|
165
|
+
await context.store.close()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AB_STORE` reference resolution (SPEC §7.2, §8.1): a local filesystem path
|
|
3
|
+
* opens the SQLite+blob-dir store; an http(s) URL is the remote HTTP adapter.
|
|
4
|
+
*
|
|
5
|
+
* The production remote adapter is composed in `src/cli/store-opening.ts` via
|
|
6
|
+
* `remoteFactory`. This low-level parser stays decoupled from that adapter, so
|
|
7
|
+
* it remains independently testable and an unwired entry point fails clearly.
|
|
8
|
+
*/
|
|
9
|
+
import { openLocalStore } from '../store/local/store'
|
|
10
|
+
import type { BuildStore } from '../store/types'
|
|
11
|
+
import { isRemoteStoreRef } from './repo-state'
|
|
12
|
+
|
|
13
|
+
export interface ResolveStoreOpts {
|
|
14
|
+
/** Constructs the remote HTTP store (§7.2.2); production wiring is external. */
|
|
15
|
+
remoteFactory?: (url: string, token?: string) => BuildStore
|
|
16
|
+
/** Scoped token (D8, `AB_TOKEN`) — passed through to the remote factory. */
|
|
17
|
+
token?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveStore(ref: string, opts: ResolveStoreOpts = {}): BuildStore {
|
|
21
|
+
if (isRemoteStoreRef(ref)) {
|
|
22
|
+
if (opts.remoteFactory === undefined) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`remote store support not wired: cannot open "${ref}" from this entry ` +
|
|
25
|
+
'point — supply the remote HTTP adapter (SPEC §7.2.2) as ' +
|
|
26
|
+
"resolveStore's remoteFactory; production composition lives in " +
|
|
27
|
+
'src/cli/store-opening.ts',
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
return opts.remoteFactory(ref, opts.token)
|
|
31
|
+
}
|
|
32
|
+
return openLocalStore(ref)
|
|
33
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal modes owned by the interactive dispatch dashboard. A declaration is
|
|
3
|
+
* the single source for both its ordinary enter/leave writes and the
|
|
4
|
+
* process-boundary emergency restore.
|
|
5
|
+
*/
|
|
6
|
+
export interface TerminalMode {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly enter: string
|
|
9
|
+
readonly restore: string
|
|
10
|
+
/** Lower values restore first when the process boundary unwinds all modes. */
|
|
11
|
+
readonly restoreOrder: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ALTERNATE_SCREEN_MODE = {
|
|
15
|
+
name: 'alternate-screen',
|
|
16
|
+
enter: '\x1b[?1049h',
|
|
17
|
+
restore: '\x1b[?1049l',
|
|
18
|
+
restoreOrder: 30,
|
|
19
|
+
} as const satisfies TerminalMode
|
|
20
|
+
|
|
21
|
+
export const HIDDEN_CURSOR_MODE = {
|
|
22
|
+
name: 'hidden-cursor',
|
|
23
|
+
enter: '\x1b[?25l',
|
|
24
|
+
restore: '\x1b[?25h',
|
|
25
|
+
restoreOrder: 40,
|
|
26
|
+
} as const satisfies TerminalMode
|
|
27
|
+
|
|
28
|
+
export const BRACKETED_PASTE_MODE = {
|
|
29
|
+
name: 'bracketed-paste',
|
|
30
|
+
enter: '\x1b[?2004h',
|
|
31
|
+
restore: '\x1b[?2004l',
|
|
32
|
+
restoreOrder: 20,
|
|
33
|
+
} as const satisfies TerminalMode
|
|
34
|
+
|
|
35
|
+
export const KITTY_KEYBOARD_FLAGS = 0b1_1101
|
|
36
|
+
|
|
37
|
+
export const KITTY_KEYBOARD_MODE = {
|
|
38
|
+
name: 'kitty-keyboard-flags',
|
|
39
|
+
enter: `\x1b[>${KITTY_KEYBOARD_FLAGS}u`,
|
|
40
|
+
restore: '\x1b[<1u',
|
|
41
|
+
restoreOrder: 10,
|
|
42
|
+
} as const satisfies TerminalMode
|
|
43
|
+
|
|
44
|
+
export interface TerminalModeController {
|
|
45
|
+
/** Arm before writing the enter sequence. Re-entering an active mode is inert. */
|
|
46
|
+
enter(mode: TerminalMode): void
|
|
47
|
+
/** Write the ordinary restore sequence, then disarm. */
|
|
48
|
+
leave(mode: TerminalMode): void
|
|
49
|
+
/** Synchronously and best-effort restore every active mode. */
|
|
50
|
+
restoreAll(): void
|
|
51
|
+
isActive(mode: TerminalMode): boolean
|
|
52
|
+
readonly activeModes: readonly TerminalMode[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Keep the currently active terminal modes at the output seam.
|
|
57
|
+
*
|
|
58
|
+
* Enter arms before its write and leave disarms only after a successful write.
|
|
59
|
+
* A failed normal write therefore remains visible to the synchronous emergency
|
|
60
|
+
* path. Emergency restoration attempts every mode even if one write fails.
|
|
61
|
+
*/
|
|
62
|
+
export function createTerminalModeController(
|
|
63
|
+
write: (chunk: string) => void,
|
|
64
|
+
emergencyWrite: (chunk: string) => void = write,
|
|
65
|
+
): TerminalModeController {
|
|
66
|
+
const active = new Map<TerminalMode, number>()
|
|
67
|
+
let activation = 0
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
enter(mode): void {
|
|
71
|
+
if (active.has(mode)) return
|
|
72
|
+
active.set(mode, activation++)
|
|
73
|
+
write(mode.enter)
|
|
74
|
+
},
|
|
75
|
+
leave(mode): void {
|
|
76
|
+
if (!active.has(mode)) return
|
|
77
|
+
write(mode.restore)
|
|
78
|
+
active.delete(mode)
|
|
79
|
+
},
|
|
80
|
+
restoreAll(): void {
|
|
81
|
+
const modes = [...active.entries()].sort(
|
|
82
|
+
([left, leftActivation], [right, rightActivation]) =>
|
|
83
|
+
left.restoreOrder - right.restoreOrder || leftActivation - rightActivation,
|
|
84
|
+
)
|
|
85
|
+
for (const [mode] of modes) {
|
|
86
|
+
try {
|
|
87
|
+
emergencyWrite(mode.restore)
|
|
88
|
+
active.delete(mode)
|
|
89
|
+
} catch {
|
|
90
|
+
// Best effort means one broken write cannot prevent the remaining
|
|
91
|
+
// independent modes from being restored. The failed mode stays armed
|
|
92
|
+
// so a later exit boundary may retry it.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
isActive: (mode): boolean => active.has(mode),
|
|
97
|
+
get activeModes(): readonly TerminalMode[] {
|
|
98
|
+
return [...active.keys()]
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const TERMINATING_SIGNALS = [
|
|
104
|
+
'SIGHUP',
|
|
105
|
+
'SIGQUIT',
|
|
106
|
+
'SIGTERM',
|
|
107
|
+
] as const satisfies readonly NodeJS.Signals[]
|
|
108
|
+
|
|
109
|
+
export interface TerminalRestoreProcess {
|
|
110
|
+
readonly pid: number
|
|
111
|
+
on(event: string, listener: (...args: unknown[]) => void): unknown
|
|
112
|
+
removeListener(event: string, listener: (...args: unknown[]) => void): unknown
|
|
113
|
+
kill(pid: number, signal: NodeJS.Signals): unknown
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface TerminalRestoreHook {
|
|
117
|
+
/** Restore active modes, remove this hook's listeners, then replay a captured signal. */
|
|
118
|
+
close(): void
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Install the synchronous process-boundary fallback used only by `ab dispatch`.
|
|
123
|
+
* SIGINT is deliberately absent: raw Ctrl-C and the binary's AbortController
|
|
124
|
+
* retain the dashboard's normal final-frame teardown. Other terminating signals
|
|
125
|
+
* restore immediately but are replayed only after the async dispatch drain.
|
|
126
|
+
*/
|
|
127
|
+
export function installTerminalRestoreHook(
|
|
128
|
+
modes: TerminalModeController,
|
|
129
|
+
boundary: TerminalRestoreProcess = process as unknown as TerminalRestoreProcess,
|
|
130
|
+
requestStop: (signal: (typeof TERMINATING_SIGNALS)[number]) => void = () => {},
|
|
131
|
+
): TerminalRestoreHook {
|
|
132
|
+
let closed = false
|
|
133
|
+
let pendingSignal: (typeof TERMINATING_SIGNALS)[number] | undefined
|
|
134
|
+
const registrations: Array<[string, (...args: unknown[]) => void]> = []
|
|
135
|
+
|
|
136
|
+
const restore = (): void => {
|
|
137
|
+
modes.restoreAll()
|
|
138
|
+
}
|
|
139
|
+
const remove = (): void => {
|
|
140
|
+
if (closed) return
|
|
141
|
+
closed = true
|
|
142
|
+
for (const [event, listener] of registrations) boundary.removeListener(event, listener)
|
|
143
|
+
}
|
|
144
|
+
const add = (event: string, listener: (...args: unknown[]) => void): void => {
|
|
145
|
+
registrations.push([event, listener])
|
|
146
|
+
boundary.on(event, listener)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// `uncaughtExceptionMonitor` observes an uncaught fault without changing
|
|
150
|
+
// Node's default crash behavior. `exit` is the final synchronous fallback.
|
|
151
|
+
add('uncaughtExceptionMonitor', restore)
|
|
152
|
+
add('exit', restore)
|
|
153
|
+
for (const signal of TERMINATING_SIGNALS) {
|
|
154
|
+
add(signal, () => {
|
|
155
|
+
restore()
|
|
156
|
+
if (pendingSignal !== undefined) return
|
|
157
|
+
pendingSignal = signal
|
|
158
|
+
requestStop(signal)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
close(): void {
|
|
164
|
+
if (closed) return
|
|
165
|
+
// Closing after runCli returns also catches an unexpectedly active ledger
|
|
166
|
+
// without depending on another promise turn or stream drain.
|
|
167
|
+
restore()
|
|
168
|
+
remove()
|
|
169
|
+
// Replay only after the supervised child has drained and the handlers are
|
|
170
|
+
// gone, preserving the operating system's terminating-signal semantics.
|
|
171
|
+
if (pendingSignal !== undefined) boundary.kill(boundary.pid, pendingSignal)
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|