@timurproko/a1 0.1.8-dev.332 → 0.1.8-dev.335
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 +22 -11
- package/dist/foundation/release/bootstrap.d.ts +2 -0
- package/dist/foundation/release/bootstrap.js +54 -18
- package/dist/foundation/release/index.d.ts +1 -0
- package/dist/foundation/release/index.js +1 -0
- package/dist/foundation/release/update-launch.d.ts +6 -0
- package/dist/foundation/release/update-launch.js +17 -0
- package/dist/foundation/release/update.d.ts +2 -0
- package/dist/foundation/release/update.js +20 -21
- package/dist/foundation/supervision/main.js +5 -2
- package/dist/foundation/supervision/server.js +40 -13
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/docs/architecture/toolchain.md +1 -1
- package/docs/ci-release-runbook.md +45 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,18 +160,29 @@ a1 update --develop 0.1.8-dev.107 # install that exact full preview versio
|
|
|
160
160
|
### Stable
|
|
161
161
|
|
|
162
162
|
```sh
|
|
163
|
-
npm run release -- patch # 0.1.1 -> 0.1.
|
|
164
|
-
npm run release -- minor # 0.1.
|
|
165
|
-
npm run release -- major # 0.1.
|
|
166
|
-
npm run release -- 0.4.0 # an exact version
|
|
163
|
+
npm run release -- patch # 0.1.8-dev -> 0.1.8; already-stable 0.1.8 -> 0.1.9
|
|
164
|
+
npm run release -- minor # 0.1.8-dev -> 0.2.0
|
|
165
|
+
npm run release -- major # 0.1.8-dev -> 1.0.0
|
|
166
|
+
npm run release -- 0.4.0 # an exact stable version
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
Run from the repository root on a clean `develop` matching `origin/develop`.
|
|
170
|
+
A target is required; bare `npm run release` displays usage and releases nothing.
|
|
171
|
+
`patch` promotes the current prerelease rather than skipping its stable version.
|
|
172
|
+
|
|
173
|
+
The command prepares a version-only PR in an isolated detached worktree, prints
|
|
174
|
+
its URL, and waits for you to validate and **merge it manually**. Neither this PR
|
|
175
|
+
nor the next-development PR is auto-merged, and green CI alone does not advance
|
|
176
|
+
the release. After the stable PR merges, the command dispatches publication for
|
|
177
|
+
that exact authoritative commit and waits for success. CI validates the packed
|
|
178
|
+
release on Windows, Linux, and macOS, publishes to npm `latest` with provenance,
|
|
179
|
+
then writes the `v<version>` tag and GitHub Release and fast-forwards `master`.
|
|
180
|
+
|
|
181
|
+
Only after confirmed publication of `0.1.8` does the command prepare the separate
|
|
182
|
+
`0.1.9-dev` PR. It reports development reopened only after you manually merge that
|
|
183
|
+
PR too. Work added to your checkout during either wait is preserved, not reset.
|
|
184
|
+
If publication fails or is uncertain, no reopening PR is prepared. If publication
|
|
185
|
+
succeeded but reopening failed, inspect the reported phase and PR; do not republish
|
|
186
|
+
the immutable stable version.
|
|
176
187
|
|
|
177
188
|
`docs/ci-release-runbook.md` has the full picture.
|
|
@@ -35,6 +35,8 @@ export interface SupervisorStartupAttempt extends SupervisorStartupAttemptIdenti
|
|
|
35
35
|
readonly signal: NodeJS.Signals | null;
|
|
36
36
|
}>;
|
|
37
37
|
}
|
|
38
|
+
/** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
|
|
39
|
+
export declare function ensureSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<void>;
|
|
38
40
|
export declare function startSupervisor(release: MaterializedRelease, environment: NodeJS.ProcessEnv): Promise<SupervisorStartupAttempt>;
|
|
39
41
|
/**
|
|
40
42
|
* Hand a release the key set its own build reads. A retained pre-cutover release is
|
|
@@ -10,6 +10,8 @@ import { assertLaunchProfileId, createSupervisorStartupAttempt, readSupervisorSt
|
|
|
10
10
|
import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
11
11
|
import { cleanupProvenIdleOwner, processIsAlive } from "./process-cleanup.js";
|
|
12
12
|
import { sweepDeadEndpoints } from "./endpoints.js";
|
|
13
|
+
import { UpdateTransactionStore } from "./update-transaction.js";
|
|
14
|
+
import { selectUpdateLaunchRelease } from "./update-launch.js";
|
|
13
15
|
import { consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease } from "./release-store.js";
|
|
14
16
|
import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
15
17
|
import { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument } from "./restart-certification.js";
|
|
@@ -29,6 +31,21 @@ export async function runBootstrap(options) {
|
|
|
29
31
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
30
32
|
const stateStore = new CohortStateStore(paths.dataDir);
|
|
31
33
|
let state = await stateStore.read();
|
|
34
|
+
const launchDuringUpdate = async () => {
|
|
35
|
+
// Concurrency: read the journal before touching npm's mutable tree, including package.json.
|
|
36
|
+
const transaction = await new UpdateTransactionStore(paths.dataDir).read();
|
|
37
|
+
if (!transaction || transaction.status === "completed")
|
|
38
|
+
return null;
|
|
39
|
+
const prior = selectUpdateLaunchRelease(await stateStore.read(), transaction);
|
|
40
|
+
if (!prior)
|
|
41
|
+
return null;
|
|
42
|
+
const retained = await readCertifiedReleaseManifest(prior, resolve(paths.dataDir, "releases"));
|
|
43
|
+
await ensureSupervisor(retained, environment);
|
|
44
|
+
return await launchUi(retained, environment, sessionArgs);
|
|
45
|
+
};
|
|
46
|
+
const duringUpdate = await launchDuringUpdate();
|
|
47
|
+
if (duringUpdate !== null)
|
|
48
|
+
return duringUpdate;
|
|
32
49
|
// Invariant: records left by cohorts whose processes are gone say nothing about ownership, and there
|
|
33
50
|
// can now be several of them. Clearing them first keeps the decision below about what is
|
|
34
51
|
// actually running.
|
|
@@ -80,10 +97,8 @@ export async function runBootstrap(options) {
|
|
|
80
97
|
return verified;
|
|
81
98
|
});
|
|
82
99
|
await markStartupPhase(environment, "durable-validation-complete");
|
|
83
|
-
const retainedPaths = resolveCohortEndpoint(paths, retained.releaseId, environment);
|
|
84
100
|
await markStartupPhase(environment, "replacement-supervisor-start");
|
|
85
|
-
|
|
86
|
-
await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
|
|
101
|
+
await ensureSupervisor(retained, environment);
|
|
87
102
|
await markStartupPhase(environment, "replacement-supervisor-ready");
|
|
88
103
|
return await launchUi(retained, environment, sessionArgs);
|
|
89
104
|
}
|
|
@@ -102,6 +117,10 @@ export async function runBootstrap(options) {
|
|
|
102
117
|
throw error;
|
|
103
118
|
return fallback;
|
|
104
119
|
}
|
|
120
|
+
// Concurrency: an update may have begun while this launch was reading the installed payload.
|
|
121
|
+
const afterMaterialization = await launchDuringUpdate();
|
|
122
|
+
if (afterMaterialization !== null)
|
|
123
|
+
return afterMaterialization;
|
|
105
124
|
await stateStore.recordCandidate(candidate);
|
|
106
125
|
state = await stateStore.read();
|
|
107
126
|
if (!state.references.active) {
|
|
@@ -192,8 +211,7 @@ export async function runBootstrap(options) {
|
|
|
192
211
|
else {
|
|
193
212
|
selected = await readMaterializedRelease(decision.releaseRoot);
|
|
194
213
|
}
|
|
195
|
-
|
|
196
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, selected.releaseId, environment).endpointMetadataPath, selected, 8_000, startup);
|
|
214
|
+
await ensureSupervisor(selected, environment);
|
|
197
215
|
return await launchUi(selected, environment, sessionArgs);
|
|
198
216
|
}
|
|
199
217
|
/**
|
|
@@ -209,14 +227,7 @@ async function launchRetainedActive(state, paths, environment, sessionArgs, outp
|
|
|
209
227
|
return null;
|
|
210
228
|
try {
|
|
211
229
|
const retained = await readCertifiedReleaseManifest(active, resolve(paths.dataDir, "releases"));
|
|
212
|
-
|
|
213
|
-
const endpoint = await readEndpointMetadata(retainedPaths.endpointMetadataPath);
|
|
214
|
-
if (!endpoint || await probeOwnership(endpoint) !== "live-verified") {
|
|
215
|
-
if (endpoint)
|
|
216
|
-
await removeEndpointArtifacts(retainedPaths.endpointMetadataPath, retainedPaths.endpoint);
|
|
217
|
-
const startup = await startSupervisor(retained, environment);
|
|
218
|
-
await waitForVerifiedEndpoint(retainedPaths.endpointMetadataPath, retained, 8_000, startup);
|
|
219
|
-
}
|
|
230
|
+
await ensureSupervisor(retained, environment);
|
|
220
231
|
output.write(`${PRODUCT_TEXT.diagnostic(`installation is being replaced; starting the retained release ${retained.packageVersion}`)}\n`);
|
|
221
232
|
return await launchUi(retained, environment, sessionArgs);
|
|
222
233
|
}
|
|
@@ -246,6 +257,25 @@ export async function recordParentCertifiedRelease(release, dataDir) {
|
|
|
246
257
|
await chmod(path, 0o400);
|
|
247
258
|
return path;
|
|
248
259
|
}
|
|
260
|
+
/** Reuse a verified owner, or join the winner if another launch starts the same cohort first. */
|
|
261
|
+
export async function ensureSupervisor(release, environment) {
|
|
262
|
+
const paths = resolveCohortEndpoint(resolveProductPaths(environment), release.releaseId, environment);
|
|
263
|
+
const metadata = await readEndpointMetadata(paths.endpointMetadataPath);
|
|
264
|
+
if (metadata) {
|
|
265
|
+
const probe = await probeOwnership(metadata);
|
|
266
|
+
if (probe !== "dead") {
|
|
267
|
+
if (probe === "live-verified" && endpointMatchesRelease(metadata, release))
|
|
268
|
+
return;
|
|
269
|
+
throw new Error(PRODUCT_TEXT.diagnostic(`refused duplicate supervisor startup: existing ownership is ${probe}`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const startup = await startSupervisor(release, environment);
|
|
273
|
+
await waitForVerifiedEndpoint(paths.endpointMetadataPath, release, 8_000, startup);
|
|
274
|
+
}
|
|
275
|
+
function endpointMatchesRelease(metadata, release) {
|
|
276
|
+
return metadata.releaseId === release.releaseId && metadata.releaseRoot === release.releaseRoot
|
|
277
|
+
&& metadata.contentDigest === release.contentDigest;
|
|
278
|
+
}
|
|
249
279
|
export async function startSupervisor(release, environment) {
|
|
250
280
|
const entry = await resolveReleaseEntryPoint(release, "bin/supervisor.js");
|
|
251
281
|
const paths = resolveProductPaths(environment);
|
|
@@ -296,10 +326,11 @@ export function releaseEnvironment(environment, release, profile) {
|
|
|
296
326
|
export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup) {
|
|
297
327
|
const deadline = Date.now() + timeoutMs;
|
|
298
328
|
let childOutcome = null;
|
|
329
|
+
let collision = null;
|
|
299
330
|
void startup?.childOutcome.then(outcome => { childOutcome = outcome; });
|
|
300
331
|
while (Date.now() < deadline) {
|
|
301
332
|
const metadata = await readEndpointMetadata(path);
|
|
302
|
-
if (metadata && metadata
|
|
333
|
+
if (metadata && endpointMatchesRelease(metadata, release) && await probeOwnership(metadata) === "live-verified") {
|
|
303
334
|
if (startup)
|
|
304
335
|
await rm(startup.resultPath, { force: true });
|
|
305
336
|
return;
|
|
@@ -307,15 +338,21 @@ export async function waitForVerifiedEndpoint(path, release, timeoutMs, startup)
|
|
|
307
338
|
if (startup) {
|
|
308
339
|
const result = await readSupervisorStartupResult(startup.resultPath, startup.attemptId, startup.releaseId);
|
|
309
340
|
if (result?.outcome === "failure") {
|
|
310
|
-
|
|
341
|
+
const error = Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor startup failed at ${result.stage}: ${result.message}`)), {
|
|
311
342
|
code: result.code ?? "SUPERVISOR_STARTUP_FAILED",
|
|
312
343
|
});
|
|
344
|
+
if (result.code !== "EADDRINUSE")
|
|
345
|
+
throw error;
|
|
346
|
+
// Concurrency: the winning process may not have published its authenticated metadata yet.
|
|
347
|
+
collision = error;
|
|
313
348
|
}
|
|
314
349
|
}
|
|
315
|
-
if (childOutcome)
|
|
350
|
+
if (childOutcome && !collision)
|
|
316
351
|
throw new Error(PRODUCT_TEXT.diagnostic(`supervisor exited before readiness: ${JSON.stringify(childOutcome)}`));
|
|
317
352
|
await new Promise(resolvePromise => setTimeout(resolvePromise, 40));
|
|
318
353
|
}
|
|
354
|
+
if (collision)
|
|
355
|
+
throw collision;
|
|
319
356
|
throw new Error(PRODUCT_TEXT.diagnostic(`supervisor did not publish verified endpoint metadata within ${timeoutMs}ms`));
|
|
320
357
|
}
|
|
321
358
|
export async function readEndpointMetadata(path) {
|
|
@@ -410,8 +447,7 @@ async function activatePendingAfterBlockerExit(candidate, stateStore, paths, env
|
|
|
410
447
|
return;
|
|
411
448
|
await removeEndpointArtifacts(paths.endpointMetadataPath, paths.endpoint);
|
|
412
449
|
await stateStore.activate(candidate.releaseId);
|
|
413
|
-
|
|
414
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, candidate.releaseId, environment).endpointMetadataPath, candidate, 8_000, startup);
|
|
450
|
+
await ensureSupervisor(candidate, environment);
|
|
415
451
|
}
|
|
416
452
|
export async function releaseVerifiedIdleOwner(metadata, dataDir, operations = {}) {
|
|
417
453
|
try {
|
|
@@ -10,6 +10,7 @@ export * from "./release-store.js";
|
|
|
10
10
|
export * from "./restart-certification.js";
|
|
11
11
|
export * from "./stable-release.js";
|
|
12
12
|
export * from "./update.js";
|
|
13
|
+
export * from "./update-launch.js";
|
|
13
14
|
export * from "./update-recovery.js";
|
|
14
15
|
export * from "./update-transaction.js";
|
|
15
16
|
export * from "./warmup.js";
|
|
@@ -10,6 +10,7 @@ export * from "./release-store.js";
|
|
|
10
10
|
export * from "./restart-certification.js";
|
|
11
11
|
export * from "./stable-release.js";
|
|
12
12
|
export * from "./update.js";
|
|
13
|
+
export * from "./update-launch.js";
|
|
13
14
|
export * from "./update-recovery.js";
|
|
14
15
|
export * from "./update-transaction.js";
|
|
15
16
|
export * from "./warmup.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CohortState, ReleaseRecord } from "./cohort-state.js";
|
|
2
|
+
import type { UpdateTransaction } from "./update-transaction.js";
|
|
3
|
+
/** Keep interactive launches on the last successful release until the update commits success. */
|
|
4
|
+
export declare function selectUpdateLaunchRelease(state: CohortState, transaction: UpdateTransaction | null): ReleaseRecord | null;
|
|
5
|
+
/** Pause cohort retirement during preparation; afterward only the successful launch release admits new sessions. */
|
|
6
|
+
export declare function selectSupervisorLaunchReleaseId(state: CohortState, transaction: UpdateTransaction | null): string | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Keep interactive launches on the last successful release until the update commits success. */
|
|
2
|
+
export function selectUpdateLaunchRelease(state, transaction) {
|
|
3
|
+
if (transaction === null || transaction.status === "completed")
|
|
4
|
+
return null;
|
|
5
|
+
const prior = transaction.priorActiveReleaseId === null ? undefined : state.releases[transaction.priorActiveReleaseId];
|
|
6
|
+
if (!prior || prior.approval !== "approved") {
|
|
7
|
+
throw new Error("unfinished update has no verified previous release available for launch; resume the update");
|
|
8
|
+
}
|
|
9
|
+
return prior;
|
|
10
|
+
}
|
|
11
|
+
/** Pause cohort retirement during preparation; afterward only the successful launch release admits new sessions. */
|
|
12
|
+
export function selectSupervisorLaunchReleaseId(state, transaction) {
|
|
13
|
+
// Concurrency: both the previous cohort and the warming candidate must remain available until cutover.
|
|
14
|
+
if (transaction?.status === "active")
|
|
15
|
+
return null;
|
|
16
|
+
return selectUpdateLaunchRelease(state, transaction)?.releaseId ?? state.references.active;
|
|
17
|
+
}
|
|
@@ -112,5 +112,7 @@ export type UpdateOwnershipAction = "clean-dead-record" | "leave-running" | "end
|
|
|
112
112
|
*/
|
|
113
113
|
export declare function planUpdateOwnership(ownership: "live-verified" | "dead", runsFromRetainedRelease: boolean): UpdateOwnershipAction;
|
|
114
114
|
export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput): UpdateLifecycleCoordinator;
|
|
115
|
+
/** Renders the terminal-only update meter; the visual preview shares this exact frame. */
|
|
116
|
+
export declare function renderUpdateProgressBar(percent: number): string;
|
|
115
117
|
export declare function runSelfUpdate(options: SelfUpdateOptions): Promise<number>;
|
|
116
118
|
export declare function assertUpdatePerformanceBudget(evidence: UpdatePerformanceEvidence, maximumPostNpmDurationMs?: number): void;
|
|
@@ -5,7 +5,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
5
5
|
import crossSpawn from "cross-spawn";
|
|
6
6
|
import { valid as validSemver } from "semver";
|
|
7
7
|
import { PRODUCT_IDENTITY, PRODUCT_TEXT } from "../../product-identity.js";
|
|
8
|
-
import { certifyMaterializedRelease, probeOwnership, readEndpointMetadata, removeEndpointArtifacts,
|
|
8
|
+
import { certifyMaterializedRelease, probeOwnership, readEndpointMetadata, removeEndpointArtifacts, ensureSupervisor, waitForProcessExit, } from "./bootstrap.js";
|
|
9
9
|
import { resolveCohortEndpoint, resolveProductPaths } from "../lifecycle/index.js";
|
|
10
10
|
import { encodeFrame, LineFrameDecoder } from "../protocol/index.js";
|
|
11
11
|
import { CohortStateStore } from "./cohort-state.js";
|
|
@@ -163,17 +163,17 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
|
|
|
163
163
|
const diagnostics = await certifyMaterializedRelease(candidate, paths.dataDir);
|
|
164
164
|
await stateStore.approve(candidate.releaseId, diagnostics);
|
|
165
165
|
await phase("certified");
|
|
166
|
-
await stateStore.activate(candidate.releaseId);
|
|
167
|
-
await phase("active-reference-committed");
|
|
168
166
|
onWarmup?.("started");
|
|
169
167
|
await warmMaterializedRelease(candidate, environment);
|
|
170
168
|
onWarmup?.("completed");
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
await ensureSupervisor(candidate, environment);
|
|
170
|
+
// Invariant: warmup and authenticated readiness precede changing the active reference.
|
|
171
|
+
await stateStore.activate(candidate.releaseId);
|
|
172
|
+
await phase("active-reference-committed");
|
|
173
173
|
},
|
|
174
174
|
};
|
|
175
175
|
}
|
|
176
|
-
const PROGRESS_BAR_WIDTH =
|
|
176
|
+
const PROGRESS_BAR_WIDTH = 40;
|
|
177
177
|
const PROGRESS_TICK_MS = 200;
|
|
178
178
|
/**
|
|
179
179
|
* Copying the release owns 78–92 and is reported file by file, so the bar crosses
|
|
@@ -186,10 +186,15 @@ const ACTIVATION_PROGRESS = {
|
|
|
186
186
|
certified: { at: 94, creepTo: 96 },
|
|
187
187
|
"active-reference-committed": { at: 96, creepTo: 99 },
|
|
188
188
|
};
|
|
189
|
-
|
|
189
|
+
/** Renders the terminal-only update meter; the visual preview shares this exact frame. */
|
|
190
|
+
export function renderUpdateProgressBar(percent) {
|
|
190
191
|
const bounded = Math.min(100, Math.max(0, Math.round(percent)));
|
|
191
192
|
const filled = Math.round((bounded / 100) * PROGRESS_BAR_WIDTH);
|
|
192
|
-
|
|
193
|
+
// Rationale: a gray line, a darker gray track, and one space before the percentage.
|
|
194
|
+
// Explicit RGB keeps both grays neutral even when the terminal remaps its ANSI palette.
|
|
195
|
+
const gray = "\u001b[38;2;128;128;128m";
|
|
196
|
+
const track = "\u001b[38;2;102;102;102m";
|
|
197
|
+
return `${gray}${"━".repeat(filled)}${track}${"─".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
|
|
193
198
|
}
|
|
194
199
|
function createUpdateProgress(output, enabled) {
|
|
195
200
|
let visible = false;
|
|
@@ -202,7 +207,7 @@ function createUpdateProgress(output, enabled) {
|
|
|
202
207
|
return;
|
|
203
208
|
shown = rounded;
|
|
204
209
|
visible = true;
|
|
205
|
-
output.stdout(`\r${
|
|
210
|
+
output.stdout(`\r${renderUpdateProgressBar(rounded)}`);
|
|
206
211
|
};
|
|
207
212
|
const stopCreep = () => {
|
|
208
213
|
if (timer !== null) {
|
|
@@ -375,11 +380,7 @@ export async function runSelfUpdate(options) {
|
|
|
375
380
|
: async () => await scheduleReleaseCleanup(paths.dataDir, paths));
|
|
376
381
|
let transaction = await transactionStore.read();
|
|
377
382
|
try {
|
|
378
|
-
if (await lifecycle.targetIsActive(targetVersion)) {
|
|
379
|
-
if (transaction?.status === "active") {
|
|
380
|
-
await transactionStore.advance("supervisor-verified");
|
|
381
|
-
await transactionStore.finish("completed");
|
|
382
|
-
}
|
|
383
|
+
if ((!transaction || transaction.status === "completed") && await lifecycle.targetIsActive(targetVersion)) {
|
|
383
384
|
await maintenance();
|
|
384
385
|
await transactionStore.clearCompleted();
|
|
385
386
|
output.stdout(`${PRODUCT_TEXT.commandName} is up to date — no update needed.\n`);
|
|
@@ -510,13 +511,14 @@ export async function runSelfUpdate(options) {
|
|
|
510
511
|
options.onPhaseTiming?.({ phase: "supervisor-verified", durationMs: Math.max(0, now() - activationPhaseStartedAt) });
|
|
511
512
|
const transactionStartedAt = now();
|
|
512
513
|
await transactionStore.advance("supervisor-verified");
|
|
513
|
-
await transactionStore.finish("completed");
|
|
514
514
|
if (transaction.recovery?.capsulePath)
|
|
515
515
|
await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId);
|
|
516
516
|
// Invariant: successful output follows the durable cleanup disposition. Slow recursive
|
|
517
517
|
// removal belongs to the detached worker started by this maintenance coordinator.
|
|
518
518
|
await maintenance();
|
|
519
|
-
|
|
519
|
+
// Concurrency: this is the launch cutover. No failure-prone update work follows success.
|
|
520
|
+
await transactionStore.finish("completed");
|
|
521
|
+
await transactionStore.clearCompleted().catch(() => undefined);
|
|
520
522
|
options.onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
|
|
521
523
|
progress.finish();
|
|
522
524
|
output.stdout(`${PRODUCT_TEXT.commandName} updated successfully: ${targetVersion}\n`);
|
|
@@ -609,11 +611,8 @@ async function rollbackPriorCohort(dataDir, environment, priorReleaseId) {
|
|
|
609
611
|
throw new Error(`prior release ${priorReleaseId} is not the recorded rollback cohort`);
|
|
610
612
|
}
|
|
611
613
|
const release = await readMaterializedRelease(prior.releaseRoot);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
// a cohort that survived the update keeps serving the work it already had.
|
|
615
|
-
const startup = await startSupervisor(release, environment);
|
|
616
|
-
await waitForVerifiedEndpoint(resolveCohortEndpoint(paths, release.releaseId, environment).endpointMetadataPath, release, 8_000, startup);
|
|
614
|
+
// Invariant: rollback reuses a surviving prior cohort rather than racing its occupied endpoint.
|
|
615
|
+
await ensureSupervisor(release, environment);
|
|
617
616
|
return "rolled back";
|
|
618
617
|
}
|
|
619
618
|
function phaseBefore(current, target) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { assertImmutableExecutionRoot, CohortStateStore, readCertifiedReleaseManifest, recordParentCertifiedRelease } from "../release/index.js";
|
|
4
|
+
import { assertImmutableExecutionRoot, CohortStateStore, readCertifiedReleaseManifest, recordParentCertifiedRelease, selectSupervisorLaunchReleaseId, UpdateTransactionStore } from "../release/index.js";
|
|
5
5
|
import { publishSupervisorStartupResult, supervisorStartupFailure, supervisorStartupReady, supervisorStartupResultPath } from "../lifecycle/index.js";
|
|
6
6
|
import { ControlStore } from "../storage/index.js";
|
|
7
7
|
import { resolveCohortEndpoint, resolveProductPaths } from "./paths.js";
|
|
@@ -38,7 +38,10 @@ export async function runSupervisor(arguments_ = []) {
|
|
|
38
38
|
const bootNonce = randomUUID();
|
|
39
39
|
const store = new ControlStore(paths.databasePath, bootNonce);
|
|
40
40
|
const cohortState = new CohortStateStore(paths.dataDir);
|
|
41
|
-
server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () =>
|
|
41
|
+
server = new SupervisorServer(store, paths, release, bootNonce, undefined, undefined, undefined, async () => {
|
|
42
|
+
const transaction = await new UpdateTransactionStore(paths.dataDir).read();
|
|
43
|
+
return selectSupervisorLaunchReleaseId(await cohortState.read(), transaction);
|
|
44
|
+
});
|
|
42
45
|
stage = "endpoint-listen";
|
|
43
46
|
await server.listen();
|
|
44
47
|
if (resultPath && attemptId)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createConnection, createServer } from "node:net";
|
|
4
4
|
import { platform } from "node:os";
|
|
5
5
|
import { dirname } from "node:path";
|
|
@@ -54,6 +54,7 @@ export class SupervisorServer {
|
|
|
54
54
|
startedAt = new Date().toISOString();
|
|
55
55
|
paths;
|
|
56
56
|
#server = null;
|
|
57
|
+
#ownsEndpoint = false;
|
|
57
58
|
#instances = new Map();
|
|
58
59
|
#instanceOwners = new Map();
|
|
59
60
|
#clientIds = new Map();
|
|
@@ -104,15 +105,24 @@ export class SupervisorServer {
|
|
|
104
105
|
if (platform() !== "win32") {
|
|
105
106
|
if (this.paths.endpointDirectory)
|
|
106
107
|
await ensureManagedEndpointDirectory(dirname(this.paths.endpoint));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
const existing = await lstat(this.paths.endpoint).catch(error => {
|
|
109
|
+
if (error.code === "ENOENT")
|
|
110
|
+
return null;
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
if (existing) {
|
|
114
|
+
if (await endpointIsLive(this.paths.endpoint)) {
|
|
115
|
+
throw Object.assign(new Error(PRODUCT_TEXT.diagnostic(`supervisor already owns ${this.paths.endpoint}`)), { code: "EADDRINUSE" });
|
|
116
|
+
}
|
|
117
|
+
await rm(this.paths.endpoint, { force: true });
|
|
118
|
+
}
|
|
110
119
|
}
|
|
111
120
|
const server = createServer(socket => this.#attach(socket));
|
|
112
121
|
this.#server = server;
|
|
113
122
|
await new Promise((resolve, reject) => {
|
|
114
123
|
server.once("error", reject);
|
|
115
124
|
server.listen(this.paths.endpoint, () => {
|
|
125
|
+
this.#ownsEndpoint = true;
|
|
116
126
|
server.off("error", reject);
|
|
117
127
|
resolve();
|
|
118
128
|
});
|
|
@@ -146,7 +156,9 @@ export class SupervisorServer {
|
|
|
146
156
|
await this.close(false);
|
|
147
157
|
}
|
|
148
158
|
async close(stopAgents = false) {
|
|
149
|
-
if (
|
|
159
|
+
if (this.#closing)
|
|
160
|
+
return;
|
|
161
|
+
if (stopAgents && this.#ownsEndpoint && !await this.#drainInstances("update", this.shutdownDeadlineMs)) {
|
|
150
162
|
throw new Error("active launch instances did not release ownership within the shutdown deadline");
|
|
151
163
|
}
|
|
152
164
|
this.#closing = true;
|
|
@@ -155,14 +167,27 @@ export class SupervisorServer {
|
|
|
155
167
|
this.#supersededPoll = null;
|
|
156
168
|
for (const client of this.#clients)
|
|
157
169
|
client.destroy();
|
|
158
|
-
|
|
159
|
-
await
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
170
|
+
try {
|
|
171
|
+
await this.#metadataWrites.catch(() => undefined);
|
|
172
|
+
if (this.#ownsEndpoint) {
|
|
173
|
+
// Concurrency: remove only our publication, before releasing the listening endpoint.
|
|
174
|
+
// A contender that failed to bind must never erase the successful owner's discovery file.
|
|
175
|
+
const metadata = await readFile(this.paths.endpointMetadataPath, "utf8").then(source => JSON.parse(source)).catch(() => null);
|
|
176
|
+
if (metadata?.supervisorId === this.id && metadata.bootNonce === this.bootNonce) {
|
|
177
|
+
await rm(this.paths.endpointMetadataPath, { force: true });
|
|
178
|
+
}
|
|
179
|
+
// Platform: Node unlinks its Unix socket when the server closes; unlinking it early would
|
|
180
|
+
// let a replacement bind before the old server finishes releasing that path.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
// Platform: a metadata sharing error must not leave a bound but undiscoverable listener.
|
|
185
|
+
if (this.#server)
|
|
186
|
+
await new Promise(resolve => this.#server?.close(() => resolve()));
|
|
187
|
+
this.#server = null;
|
|
188
|
+
this.#ownsEndpoint = false;
|
|
189
|
+
this.store.close();
|
|
190
|
+
}
|
|
166
191
|
}
|
|
167
192
|
async closeForReleaseReplacement(stopAgents) {
|
|
168
193
|
await this.close(stopAgents);
|
|
@@ -473,6 +498,8 @@ export class SupervisorServer {
|
|
|
473
498
|
return [...this.#instances.keys()];
|
|
474
499
|
}
|
|
475
500
|
#writeEndpointMetadata() {
|
|
501
|
+
if (this.#closing)
|
|
502
|
+
return Promise.resolve();
|
|
476
503
|
const liveInstanceIds = this.#liveInstanceIds();
|
|
477
504
|
const metadata = {
|
|
478
505
|
schema: PRODUCT_IDENTITY.protocol.supervisorSchema,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "darwin",
|
|
6
6
|
"architecture": "arm64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-12T16:36:02.575Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "9db1726bbe3fc2e8292f2ead1e7e9d0fd4d7d0dc9217b372582bf354b0f565dc",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "linux",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-12T16:36:05.106Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "d8cda6b0c7cb36c0cc41e90802aceebf6c02eaebbc08a83d7d963d2e918dbd7a",
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
"platform": "win32",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-12T16:36:30.210Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian.exe",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "0bc42bc571ebdcf244d60f42d03472a2dd57da4984b1d39931f9a2ad3787cde5",
|
|
12
12
|
"size": 177664
|
|
13
13
|
},
|
|
14
14
|
"provenance": {
|
|
Binary file
|
|
@@ -71,7 +71,7 @@ Presentation acceptance is the reader comparing `a1 pi` with pinned Pi. `node sc
|
|
|
71
71
|
|
|
72
72
|
## Publication
|
|
73
73
|
|
|
74
|
-
One workflow publishes both channels: `.github/workflows/release.yml`. Pushes do not publish. Nightly development verification runs at `03:17 UTC`; `npm run develop` explicitly requests a numbered preview and `npm run release` explicitly requests stable publication after its version pull request
|
|
74
|
+
One workflow publishes both channels: `.github/workflows/release.yml`. Pushes do not publish. Nightly development verification runs at `03:17 UTC`; `npm run develop` explicitly requests a numbered preview and `npm run release -- patch` explicitly requests stable publication after its version pull request is manually merged; it promotes a development version to its stable core. The following development-version pull request also requires manual merge. A preview is stamped as `-dev.<merged pull-request number>` and uses npm `next` only as an internal dist-tag. New candidates pack once and are validated on Windows, Linux, and macOS; a repeated nightly verifies the exact immutable registry tarball. All publication uses provenance from the `npm-publish` environment, a preview never changes `latest`, and the stable tag, GitHub Release, and `master` are written only after npm has the package. One global non-cancelling concurrency group serializes the final registry check.
|
|
75
75
|
|
|
76
76
|
`docs/ci-release-runbook.md` is the operational reference.
|
|
77
77
|
|
|
@@ -166,15 +166,40 @@ work.
|
|
|
166
166
|
|
|
167
167
|
## Cutting a stable release
|
|
168
168
|
|
|
169
|
-
From
|
|
169
|
+
From the repository root on clean `develop` matching `origin/develop`:
|
|
170
170
|
|
|
171
171
|
```sh
|
|
172
|
-
npm run release -- patch #
|
|
172
|
+
npm run release -- patch # 0.1.8-dev -> 0.1.8; already-stable 0.1.8 -> 0.1.9
|
|
173
|
+
npm run release -- minor # 0.1.8-dev -> 0.2.0
|
|
174
|
+
npm run release -- major # 0.1.8-dev -> 1.0.0
|
|
175
|
+
npm run release -- 0.4.0 # exact stable target
|
|
173
176
|
```
|
|
174
177
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
A target is required: `npm run release` alone is a mutation-free usage error.
|
|
179
|
+
`patch` preserves prerelease-aware semantics, including `0.1.8-dev.123 -> 0.1.8`.
|
|
180
|
+
The command reports its source, stable target, and prospective reopening before
|
|
181
|
+
preparing anything.
|
|
182
|
+
|
|
183
|
+
1. The stable-version edit is committed in an owned detached worktree beneath
|
|
184
|
+
`.worktrees/`. Only this package's manifest and root lockfile version change.
|
|
185
|
+
2. Follow the printed PR URL, wait for required CI, perform local validation, and
|
|
186
|
+
**merge manually after acceptance**. The helper does not merge PRs or enable
|
|
187
|
+
auto-merge. It polls for actual merge with a bounded 30-minute wait.
|
|
188
|
+
3. The helper verifies the merged version and source SHA against authoritative
|
|
189
|
+
develop, then explicitly dispatches stable publication for that exact source.
|
|
190
|
+
A changed source is an error, not permission to substitute a newer commit.
|
|
191
|
+
4. Only after verified publication of `0.1.8` does it prepare a separate PR for
|
|
192
|
+
`0.1.9-dev`. Validate and manually merge that PR as well. Until then the helper
|
|
193
|
+
reports development reopening as incomplete.
|
|
194
|
+
|
|
195
|
+
Closed PRs, timeout, cancellation, and query failures retain identifiable phase
|
|
196
|
+
work for inspection. Conflicting existing branches/PRs are not overwritten;
|
|
197
|
+
matching pending PRs can be observed again without replacing them. Worktrees
|
|
198
|
+
retained by an earlier attempt are not removed by a later invocation. Cleanup
|
|
199
|
+
only removes clean, owned, confirmed-merged phase worktrees and unchanged remote
|
|
200
|
+
phase branches. The caller is never hard-reset: a final fast-forward is attempted
|
|
201
|
+
only when its branch, original HEAD, and cleanliness remain unchanged. Otherwise
|
|
202
|
+
preserve local work and synchronize manually with the reported remote state.
|
|
178
203
|
|
|
179
204
|
Stable publication builds the process guardian on all supported platforms, packs
|
|
180
205
|
once, runs the complete suite against those exact bytes on Windows, Linux, and
|
|
@@ -200,8 +225,21 @@ Rules that do not bend:
|
|
|
200
225
|
path, and use the confirmed apply mode only for an accepted mutable policy change.
|
|
201
226
|
- **Nightly documentation review fails:** inspect the reported paths and rules, identify the introducing merge from the nightly interval, and repair the invariant before unrelated work proceeds.
|
|
202
227
|
- **Development publication fails:** fix the cause and rerun `npm run develop`; an npm version that already exists is never overwritten.
|
|
203
|
-
- **Stable
|
|
204
|
-
- **Stable publication is uncertain
|
|
228
|
+
- **Stable preparation stops before dispatch:** inspect the reported version PR/worktree. After preserving work, an exact matching pending PR can be observed again. If develop already declares the prepared stable version, use its exact target (for example `npm run release -- 0.1.8`) only after verifying that source's merged PR and confirming the registry/tag guards still permit it. Do not use `patch` from stable develop to retry the same version: that deliberately selects the next patch.
|
|
229
|
+
- **Stable publication fails or is uncertain:** inspect the workflow, registry version/digest, tag, and release before choosing recovery. No reopening PR is prepared. Never republish immutable bytes or move a release tag.
|
|
230
|
+
- **Stable publication succeeded but reopening stops:** the stable version is already published. Inspect and finish the reported next-development PR manually; do not repeat stable publication. If no reopening PR was created, prepare the next-development version through a separately validated manual PR after inspecting remote state.
|
|
231
|
+
|
|
232
|
+
## Safe release-command validation
|
|
233
|
+
|
|
234
|
+
Use the isolated harness rather than a live release to validate changes to this command:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
npm exec --no -- vitest run test/repository-governance/release-target.test.ts test/repository-governance/release-command.test.ts --maxWorkers=1 --minWorkers=1
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
It uses disposable local Git repositories and fake GitHub, registry, and publication
|
|
241
|
+
services. It does not publish a package or create production version PRs. A real
|
|
242
|
+
release remains a separate deliberate operation.
|
|
205
243
|
|
|
206
244
|
## Branch protection rationale
|
|
207
245
|
|
package/package.json
CHANGED