@timurproko/a1 0.2.7-dev.1285 → 0.2.7-dev.1286
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/dist/foundation/release/index.d.ts +1 -1
- package/dist/foundation/release/index.js +1 -1
- package/dist/foundation/release/release-gc.js +22 -1
- package/dist/foundation/release/release-state.d.ts +36 -1
- package/dist/foundation/release/release-state.js +115 -9
- package/dist/foundation/release/release-use.js +23 -7
- package/dist/foundation/release/update-activation.d.ts +3 -1
- package/dist/foundation/release/update-activation.js +21 -4
- package/dist/foundation/release/update.d.ts +3 -1
- package/dist/foundation/release/update.js +60 -21
- package/package.json +3 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { certifyMaterializedRelease, recordParentCertifiedRelease, releaseEnvironment, runBootstrap, } from "./bootstrap.js";
|
|
2
2
|
export type { BootstrapOptions } from "./bootstrap.js";
|
|
3
3
|
export { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
|
|
4
|
-
export { ReleaseStateStore, RELEASE_STATE_SCHEMA, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
|
|
4
|
+
export { ReleaseStateStore, ReleaseStateNewerError, isReleaseStateNewerError, RELEASE_STATE_SCHEMA, RELEASE_STATE_LEVEL, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
|
|
5
5
|
export type { ActiveReleaseTransactionReference, ReleaseState, OrphanReleaseDisposition, ProtectedReleaseInputs, ProtectedReleasePlan, ReleaseApproval, ReleaseCleanupDiagnostic, ReleaseCleanupDisposition, ReleaseCleanupStage, ReleaseCleanupState, ReleaseCleanupWorkerRun, ReleaseCleanupWorkerStatus, ReleaseCleanupWorkerSummary, ReleaseRecord, ReleaseReferences, ReleaseRetentionReconciliation, } from "./release-state.js";
|
|
6
6
|
export { DEPENDENCY_LAYER_MANIFEST, DEPENDENCY_LAYER_SCHEMA, RUNTIME_PAYLOAD_INVENTORY, dependencyLayerCertificationPath, dependencyReference, generateDependencyRuntimePayload, legacyDependencyLayerCertificationPath, materializeDependencyLayer, readCertifiedDependencyLayer, selectDependencyRuntimePayload, selectPublishedDependencyRuntimePayload, verifyDependencyLayer, } from "./dependency-layer.js";
|
|
7
7
|
export type { DependencyLayerIdentity, DependencyLayerOperationEvent, DependencyLayerReference, GeneratedRuntimePayload, MaterializeDependencyLayerOptions, MaterializedDependencyLayer, PublishedRuntimeFileClassification, PublishedRuntimePayload, ReadCertifiedDependencyLayerOptions, RuntimePayloadExclusion, RuntimePayloadInventory, SelectedRuntimePayload, } from "./dependency-layer.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { certifyMaterializedRelease, recordParentCertifiedRelease, releaseEnvironment, runBootstrap, } from "./bootstrap.js";
|
|
2
2
|
export { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
|
|
3
|
-
export { ReleaseStateStore, RELEASE_STATE_SCHEMA, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
|
|
3
|
+
export { ReleaseStateStore, ReleaseStateNewerError, isReleaseStateNewerError, RELEASE_STATE_SCHEMA, RELEASE_STATE_LEVEL, RELEASE_RETENTION_MS, emptyState, planProtectedReleases, withinRetention } from "./release-state.js";
|
|
4
4
|
export { DEPENDENCY_LAYER_MANIFEST, DEPENDENCY_LAYER_SCHEMA, RUNTIME_PAYLOAD_INVENTORY, dependencyLayerCertificationPath, dependencyReference, generateDependencyRuntimePayload, legacyDependencyLayerCertificationPath, materializeDependencyLayer, readCertifiedDependencyLayer, selectDependencyRuntimePayload, selectPublishedDependencyRuntimePayload, verifyDependencyLayer, } from "./dependency-layer.js";
|
|
5
5
|
export { processIsAlive } from "./process-liveness.js";
|
|
6
6
|
export { PRODUCT_PACKAGE_NAME, createReleaseIdentity, deriveReleaseIdentity, digestManifestFiles, discoverReleasePayload, packageRootFromModule, releaseFileIdentity, resolveWithin, } from "./release.js";
|
|
@@ -3,7 +3,7 @@ import { spawnReleaseEntry } from "./release-use.js";
|
|
|
3
3
|
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { dirname, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { ReleaseStateStore, releaseUseIsLive, } from "./release-state.js";
|
|
6
|
+
import { ReleaseStateStore, releaseUseIsLive, isReleaseStateNewerError, } from "./release-state.js";
|
|
7
7
|
import { resolveProductPaths } from "../lifecycle/index.js";
|
|
8
8
|
import { RELEASE_MANIFEST_FILENAME } from "./release-store.js";
|
|
9
9
|
import { DEPENDENCY_LAYER_MANIFEST, readCertifiedDependencyLayer } from "./dependency-layer.js";
|
|
@@ -130,6 +130,16 @@ export async function runBoundedReleaseCleanup(dataDir, paths, options = {}) {
|
|
|
130
130
|
* potentially slow recursive deletion. A later launch/update resumes work if spawning fails.
|
|
131
131
|
*/
|
|
132
132
|
export async function scheduleReleaseCleanup(dataDir, paths, options = {}) {
|
|
133
|
+
try {
|
|
134
|
+
await scheduleOwnedReleaseCleanup(dataDir, paths, options);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// Compatibility: a newer release owns state this code may not write; its own maintenance cleans up.
|
|
138
|
+
if (!isReleaseStateNewerError(error))
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function scheduleOwnedReleaseCleanup(dataDir, paths, options) {
|
|
133
143
|
const { store } = await prepareReleaseCleanup(dataDir, paths, options);
|
|
134
144
|
const runId = randomUUID();
|
|
135
145
|
await store.recordCleanupWorkerScheduled(runId);
|
|
@@ -152,6 +162,17 @@ export async function scheduleReleaseCleanup(dataDir, paths, options = {}) {
|
|
|
152
162
|
}
|
|
153
163
|
/** Entry used by the private cleanup executable shipped in every immutable release. */
|
|
154
164
|
export async function runReleaseCleanupWorker(environment = process.env, workerOptions = {}) {
|
|
165
|
+
try {
|
|
166
|
+
return await runOwnedReleaseCleanupWorker(environment, workerOptions);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// Compatibility: a worker started by an older release stands down once a newer release owns the state.
|
|
170
|
+
if (!isReleaseStateNewerError(error))
|
|
171
|
+
throw error;
|
|
172
|
+
return { planned: 0, attempted: 0, completed: 0, remaining: 0, durationMs: 0 };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function runOwnedReleaseCleanupWorker(environment, workerOptions) {
|
|
155
176
|
const paths = resolveProductPaths(environment);
|
|
156
177
|
const store = new ReleaseStateStore(paths.dataDir);
|
|
157
178
|
const runId = environment[WORKER_RUN_ID_ENV] ?? randomUUID();
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { MaterializedRelease } from "./release-store.js";
|
|
2
|
+
/**
|
|
3
|
+
* Protocol: the control-state family identifier. It is frozen: changing it locks every
|
|
4
|
+
* already-published release out of the state, which is what broke the 1276 → 1285 update.
|
|
5
|
+
* Evolve the format through {@link RELEASE_STATE_LEVEL} instead.
|
|
6
|
+
*/
|
|
2
7
|
export declare const RELEASE_STATE_SCHEMA: string;
|
|
8
|
+
/**
|
|
9
|
+
* Protocol: the compatibility level this code reads and writes. State records the lowest
|
|
10
|
+
* level that may read it and the lowest that may write it; additive fields raise neither,
|
|
11
|
+
* because older code preserves fields it does not know. Raise `read` only when older code would
|
|
12
|
+
* misread the state, and `write` only when older writes would corrupt it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const RELEASE_STATE_LEVEL = 1;
|
|
3
15
|
/** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
|
|
4
16
|
export declare const RELEASE_RETENTION_MS: number;
|
|
5
17
|
export type ReleaseApproval = "candidate" | "approved" | "rejected";
|
|
@@ -81,6 +93,27 @@ export interface ReleaseState {
|
|
|
81
93
|
readonly references: ReleaseReferences;
|
|
82
94
|
readonly cleanup: ReleaseCleanupState;
|
|
83
95
|
readonly uses?: Readonly<Record<string, ReleaseUseClaim>>;
|
|
96
|
+
/** Lowest compatibility levels that may read and write this state; absent means level 1. */
|
|
97
|
+
readonly format?: ReleaseStateFormat;
|
|
98
|
+
}
|
|
99
|
+
export interface ReleaseStateFormat {
|
|
100
|
+
readonly read: number;
|
|
101
|
+
readonly write: number;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* State written by a newer release that this code may not read or write. Callers doing
|
|
105
|
+
* housekeeping treat it as "a newer release owns this state" and skip their work.
|
|
106
|
+
*/
|
|
107
|
+
export declare class ReleaseStateNewerError extends Error {
|
|
108
|
+
readonly access: "read" | "write";
|
|
109
|
+
readonly required: number;
|
|
110
|
+
constructor(access: "read" | "write", required: number);
|
|
111
|
+
}
|
|
112
|
+
export declare function isReleaseStateNewerError(error: unknown): error is ReleaseStateNewerError;
|
|
113
|
+
export interface ReleaseStateStoreOptions {
|
|
114
|
+
/** Replaces the atomic rename; tests inject transient sharing failures. */
|
|
115
|
+
readonly rename?: (from: string, to: string) => Promise<void>;
|
|
116
|
+
readonly platform?: NodeJS.Platform;
|
|
84
117
|
}
|
|
85
118
|
export interface ProtectedReleaseInputs {
|
|
86
119
|
/** Releases the reconciling command itself runs from; they are never collected under it. */
|
|
@@ -121,8 +154,10 @@ export declare function planProtectedReleases(state: ReleaseState, inputs?: Prot
|
|
|
121
154
|
export declare function withinRetention(release: ReleaseRecord, now: number, retentionMs?: number): boolean;
|
|
122
155
|
/** Serializes validated release-state revisions under a lock and commits each update atomically. */
|
|
123
156
|
export declare class ReleaseStateStore {
|
|
157
|
+
#private;
|
|
124
158
|
readonly path: string;
|
|
125
|
-
constructor(dataDir: string);
|
|
159
|
+
constructor(dataDir: string, options?: ReleaseStateStoreOptions);
|
|
160
|
+
private renameWithRetry;
|
|
126
161
|
read(): Promise<ReleaseState>;
|
|
127
162
|
update(operation: (current: ReleaseState) => ReleaseState | Promise<ReleaseState>): Promise<ReleaseState>;
|
|
128
163
|
recordCandidate(release: MaterializedRelease): Promise<ReleaseState>;
|
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readdir, readFile, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
|
|
5
5
|
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
6
|
+
/**
|
|
7
|
+
* Protocol: the control-state family identifier. It is frozen: changing it locks every
|
|
8
|
+
* already-published release out of the state, which is what broke the 1276 → 1285 update.
|
|
9
|
+
* Evolve the format through {@link RELEASE_STATE_LEVEL} instead.
|
|
10
|
+
*/
|
|
6
11
|
export const RELEASE_STATE_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
|
|
12
|
+
/** Compatibility: earlier family identifier, normalized on read. */
|
|
13
|
+
const LEGACY_RELEASE_STATE_SCHEMA = "a1-release-cohort-v1";
|
|
14
|
+
/**
|
|
15
|
+
* Protocol: the compatibility level this code reads and writes. State records the lowest
|
|
16
|
+
* level that may read it and the lowest that may write it; additive fields raise neither,
|
|
17
|
+
* because older code preserves fields it does not know. Raise `read` only when older code would
|
|
18
|
+
* misread the state, and `write` only when older writes would corrupt it.
|
|
19
|
+
*/
|
|
20
|
+
export const RELEASE_STATE_LEVEL = 1;
|
|
21
|
+
const RELEASE_STATE_TEMPORARY_PATTERN = /^release-state\.json\.(\d+)\.[0-9a-f-]{36}\.tmp$/;
|
|
22
|
+
const RENAME_RETRY_MS = 2_000;
|
|
7
23
|
const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
|
|
8
24
|
/** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
|
|
9
25
|
export const RELEASE_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
26
|
+
/**
|
|
27
|
+
* State written by a newer release that this code may not read or write. Callers doing
|
|
28
|
+
* housekeeping treat it as "a newer release owns this state" and skip their work.
|
|
29
|
+
*/
|
|
30
|
+
export class ReleaseStateNewerError extends Error {
|
|
31
|
+
access;
|
|
32
|
+
required;
|
|
33
|
+
constructor(access, required) {
|
|
34
|
+
super(`release state requires ${access} level ${required}; this release supports level ${RELEASE_STATE_LEVEL}. A newer release owns it`);
|
|
35
|
+
this.name = "ReleaseStateNewerError";
|
|
36
|
+
this.access = access;
|
|
37
|
+
this.required = required;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isReleaseStateNewerError(error) {
|
|
41
|
+
return error instanceof ReleaseStateNewerError;
|
|
42
|
+
}
|
|
10
43
|
/**
|
|
11
44
|
* Age-based retention. A release stays while a selector names it (active, its rollback, or a
|
|
12
45
|
* candidate still being certified), while an unfinished update may return to it, while the
|
|
@@ -56,12 +89,37 @@ export function withinRetention(release, now, retentionMs = RELEASE_RETENTION_MS
|
|
|
56
89
|
/** Serializes validated release-state revisions under a lock and commits each update atomically. */
|
|
57
90
|
export class ReleaseStateStore {
|
|
58
91
|
path;
|
|
59
|
-
|
|
92
|
+
#rename;
|
|
93
|
+
#platform;
|
|
94
|
+
constructor(dataDir, options = {}) {
|
|
60
95
|
this.path = resolve(dataDir, "release-state.json");
|
|
96
|
+
this.#rename = options.rename ?? rename;
|
|
97
|
+
this.#platform = options.platform ?? process.platform;
|
|
98
|
+
}
|
|
99
|
+
async renameWithRetry(temporary) {
|
|
100
|
+
const deadline = Date.now() + RENAME_RETRY_MS;
|
|
101
|
+
for (let delay = 10;; delay = Math.min(delay * 2, 200)) {
|
|
102
|
+
try {
|
|
103
|
+
await this.#rename(temporary, this.path);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
108
|
+
// Platform: Windows refuses to replace a file another process has open for reading.
|
|
109
|
+
// Readers hold it only briefly, so the replacement is retried rather than lost.
|
|
110
|
+
const transient = this.#platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY");
|
|
111
|
+
if (!transient || Date.now() >= deadline)
|
|
112
|
+
throw error;
|
|
113
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, delay));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
61
116
|
}
|
|
62
117
|
async read() {
|
|
63
118
|
try {
|
|
64
119
|
const state = normalizeState(JSON.parse(await readFile(this.path, "utf8")));
|
|
120
|
+
const format = stateFormat(state);
|
|
121
|
+
if (format.read > RELEASE_STATE_LEVEL)
|
|
122
|
+
throw new ReleaseStateNewerError("read", format.read);
|
|
65
123
|
validateState(state);
|
|
66
124
|
return state;
|
|
67
125
|
}
|
|
@@ -77,19 +135,30 @@ export class ReleaseStateStore {
|
|
|
77
135
|
const lock = await acquireLock(lockPath);
|
|
78
136
|
try {
|
|
79
137
|
const current = await this.read();
|
|
138
|
+
const format = stateFormat(current);
|
|
139
|
+
if (format.write > RELEASE_STATE_LEVEL)
|
|
140
|
+
throw new ReleaseStateNewerError("write", format.write);
|
|
80
141
|
const next = await operation(current);
|
|
81
142
|
validateTransition(current, next);
|
|
82
|
-
|
|
143
|
+
// Invariant: levels only rise; an older writer never weakens what a newer one required.
|
|
144
|
+
const committed = { ...next, revision: current.revision + 1, format };
|
|
83
145
|
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
84
146
|
const file = await open(temporary, "wx", 0o600);
|
|
85
147
|
try {
|
|
86
|
-
|
|
87
|
-
|
|
148
|
+
try {
|
|
149
|
+
await file.writeFile(JSON.stringify(committed, null, 2));
|
|
150
|
+
await file.sync();
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await file.close();
|
|
154
|
+
}
|
|
155
|
+
await this.renameWithRetry(temporary);
|
|
88
156
|
}
|
|
89
|
-
|
|
90
|
-
await
|
|
157
|
+
catch (error) {
|
|
158
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
159
|
+
throw error;
|
|
91
160
|
}
|
|
92
|
-
await
|
|
161
|
+
await reclaimOrphanedTemporaries(dirname(this.path));
|
|
93
162
|
return committed;
|
|
94
163
|
}
|
|
95
164
|
finally {
|
|
@@ -425,6 +494,43 @@ async function reclaimAbandonedLock(path) {
|
|
|
425
494
|
await rm(gate, { recursive: true, force: true });
|
|
426
495
|
}
|
|
427
496
|
}
|
|
497
|
+
function stateFormat(state) {
|
|
498
|
+
const format = state?.format;
|
|
499
|
+
if (format === undefined)
|
|
500
|
+
return { read: RELEASE_STATE_LEVEL, write: RELEASE_STATE_LEVEL };
|
|
501
|
+
const { read, write } = (format ?? {});
|
|
502
|
+
if (!Number.isSafeInteger(read) || !Number.isSafeInteger(write) || read < 1 || write < 1) {
|
|
503
|
+
throw new Error("invalid release state format");
|
|
504
|
+
}
|
|
505
|
+
return { read: read, write: write };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* A write that died between creating and renaming its temporary file leaves it behind.
|
|
509
|
+
* Only files whose writer process is gone are removed; a running writer's file is never touched.
|
|
510
|
+
*/
|
|
511
|
+
async function reclaimOrphanedTemporaries(directory) {
|
|
512
|
+
const names = await readdir(directory).catch(() => []);
|
|
513
|
+
for (const name of names) {
|
|
514
|
+
const match = RELEASE_STATE_TEMPORARY_PATTERN.exec(name);
|
|
515
|
+
if (!match)
|
|
516
|
+
continue;
|
|
517
|
+
const pid = Number(match[1]);
|
|
518
|
+
if (pid === process.pid || processIsAlive(pid))
|
|
519
|
+
continue;
|
|
520
|
+
await rm(resolve(directory, name), { force: true }).catch(() => undefined);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function processIsAlive(pid) {
|
|
524
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
525
|
+
return true;
|
|
526
|
+
try {
|
|
527
|
+
process.kill(pid, 0);
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
532
|
+
}
|
|
533
|
+
}
|
|
428
534
|
function normalizeState(value) {
|
|
429
535
|
if (!value || typeof value !== "object")
|
|
430
536
|
return value;
|
|
@@ -435,7 +541,7 @@ function normalizeState(value) {
|
|
|
435
541
|
return {
|
|
436
542
|
...state,
|
|
437
543
|
// Compatibility: one-way control-state upgrade fences pre-claim cleanup workers; session files are untouched.
|
|
438
|
-
...(state.schema ===
|
|
544
|
+
...(state.schema === LEGACY_RELEASE_STATE_SCHEMA ? { schema: RELEASE_STATE_SCHEMA } : {}),
|
|
439
545
|
cleanup: cleanup
|
|
440
546
|
? { ...cleanup, workerRuns: cleanup.workerRuns ?? [] }
|
|
441
547
|
: { pending: {}, diagnostics: [], workerRuns: [] },
|
|
@@ -3,7 +3,7 @@ import { realpath } from "node:fs/promises";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { basename, dirname, resolve } from "node:path";
|
|
5
5
|
import { resolveProductPaths } from "../lifecycle/index.js";
|
|
6
|
-
import { ReleaseStateStore, releaseUseIsLive } from "./release-state.js";
|
|
6
|
+
import { ReleaseStateStore, isReleaseStateNewerError, releaseUseIsLive } from "./release-state.js";
|
|
7
7
|
/** Mutable installed entries are not GC-managed; immutable helper processes need their own claim. */
|
|
8
8
|
async function claimReleaseEntry(entry, environment) {
|
|
9
9
|
const dataDir = resolveProductPaths(environment).dataDir;
|
|
@@ -11,7 +11,16 @@ async function claimReleaseEntry(entry, environment) {
|
|
|
11
11
|
const storeRoot = await realpath(resolve(dataDir, "releases")).catch(() => undefined);
|
|
12
12
|
if (dirname(root) !== storeRoot)
|
|
13
13
|
return undefined;
|
|
14
|
-
|
|
14
|
+
try {
|
|
15
|
+
return await acquireReleaseUse(dataDir, basename(root));
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
// Compatibility: a newer release owns state this code may not write. It retains releases it
|
|
19
|
+
// cannot prove unused, so the launch proceeds without a claim rather than failing.
|
|
20
|
+
if (isReleaseStateNewerError(error))
|
|
21
|
+
return undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
15
24
|
}
|
|
16
25
|
export async function spawnReleaseEntry(entry, args, options) {
|
|
17
26
|
const use = await claimReleaseEntry(entry, options.env ?? process.env);
|
|
@@ -59,11 +68,18 @@ export async function acquireReleaseUse(dataDir, releaseId) {
|
|
|
59
68
|
async release() {
|
|
60
69
|
if (released)
|
|
61
70
|
return;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
try {
|
|
72
|
+
await store.update(state => {
|
|
73
|
+
const uses = { ...state.uses };
|
|
74
|
+
delete uses[token];
|
|
75
|
+
return { ...state, uses };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Compatibility: once a newer release owns the state, it settles this claim by process liveness.
|
|
80
|
+
if (!isReleaseStateNewerError(error))
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
67
83
|
released = true;
|
|
68
84
|
},
|
|
69
85
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProductPaths } from "../lifecycle/index.js";
|
|
1
2
|
/**
|
|
2
3
|
* The one activation contract an installed tree can offer the updater that installed it.
|
|
3
4
|
* The updater knows nothing else about the tree: the manifest names the contracts the tree
|
|
@@ -69,4 +70,5 @@ export declare function delegateActivation(request: UpdateActivationRequest, cal
|
|
|
69
70
|
*/
|
|
70
71
|
export declare function runActivationEntry(argv: readonly string[], entryUrl: string, io?: {
|
|
71
72
|
write(line: string): void;
|
|
72
|
-
|
|
73
|
+
diagnostic?(line: string): void;
|
|
74
|
+
}, environment?: NodeJS.ProcessEnv, maintenance?: (dataDir: string, paths: ProductPaths) => Promise<void>): Promise<number>;
|
|
@@ -6,6 +6,7 @@ import { PRODUCT_TEXT } from "../../product-identity.js";
|
|
|
6
6
|
import { certifyMaterializedRelease } from "./bootstrap.js";
|
|
7
7
|
import { ReleaseStateStore } from "./release-state.js";
|
|
8
8
|
import { resolveProductPaths } from "../lifecycle/index.js";
|
|
9
|
+
import { scheduleReleaseCleanup } from "./release-gc.js";
|
|
9
10
|
import { materializeRelease } from "./release-store.js";
|
|
10
11
|
import { warmMaterializedRelease } from "./warmup.js";
|
|
11
12
|
import { errorMessage } from "./managed-path.js";
|
|
@@ -113,6 +114,9 @@ export async function delegateActivation(request, callbacks) {
|
|
|
113
114
|
for (const event of events) {
|
|
114
115
|
relay = relay.then(async () => {
|
|
115
116
|
const parsed = parseActivationEvent(event);
|
|
117
|
+
// Compatibility: a newer tree may report events this updater does not know; they carry no verdict.
|
|
118
|
+
if (parsed === "unknown")
|
|
119
|
+
return;
|
|
116
120
|
if (!parsed) {
|
|
117
121
|
failure ??= new Error(`unrecognized activation event ${JSON.stringify(event).slice(0, 200)}`);
|
|
118
122
|
return;
|
|
@@ -146,14 +150,19 @@ export async function delegateActivation(request, callbacks) {
|
|
|
146
150
|
* Body of the installed tree's activation entry: activate the tree this code ships in,
|
|
147
151
|
* writing one progress event per line so the updater that started it can follow along.
|
|
148
152
|
*/
|
|
149
|
-
export async function runActivationEntry(argv, entryUrl, io = {
|
|
153
|
+
export async function runActivationEntry(argv, entryUrl, io = {
|
|
154
|
+
write: line => process.stdout.write(line),
|
|
155
|
+
diagnostic: line => process.stderr.write(line),
|
|
156
|
+
}, environment = process.env, maintenance = scheduleReleaseCleanup) {
|
|
150
157
|
const emit = (event) => io.write(`${JSON.stringify(event)}\n`);
|
|
151
158
|
try {
|
|
152
159
|
const arguments_ = parseEntryArguments(argv);
|
|
153
160
|
const packageRoot = resolve(fileURLToPath(new URL("..", entryUrl)));
|
|
161
|
+
const paths = resolveProductPaths(environment);
|
|
162
|
+
const dataDir = arguments_.dataDir ?? paths.dataDir;
|
|
154
163
|
await activateInstalledRelease({
|
|
155
164
|
packageRoot,
|
|
156
|
-
dataDir
|
|
165
|
+
dataDir,
|
|
157
166
|
targetVersion: arguments_.targetVersion,
|
|
158
167
|
environment,
|
|
159
168
|
}, {
|
|
@@ -161,6 +170,12 @@ export async function runActivationEntry(argv, entryUrl, io = { write: line => p
|
|
|
161
170
|
onMaterializing: progress => emit({ event: "materializing", ...progress }),
|
|
162
171
|
onWarmup: state => emit({ event: "warmup", state }),
|
|
163
172
|
});
|
|
173
|
+
// Invariant: the release that now owns the control state performs its own maintenance. The
|
|
174
|
+
// updater that started this entry may be older and must not interpret state this code wrote.
|
|
175
|
+
// The activation is committed, so a maintenance failure is a diagnostic, never a verdict.
|
|
176
|
+
await maintenance(dataDir, { ...paths, dataDir }).catch((error) => {
|
|
177
|
+
io.diagnostic?.(`release maintenance after activation failed: ${errorMessage(error)}\n`);
|
|
178
|
+
});
|
|
164
179
|
emit({ event: "completed" });
|
|
165
180
|
return 0;
|
|
166
181
|
}
|
|
@@ -197,7 +212,9 @@ function parseActivationEvent(value) {
|
|
|
197
212
|
case "materializing":
|
|
198
213
|
return typeof record.completed === "number" && typeof record.total === "number" ? { event: "materializing", completed: record.completed, total: record.total } : null;
|
|
199
214
|
case "phase":
|
|
200
|
-
|
|
215
|
+
if (record.phase === "materialized" || record.phase === "certified" || record.phase === "active-reference-committed")
|
|
216
|
+
return { event: "phase", phase: record.phase };
|
|
217
|
+
return typeof record.phase === "string" && record.phase.length > 0 ? "unknown" : null;
|
|
201
218
|
case "warmup":
|
|
202
219
|
return record.state === "started" || record.state === "completed" ? { event: "warmup", state: record.state } : null;
|
|
203
220
|
case "completed":
|
|
@@ -205,7 +222,7 @@ function parseActivationEvent(value) {
|
|
|
205
222
|
case "failed":
|
|
206
223
|
return typeof record.message === "string" ? { event: "failed", message: record.message } : null;
|
|
207
224
|
default:
|
|
208
|
-
return null;
|
|
225
|
+
return typeof record.event === "string" && record.event.length > 0 ? "unknown" : null;
|
|
209
226
|
}
|
|
210
227
|
}
|
|
211
228
|
function summarize(text) {
|
|
@@ -87,8 +87,10 @@ export type { UpdateActivationPhase, UpdateMaterializationProgress } from "./upd
|
|
|
87
87
|
export interface UpdateLifecycleCoordinator {
|
|
88
88
|
targetIsActive(targetVersion: string): Promise<boolean>;
|
|
89
89
|
verifyPackageUnlocked(packageRoot: string): Promise<void>;
|
|
90
|
-
activateInstalled(packageRoot: string, targetVersion: string, phase: (phase: UpdateActivationPhase) => Promise<void>, onMaterializing?: (progress: UpdateMaterializationProgress) => void, onWarmup?: (state: "started" | "completed") => void): Promise<void>;
|
|
90
|
+
activateInstalled(packageRoot: string, targetVersion: string, phase: (phase: UpdateActivationPhase) => Promise<void>, onMaterializing?: (progress: UpdateMaterializationProgress) => void, onWarmup?: (state: "started" | "completed") => void): Promise<UpdateActivationMode | void>;
|
|
91
91
|
}
|
|
92
|
+
/** Whether the installed tree activated itself (and owns its maintenance) or this updater activated it. */
|
|
93
|
+
export type UpdateActivationMode = "delegated" | "in-process";
|
|
92
94
|
export interface UpdateTransactionJournal {
|
|
93
95
|
readonly path: string;
|
|
94
96
|
read(): Promise<UpdateTransaction | null>;
|
|
@@ -136,10 +136,12 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
|
|
|
136
136
|
// its own to know. A tree that serves the contract activates itself with its own code;
|
|
137
137
|
// a tree from before the contract is activated here, with the layout it had then.
|
|
138
138
|
const contracts = await readActivationContracts(packageRoot, fileSystem.readFile);
|
|
139
|
-
if (contracts.includes(UPDATE_ACTIVATION_CONTRACT))
|
|
139
|
+
if (contracts.includes(UPDATE_ACTIVATION_CONTRACT)) {
|
|
140
140
|
await delegateActivation(request, callbacks);
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
return "delegated";
|
|
142
|
+
}
|
|
143
|
+
await activateInstalledRelease(request, callbacks);
|
|
144
|
+
return "in-process";
|
|
143
145
|
},
|
|
144
146
|
};
|
|
145
147
|
}
|
|
@@ -416,13 +418,22 @@ async function withUpdateExecutionLease(input, options, output, installer, opera
|
|
|
416
418
|
// back to the release that ran before it, so this request proceeds as an ordinary update of its
|
|
417
419
|
// own target instead of reporting a dead attempt as in progress.
|
|
418
420
|
const interrupted = lease.owner.targetVersion;
|
|
419
|
-
const rollback = await installer.rollback(lease.transaction.priorActiveReleaseId)
|
|
420
|
-
.catch(error => `rollback failed: ${errorMessage(error)}`);
|
|
421
421
|
const store = new UpdateTransactionStore(input.dataDir);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
422
|
+
if (lease.transaction.phase === "active-reference-committed") {
|
|
423
|
+
// Invariant: the interrupted update already committed its target; agents may run it, so it stays.
|
|
424
|
+
await store.finish("completed", `interrupted ${interrupted} update was already active; settled by ${input.targetVersion}`);
|
|
425
|
+
await store.clearCompleted().catch(() => undefined);
|
|
426
|
+
await reclaimDeadLease(input.dataDir, lease.owner);
|
|
427
|
+
output.stdout(`${PRODUCT_TEXT.commandName} settled the interrupted ${interrupted} update, which was already active\n`);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
const rollback = await installer.rollback(lease.transaction.priorActiveReleaseId)
|
|
431
|
+
.catch(error => `rollback failed: ${errorMessage(error)}`);
|
|
432
|
+
await store.finish("rolled-back", `interrupted ${interrupted} update settled by ${input.targetVersion}; ${rollback}`);
|
|
433
|
+
await store.clearCompleted().catch(() => undefined);
|
|
434
|
+
await reclaimDeadLease(input.dataDir, lease.owner);
|
|
435
|
+
output.stdout(`${PRODUCT_TEXT.commandName} rolled back the interrupted ${interrupted} update: ${rollback}\n`);
|
|
436
|
+
}
|
|
426
437
|
lease = await acquireUpdateExecutionLease(input);
|
|
427
438
|
}
|
|
428
439
|
if (lease.status !== "acquired") {
|
|
@@ -533,7 +544,9 @@ async function runUpdateTransaction(plan) {
|
|
|
533
544
|
const { packageRoot, npmCliRoot } = plan.installation;
|
|
534
545
|
const run = { transaction: await transactionStore.read() };
|
|
535
546
|
try {
|
|
536
|
-
|
|
547
|
+
// Rationale: a settled transaction, even one reported as failed after its commit, leaves nothing
|
|
548
|
+
// to install when its target is already active; reinstalling the same release would only churn.
|
|
549
|
+
if ((!run.transaction || run.transaction.status !== "active") && await lifecycle.targetIsActive(targetVersion)) {
|
|
537
550
|
await installer.maintenance();
|
|
538
551
|
await transactionStore.clearCompleted();
|
|
539
552
|
output.stdout(`${PRODUCT_TEXT.commandName} is up to date — no update needed.\n`);
|
|
@@ -610,7 +623,7 @@ async function replaceUpdatePackage(plan, run, protection, npmCli) {
|
|
|
610
623
|
}
|
|
611
624
|
/** Activate the installed package, commit maintenance, and only then report success. */
|
|
612
625
|
async function activateUpdate(plan, run) {
|
|
613
|
-
const {
|
|
626
|
+
const { progress, transactionStore, now, onPhaseTiming } = plan;
|
|
614
627
|
progress.set(70, MATERIALIZE_PROGRESS.from);
|
|
615
628
|
let activationPhaseStartedAt = now();
|
|
616
629
|
const recordActivationPhase = async (phase) => {
|
|
@@ -619,7 +632,7 @@ async function activateUpdate(plan, run) {
|
|
|
619
632
|
progress.set(ACTIVATION_PROGRESS[phase].at, ACTIVATION_PROGRESS[phase].creepTo);
|
|
620
633
|
activationPhaseStartedAt = now();
|
|
621
634
|
};
|
|
622
|
-
await plan.lifecycle.activateInstalled(plan.installation.packageRoot, plan.targetVersion, recordActivationPhase, ({ completed, total }) => progress.set(MATERIALIZE_PROGRESS.from
|
|
635
|
+
const activation = await plan.lifecycle.activateInstalled(plan.installation.packageRoot, plan.targetVersion, recordActivationPhase, ({ completed, total }) => progress.set(MATERIALIZE_PROGRESS.from
|
|
623
636
|
+ (MATERIALIZE_PROGRESS.to - MATERIALIZE_PROGRESS.from) * (total > 0 ? Math.min(1, completed / total) : 0)), state => {
|
|
624
637
|
if (state === "started") {
|
|
625
638
|
activationPhaseStartedAt = now();
|
|
@@ -632,19 +645,39 @@ async function activateUpdate(plan, run) {
|
|
|
632
645
|
}
|
|
633
646
|
});
|
|
634
647
|
const transactionStartedAt = now();
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
648
|
+
const warnings = [];
|
|
649
|
+
// Invariant: a delegated tree owns the control state it just wrote and has already scheduled its
|
|
650
|
+
// own maintenance; this updater may be older and must not interpret that state. An in-process
|
|
651
|
+
// activation ran this updater's own code, so the updater remains the owner of its maintenance.
|
|
652
|
+
if (activation !== "delegated") {
|
|
653
|
+
// Rationale: the activation is committed, so cleanup trouble is retried later, never a failure.
|
|
654
|
+
await plan.installer.maintenance().catch(error => { warnings.push(`release cleanup will retry: ${errorMessage(error)}`); });
|
|
655
|
+
}
|
|
656
|
+
await completeCommittedUpdate(plan, run.transaction, warnings);
|
|
657
|
+
onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The active reference names the target, so the update has happened. Everything here is
|
|
662
|
+
* best-effort bookkeeping: it may warn, but it never fails the update and never rolls back.
|
|
663
|
+
*/
|
|
664
|
+
async function completeCommittedUpdate(plan, transaction, warnings) {
|
|
665
|
+
const { output, progress, transactionStore, paths } = plan;
|
|
666
|
+
if (transaction?.recovery?.capsulePath) {
|
|
667
|
+
await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId)
|
|
668
|
+
.catch(error => { warnings.push(`recovery evidence will be removed later: ${errorMessage(error)}`); });
|
|
669
|
+
}
|
|
641
670
|
// Concurrency: this is the launch cutover. No failure-prone update work follows success.
|
|
642
|
-
await transactionStore.finish("completed")
|
|
671
|
+
await transactionStore.finish("completed", warnings.length === 0 ? null : warnings.join("; ").slice(0, 2_000))
|
|
672
|
+
.catch(error => { warnings.push(`update journal could not be settled: ${errorMessage(error)}`); });
|
|
643
673
|
await Promise.all([transactionStore.clearCompleted(), invalidateReleaseCheckCache(paths.configDir)]).catch(() => undefined);
|
|
644
|
-
onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
|
|
645
674
|
progress.finish();
|
|
646
675
|
output.stdout(`${PRODUCT_TEXT.commandName} updated successfully to ${plan.targetVersion}\n`);
|
|
647
|
-
|
|
676
|
+
for (const warning of warnings)
|
|
677
|
+
output.stderr(`${PRODUCT_TEXT.diagnostic(`warning: ${singleLine(warning)}`)}\n`);
|
|
678
|
+
}
|
|
679
|
+
function singleLine(text) {
|
|
680
|
+
return text.replace(/[\r\n]+/gu, " ").slice(0, 400);
|
|
648
681
|
}
|
|
649
682
|
async function settleUpdateFailure(plan, transaction, error) {
|
|
650
683
|
const { output, transactionStore } = plan;
|
|
@@ -654,6 +687,12 @@ async function settleUpdateFailure(plan, transaction, error) {
|
|
|
654
687
|
output.stderr(`${PRODUCT_TEXT.diagnostic(`update could not start safely: ${message}. Nothing was changed. Retry with npm x -y -- ${PRODUCT_PACKAGE}-install`)}\n`);
|
|
655
688
|
return 1;
|
|
656
689
|
}
|
|
690
|
+
if (transaction?.status === "active" && transaction.phase === "active-reference-committed") {
|
|
691
|
+
// Invariant: once the active reference names the target, running agents may already use it.
|
|
692
|
+
// Rolling back would move the reference under them, so a later failure only warns.
|
|
693
|
+
await completeCommittedUpdate(plan, transaction, [`post-activation step failed: ${message}`]);
|
|
694
|
+
return 0;
|
|
695
|
+
}
|
|
657
696
|
const rollback = await plan.installer.rollback(transaction?.priorActiveReleaseId ?? null)
|
|
658
697
|
.catch(rollbackError => `rollback failed: ${errorMessage(rollbackError)}`);
|
|
659
698
|
if (transaction)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timurproko/a1",
|
|
3
|
-
"version": "0.2.7-dev.
|
|
3
|
+
"version": "0.2.7-dev.1286",
|
|
4
4
|
"description": "Standalone single-agent terminal UI on pinned Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"privateLaunchContract": "neutral-launch-v1",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@mariozechner/clipboard": "^0.3.9"
|
|
114
114
|
},
|
|
115
115
|
"publication": {
|
|
116
|
-
"source": "
|
|
117
|
-
"pullRequest":
|
|
116
|
+
"source": "81969ce6924469b3081347067fd0d0190359b132",
|
|
117
|
+
"pullRequest": 913
|
|
118
118
|
}
|
|
119
119
|
}
|