@timurproko/a1 0.2.7-dev.1276 → 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/README.md +5 -1
- package/dist/app/session-shell/session-shell-root.d.ts +4 -68
- package/dist/app/session-shell/session-shell.js +5 -11
- package/dist/app/session-shell/shell-host.js +2 -2
- package/dist/app/session-shell/shell-options.d.ts +65 -0
- package/dist/app/session-shell/shell-options.js +1 -0
- package/dist/app/session-shell/workflow-router.d.ts +3 -1
- package/dist/app/session-shell/workflow-router.js +12 -0
- package/dist/cli/dispatch.js +27 -1
- package/dist/cli/packages.js +18 -2
- package/dist/cli/session-context.js +6 -2
- package/dist/composition/owned-ui.js +20 -8
- package/dist/composition/session-naming.js +1 -1
- package/dist/composition/session-runtime-registry.d.ts +2 -0
- package/dist/composition/session-runtime-registry.js +49 -9
- package/dist/contracts/agent-engine/package-ports.d.ts +10 -14
- package/dist/contracts/agent-engine/package-ports.js +5 -9
- package/dist/contracts/owned-ui/session-workflows.d.ts +6 -0
- package/dist/contracts/presentation/index.d.ts +1 -0
- package/dist/contracts/presentation/progress-palette.generated.d.ts +1 -1
- package/dist/contracts/presentation/progress-palette.generated.js +1 -1
- package/dist/contracts/presentation/progress-spinner.d.ts +7 -0
- package/dist/contracts/presentation/progress-spinner.js +1 -0
- package/dist/features/launch/session-context.d.ts +8 -0
- package/dist/features/launch/session-context.js +19 -10
- package/dist/features/owned-ui/reference-screen-app.js +9 -5
- package/dist/features/owned-ui/session-fork-prompt.d.ts +2 -1
- package/dist/features/owned-ui/session-fork-prompt.js +11 -5
- package/dist/features/owned-ui/settings-app.d.ts +1 -1
- package/dist/features/owned-ui/settings-app.js +14 -7
- package/dist/features/owned-ui/settings-spinner-preview.d.ts +9 -0
- package/dist/features/owned-ui/settings-spinner-preview.js +29 -0
- package/dist/features/session-naming/context.d.ts +17 -0
- package/dist/features/session-naming/context.js +39 -0
- package/dist/features/session-naming/controller.d.ts +1 -5
- package/dist/features/session-naming/controller.js +7 -4
- package/dist/features/session-naming/generate.d.ts +2 -1
- package/dist/features/session-naming/generate.js +14 -10
- package/dist/features/session-naming/index.d.ts +1 -1
- package/dist/foundation/lifecycle/index.d.ts +3 -1
- package/dist/foundation/lifecycle/index.js +3 -1
- package/dist/foundation/lifecycle/session-claim-lock.d.ts +2 -2
- package/dist/foundation/lifecycle/session-claim-lock.js +36 -10
- package/dist/foundation/lifecycle/session-context-retry.d.ts +2 -0
- package/dist/foundation/lifecycle/session-context-retry.js +17 -0
- package/dist/foundation/lifecycle/session-repository-context.d.ts +7 -1
- package/dist/foundation/lifecycle/session-repository-context.js +105 -39
- package/dist/foundation/lifecycle/session-worktree-entry.d.ts +3 -0
- package/dist/foundation/lifecycle/session-worktree-entry.js +87 -0
- package/dist/foundation/release/automatic-update.js +2 -2
- package/dist/foundation/release/bootstrap.js +19 -3
- package/dist/foundation/release/index.d.ts +1 -1
- package/dist/foundation/release/index.js +1 -1
- package/dist/foundation/release/release-gc.js +41 -5
- package/dist/foundation/release/release-state.d.ts +48 -4
- package/dist/foundation/release/release-state.js +168 -31
- package/dist/foundation/release/release-use.d.ts +10 -0
- package/dist/foundation/release/release-use.js +86 -0
- package/dist/foundation/release/update-activation.d.ts +3 -1
- package/dist/foundation/release/update-activation.js +23 -6
- package/dist/foundation/release/update-recovery.js +2 -2
- package/dist/foundation/release/update.d.ts +3 -1
- package/dist/foundation/release/update.js +61 -22
- package/dist/foundation/release/warmup.js +8 -9
- package/dist/foundation/session-writer-lock/index.d.ts +2 -2
- package/dist/foundation/session-writer-lock/index.js +82 -12
- package/dist/foundation/startup/startup-descriptor.js +2 -2
- package/dist/foundation/terminal-cleanup/fatal-diagnostic.d.ts +2 -0
- package/dist/foundation/terminal-cleanup/fatal-diagnostic.js +59 -0
- package/dist/foundation/terminal-cleanup/fatal-exit.js +1 -29
- package/dist/integrations/pi/components/resources/builtin-themes.d.ts +2 -2
- package/dist/integrations/pi/components/resources/builtin-themes.js +2 -2
- package/dist/integrations/pi/components/share-operation-dialog.d.ts +2 -1
- package/dist/integrations/pi/components/share-operation-dialog.js +13 -5
- package/dist/integrations/pi/components/shell-footer-status.d.ts +2 -1
- package/dist/integrations/pi/components/shell-footer-status.js +15 -8
- package/dist/integrations/pi/components/shell-session-info-reference.js +11 -8
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +1 -1
- package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
- package/dist/integrations/pi/components/upstream/components/auth-url.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/auth-url.js +1 -1
- package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-editor.js +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-input.js +1 -1
- package/dist/integrations/pi/components/upstream/components/extension-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/login-dialog.js +1 -1
- package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
- package/dist/integrations/pi/components/upstream/components/oauth-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/oauth-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/owned-editor.js +1 -1
- package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
- package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/session-footer.js +14 -8
- package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
- package/dist/integrations/pi/components/upstream/components/session-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +1 -1
- package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/status-indicator.js +1 -1
- package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
- package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/thinking-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/tool-execution.js +1 -1
- package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/components/tree-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
- package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
- package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/history/editor-core.js +1 -1
- package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
- package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
- package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
- package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
- package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
- package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +1 -1
- package/dist/integrations/pi/components/upstream/theme/theme.js +1 -1
- package/dist/integrations/pi/engine/adapter.d.ts +3 -1
- package/dist/integrations/pi/engine/adapter.js +24 -6
- package/dist/integrations/pi/engine/changelog.js +5 -2
- package/dist/integrations/pi/engine/delivery-retention.d.ts +7 -0
- package/dist/integrations/pi/engine/delivery-retention.js +66 -0
- package/dist/integrations/pi/engine/event-delivery.d.ts +17 -1
- package/dist/integrations/pi/engine/event-delivery.js +25 -3
- package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -1
- package/dist/integrations/pi/engine/extension-ui-binding.js +45 -0
- package/dist/integrations/pi/engine/host-ports.d.ts +2 -0
- package/dist/integrations/pi/engine/host.d.ts +3 -0
- package/dist/integrations/pi/engine/host.js +2 -0
- package/dist/integrations/pi/engine/package-integration.js +19 -4
- package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -1
- package/dist/integrations/pi/engine/pending-delivery.js +51 -18
- package/dist/integrations/pi/engine/resources/changelog.json +4 -4
- package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -0
- package/dist/integrations/pi/engine/runtime-integration.js +58 -9
- package/dist/integrations/pi/engine/session-cwd-recovery.d.ts +11 -0
- package/dist/integrations/pi/engine/session-cwd-recovery.js +18 -0
- package/dist/integrations/pi/engine/session-runtime.d.ts +3 -0
- package/dist/integrations/pi/engine/session-runtime.js +2 -0
- package/dist/integrations/pi/engine/session-selection.d.ts +3 -2
- package/dist/integrations/pi/engine/session-selection.js +25 -5
- package/dist/integrations/pi/engine/transcript-image-assets.d.ts +3 -0
- package/dist/integrations/pi/engine/transcript-image-assets.js +65 -14
- package/dist/integrations/pi/engine/view-state.d.ts +14 -0
- package/dist/integrations/pi/engine/view-state.js +17 -0
- package/dist/integrations/pi/engine/workflow-runner.d.ts +2 -0
- package/dist/integrations/pi/engine/workflow-runner.js +47 -26
- package/dist/integrations/pi/engine/worktree-tools.d.ts +4 -0
- package/dist/integrations/pi/engine/worktree-tools.js +26 -0
- package/dist/integrations/pi/session-presenters/components.d.ts +2 -1
- package/dist/integrations/pi/session-presenters/components.js +5 -3
- package/dist/integrations/pi/session-presenters/presenters.d.ts +3 -1
- package/dist/integrations/pi/session-presenters/presenters.js +1 -1
- package/dist/integrations/pi/startup-public.d.ts +1 -1
- package/dist/integrations/pi/startup-public.js +16 -20
- package/dist/integrations/pi/startup-public.manifest.json +29 -29
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +1 -1
- package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +1 -1
- package/dist/product-identity.json +1 -1
- package/dist/runtime-payload-inventory.json +5027 -5027
- package/dist/ui/components/index.d.ts +1 -1
- package/dist/ui/components/index.js +1 -1
- package/dist/ui/components/progress-status.d.ts +7 -0
- package/dist/ui/components/progress-status.js +21 -0
- package/dist/ui/components/value-menu.d.ts +1 -1
- package/dist/ui/components/value-menu.js +4 -2
- package/dist/ui/settings/declarations.d.ts +16 -4
- package/dist/ui/settings/declarations.js +13 -4
- package/docs/architecture/internal-naming.md +1 -1
- package/docs/architecture/project-structure.md +7 -11
- package/docs/features/session-exports.md +1 -1
- package/docs/features/session-naming.md +8 -6
- package/docs/features/worktrees.md +38 -0
- package/docs/local-worktree-cleanup.md +27 -4
- package/docs/openspec-archive-automation.md +30 -4
- package/docs/worktree-link-recovery.md +2 -2
- package/package.json +5 -5
|
@@ -1,6 +1,18 @@
|
|
|
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;
|
|
3
|
-
/**
|
|
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;
|
|
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";
|
|
6
18
|
export interface ReleaseRecord {
|
|
@@ -9,6 +21,8 @@ export interface ReleaseRecord {
|
|
|
9
21
|
readonly packageVersion: string;
|
|
10
22
|
readonly contentDigest: string;
|
|
11
23
|
readonly launchContract?: string;
|
|
24
|
+
/** Only binaries implementing launch claims may become age-collectible. */
|
|
25
|
+
readonly lifetimeClaims?: true;
|
|
12
26
|
readonly approval: ReleaseApproval;
|
|
13
27
|
readonly materializedAt: string;
|
|
14
28
|
/** When the release was approved; retention measures its age from this instant. */
|
|
@@ -66,12 +80,40 @@ export interface ReleaseCleanupState {
|
|
|
66
80
|
readonly diagnostics: readonly ReleaseCleanupDiagnostic[];
|
|
67
81
|
readonly workerRuns: readonly ReleaseCleanupWorkerRun[];
|
|
68
82
|
}
|
|
83
|
+
export interface ReleaseUseClaim {
|
|
84
|
+
readonly releaseId: string;
|
|
85
|
+
readonly pid: number;
|
|
86
|
+
/** An interrupted handoff is uncertain and must remain protected. */
|
|
87
|
+
readonly phase: "launching" | "running";
|
|
88
|
+
}
|
|
69
89
|
export interface ReleaseState {
|
|
70
90
|
readonly schema: typeof RELEASE_STATE_SCHEMA;
|
|
71
91
|
readonly revision: number;
|
|
72
92
|
readonly releases: Readonly<Record<string, ReleaseRecord>>;
|
|
73
93
|
readonly references: ReleaseReferences;
|
|
74
94
|
readonly cleanup: ReleaseCleanupState;
|
|
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;
|
|
75
117
|
}
|
|
76
118
|
export interface ProtectedReleaseInputs {
|
|
77
119
|
/** Releases the reconciling command itself runs from; they are never collected under it. */
|
|
@@ -101,8 +143,7 @@ export interface ReleaseRetentionReconciliation {
|
|
|
101
143
|
* Age-based retention. A release stays while a selector names it (active, its rollback, or a
|
|
102
144
|
* candidate still being certified), while an unfinished update may return to it, while the
|
|
103
145
|
* reconciling command runs from it, or while its approval is younger than the retention window.
|
|
104
|
-
*
|
|
105
|
-
* can lose its release files, which is the documented trade-off.
|
|
146
|
+
* Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
|
|
106
147
|
*/
|
|
107
148
|
export declare function planProtectedReleases(state: ReleaseState, inputs?: ProtectedReleaseInputs): ProtectedReleasePlan;
|
|
108
149
|
/**
|
|
@@ -113,8 +154,10 @@ export declare function planProtectedReleases(state: ReleaseState, inputs?: Prot
|
|
|
113
154
|
export declare function withinRetention(release: ReleaseRecord, now: number, retentionMs?: number): boolean;
|
|
114
155
|
/** Serializes validated release-state revisions under a lock and commits each update atomically. */
|
|
115
156
|
export declare class ReleaseStateStore {
|
|
157
|
+
#private;
|
|
116
158
|
readonly path: string;
|
|
117
|
-
constructor(dataDir: string);
|
|
159
|
+
constructor(dataDir: string, options?: ReleaseStateStoreOptions);
|
|
160
|
+
private renameWithRetry;
|
|
118
161
|
read(): Promise<ReleaseState>;
|
|
119
162
|
update(operation: (current: ReleaseState) => ReleaseState | Promise<ReleaseState>): Promise<ReleaseState>;
|
|
120
163
|
recordCandidate(release: MaterializedRelease): Promise<ReleaseState>;
|
|
@@ -134,3 +177,4 @@ export declare class ReleaseStateStore {
|
|
|
134
177
|
recordCleanupWorkerFinished(runId: string, status: Exclude<ReleaseCleanupWorkerStatus, "scheduled" | "running">, summary: ReleaseCleanupWorkerSummary, error?: unknown): Promise<ReleaseState>;
|
|
135
178
|
}
|
|
136
179
|
export declare function emptyState(): ReleaseState;
|
|
180
|
+
export declare function releaseUseIsLive(claim: ReleaseUseClaim): boolean;
|
|
@@ -1,21 +1,56 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, rename, rm
|
|
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
|
|
13
46
|
* reconciling command runs from it, or while its approval is younger than the retention window.
|
|
14
|
-
*
|
|
15
|
-
* can lose its release files, which is the documented trade-off.
|
|
47
|
+
* Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
|
|
16
48
|
*/
|
|
17
49
|
export function planProtectedReleases(state, inputs = {}) {
|
|
18
50
|
const protectedIds = new Set();
|
|
51
|
+
for (const claim of Object.values(state.uses ?? {}))
|
|
52
|
+
if (releaseUseIsLive(claim))
|
|
53
|
+
protectedIds.add(claim.releaseId);
|
|
19
54
|
for (const id of [state.references.active, state.references.pending, state.references.approved, state.references.rollback]) {
|
|
20
55
|
if (id !== null)
|
|
21
56
|
protectedIds.add(id);
|
|
@@ -28,7 +63,7 @@ export function planProtectedReleases(state, inputs = {}) {
|
|
|
28
63
|
const now = inputs.now ?? Date.now();
|
|
29
64
|
const window = inputs.retentionMs ?? RELEASE_RETENTION_MS;
|
|
30
65
|
for (const release of Object.values(state.releases)) {
|
|
31
|
-
if (withinRetention(release, now, window))
|
|
66
|
+
if (withinRetention(release, now, window) || (release.approval === "approved" && release.lifetimeClaims !== true))
|
|
32
67
|
protectedIds.add(release.releaseId);
|
|
33
68
|
}
|
|
34
69
|
const protectedReleaseIds = [...protectedIds].sort();
|
|
@@ -54,12 +89,37 @@ export function withinRetention(release, now, retentionMs = RELEASE_RETENTION_MS
|
|
|
54
89
|
/** Serializes validated release-state revisions under a lock and commits each update atomically. */
|
|
55
90
|
export class ReleaseStateStore {
|
|
56
91
|
path;
|
|
57
|
-
|
|
92
|
+
#rename;
|
|
93
|
+
#platform;
|
|
94
|
+
constructor(dataDir, options = {}) {
|
|
58
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
|
+
}
|
|
59
116
|
}
|
|
60
117
|
async read() {
|
|
61
118
|
try {
|
|
62
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);
|
|
63
123
|
validateState(state);
|
|
64
124
|
return state;
|
|
65
125
|
}
|
|
@@ -75,19 +135,30 @@ export class ReleaseStateStore {
|
|
|
75
135
|
const lock = await acquireLock(lockPath);
|
|
76
136
|
try {
|
|
77
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);
|
|
78
141
|
const next = await operation(current);
|
|
79
142
|
validateTransition(current, next);
|
|
80
|
-
|
|
143
|
+
// Invariant: levels only rise; an older writer never weakens what a newer one required.
|
|
144
|
+
const committed = { ...next, revision: current.revision + 1, format };
|
|
81
145
|
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
82
146
|
const file = await open(temporary, "wx", 0o600);
|
|
83
147
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
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);
|
|
86
156
|
}
|
|
87
|
-
|
|
88
|
-
await
|
|
157
|
+
catch (error) {
|
|
158
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
159
|
+
throw error;
|
|
89
160
|
}
|
|
90
|
-
await
|
|
161
|
+
await reclaimOrphanedTemporaries(dirname(this.path));
|
|
91
162
|
return committed;
|
|
92
163
|
}
|
|
93
164
|
finally {
|
|
@@ -104,6 +175,7 @@ export class ReleaseStateStore {
|
|
|
104
175
|
packageVersion: release.packageVersion,
|
|
105
176
|
contentDigest: release.contentDigest,
|
|
106
177
|
...(release.launchContract === undefined ? {} : { launchContract: release.launchContract }),
|
|
178
|
+
...(release.files.some(file => file.path === "dist/foundation/release/release-use.js") ? { lifetimeClaims: true } : {}),
|
|
107
179
|
approval: "candidate",
|
|
108
180
|
materializedAt: new Date().toISOString(),
|
|
109
181
|
certifiedAt: null,
|
|
@@ -363,7 +435,7 @@ async function acquireLock(path) {
|
|
|
363
435
|
while (true) {
|
|
364
436
|
try {
|
|
365
437
|
const lock = await open(path, "wx", 0o600);
|
|
366
|
-
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
|
|
438
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() }));
|
|
367
439
|
await lock.sync();
|
|
368
440
|
return lock;
|
|
369
441
|
}
|
|
@@ -382,31 +454,35 @@ async function acquireLock(path) {
|
|
|
382
454
|
}
|
|
383
455
|
}
|
|
384
456
|
async function reclaimAbandonedLock(path) {
|
|
385
|
-
let
|
|
457
|
+
let owner;
|
|
386
458
|
try {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
459
|
+
owner = await readFile(path, "utf8");
|
|
460
|
+
const value = JSON.parse(owner);
|
|
461
|
+
if (!Number.isSafeInteger(value.pid) || value.pid <= 0)
|
|
462
|
+
return false;
|
|
463
|
+
try {
|
|
464
|
+
process.kill(value.pid, 0);
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
if (!(error instanceof Error && "code" in error && error.code === "ESRCH"))
|
|
469
|
+
return false;
|
|
395
470
|
}
|
|
396
471
|
}
|
|
397
472
|
catch {
|
|
398
|
-
|
|
399
|
-
|
|
473
|
+
return false;
|
|
474
|
+
} // Security: unknown ownership never becomes safe merely through age.
|
|
475
|
+
const gate = `${path}.reclaim`;
|
|
476
|
+
try {
|
|
477
|
+
await mkdir(gate);
|
|
400
478
|
}
|
|
401
|
-
|
|
479
|
+
catch {
|
|
402
480
|
return false;
|
|
403
|
-
|
|
404
|
-
const oldQuarantine = await stat(quarantine).catch(() => null);
|
|
405
|
-
if (oldQuarantine && Date.now() - oldQuarantine.mtimeMs > 10_000)
|
|
406
|
-
await rm(quarantine, { force: true });
|
|
481
|
+
}
|
|
407
482
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
483
|
+
if (await readFile(path, "utf8") !== owner)
|
|
484
|
+
return false;
|
|
485
|
+
const quarantine = `${path}.abandoned-${randomUUID()}`;
|
|
410
486
|
await rename(path, quarantine);
|
|
411
487
|
await rm(quarantine, { force: true });
|
|
412
488
|
return true;
|
|
@@ -414,6 +490,46 @@ async function reclaimAbandonedLock(path) {
|
|
|
414
490
|
catch {
|
|
415
491
|
return false;
|
|
416
492
|
}
|
|
493
|
+
finally {
|
|
494
|
+
await rm(gate, { recursive: true, force: true });
|
|
495
|
+
}
|
|
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
|
+
}
|
|
417
533
|
}
|
|
418
534
|
function normalizeState(value) {
|
|
419
535
|
if (!value || typeof value !== "object")
|
|
@@ -424,6 +540,8 @@ function normalizeState(value) {
|
|
|
424
540
|
const cleanup = state.cleanup;
|
|
425
541
|
return {
|
|
426
542
|
...state,
|
|
543
|
+
// Compatibility: one-way control-state upgrade fences pre-claim cleanup workers; session files are untouched.
|
|
544
|
+
...(state.schema === LEGACY_RELEASE_STATE_SCHEMA ? { schema: RELEASE_STATE_SCHEMA } : {}),
|
|
427
545
|
cleanup: cleanup
|
|
428
546
|
? { ...cleanup, workerRuns: cleanup.workerRuns ?? [] }
|
|
429
547
|
: { pending: {}, diagnostics: [], workerRuns: [] },
|
|
@@ -436,6 +554,13 @@ function validateState(state) {
|
|
|
436
554
|
|| !Array.isArray(state.cleanup.workerRuns)) {
|
|
437
555
|
throw new Error("invalid release state");
|
|
438
556
|
}
|
|
557
|
+
if (state.uses !== undefined && (state.uses === null || typeof state.uses !== "object" || Array.isArray(state.uses)))
|
|
558
|
+
throw new Error("invalid release use claims");
|
|
559
|
+
for (const [token, claim] of Object.entries(state.uses ?? {})) {
|
|
560
|
+
if (!/^[a-f0-9-]{36}$/.test(token) || !claim || !RELEASE_ID_PATTERN.test(claim.releaseId)
|
|
561
|
+
|| !Number.isSafeInteger(claim.pid) || claim.pid <= 0 || !["launching", "running"].includes(claim.phase))
|
|
562
|
+
throw new Error("invalid release use claim");
|
|
563
|
+
}
|
|
439
564
|
for (const run of state.cleanup.workerRuns) {
|
|
440
565
|
if (!run || typeof run.runId !== "string" || run.runId.length === 0
|
|
441
566
|
|| !["scheduled", "running", "completed", "blocked", "continued", "failed", "skipped"].includes(run.status)
|
|
@@ -473,6 +598,18 @@ function validateTransition(current, next) {
|
|
|
473
598
|
if (next.revision !== current.revision)
|
|
474
599
|
throw new Error("release revision is controlled by the state store");
|
|
475
600
|
}
|
|
601
|
+
export function releaseUseIsLive(claim) {
|
|
602
|
+
if (claim.phase === "launching")
|
|
603
|
+
return true;
|
|
604
|
+
// Security: PID reuse only over-retains: no live/uncertain process identity ever proves a claim dead.
|
|
605
|
+
try {
|
|
606
|
+
process.kill(claim.pid, 0);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
476
613
|
function boundedError(error) {
|
|
477
614
|
return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 1_000);
|
|
478
615
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
2
|
+
export declare function spawnReleaseEntry(entry: string, args: readonly string[], options: SpawnOptions): Promise<ChildProcess & {
|
|
3
|
+
releaseSettled: Promise<void>;
|
|
4
|
+
}>;
|
|
5
|
+
/** A durable launch reservation closes selection/collection and spawn/handoff races. */
|
|
6
|
+
export declare function acquireReleaseUse(dataDir: string, releaseId: string): Promise<{
|
|
7
|
+
track(child: ChildProcess): Promise<void>;
|
|
8
|
+
started(pid: number): Promise<void>;
|
|
9
|
+
release(): Promise<void>;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { basename, dirname, resolve } from "node:path";
|
|
5
|
+
import { resolveProductPaths } from "../lifecycle/index.js";
|
|
6
|
+
import { ReleaseStateStore, isReleaseStateNewerError, releaseUseIsLive } from "./release-state.js";
|
|
7
|
+
/** Mutable installed entries are not GC-managed; immutable helper processes need their own claim. */
|
|
8
|
+
async function claimReleaseEntry(entry, environment) {
|
|
9
|
+
const dataDir = resolveProductPaths(environment).dataDir;
|
|
10
|
+
const root = await realpath(dirname(dirname(resolve(entry))));
|
|
11
|
+
const storeRoot = await realpath(resolve(dataDir, "releases")).catch(() => undefined);
|
|
12
|
+
if (dirname(root) !== storeRoot)
|
|
13
|
+
return undefined;
|
|
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
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function spawnReleaseEntry(entry, args, options) {
|
|
26
|
+
const use = await claimReleaseEntry(entry, options.env ?? process.env);
|
|
27
|
+
try {
|
|
28
|
+
const child = spawn(process.execPath, [entry, ...args], options);
|
|
29
|
+
const releaseSettled = use?.track(child) ?? Promise.resolve();
|
|
30
|
+
// Concurrency: detached callers need no waiter; awaited callers still observe release failures.
|
|
31
|
+
void releaseSettled.catch(() => undefined);
|
|
32
|
+
return Object.assign(child, { releaseSettled });
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
await use?.release();
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** A durable launch reservation closes selection/collection and spawn/handoff races. */
|
|
40
|
+
export async function acquireReleaseUse(dataDir, releaseId) {
|
|
41
|
+
const store = new ReleaseStateStore(dataDir);
|
|
42
|
+
const token = randomUUID();
|
|
43
|
+
await store.update(state => {
|
|
44
|
+
if (!state.releases[releaseId])
|
|
45
|
+
throw new Error("Selected release is no longer available for launch");
|
|
46
|
+
const uses = Object.fromEntries(Object.entries(state.uses ?? {}).filter(([, claim]) => releaseUseIsLive(claim)));
|
|
47
|
+
return { ...state, uses: { ...uses, [token]: { releaseId, pid: process.pid, phase: "launching" } } };
|
|
48
|
+
});
|
|
49
|
+
let released = false;
|
|
50
|
+
return {
|
|
51
|
+
track(child) {
|
|
52
|
+
let handoff = Promise.resolve();
|
|
53
|
+
child.once("spawn", () => { handoff = this.started(child.pid).catch(() => undefined); });
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
child.once("close", () => { void handoff.then(() => this.release()).then(resolve, reject); });
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
async started(pid) {
|
|
59
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || released)
|
|
60
|
+
throw new Error("Invalid release process handoff");
|
|
61
|
+
await store.update(state => {
|
|
62
|
+
if (!state.uses?.[token])
|
|
63
|
+
throw new Error("Release launch reservation was lost");
|
|
64
|
+
const claim = { releaseId, pid, phase: "running" };
|
|
65
|
+
return { ...state, uses: { ...state.uses, [token]: claim } };
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
async release() {
|
|
69
|
+
if (released)
|
|
70
|
+
return;
|
|
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
|
+
}
|
|
83
|
+
released = true;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -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>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnReleaseEntry } from "./release-use.js";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -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";
|
|
@@ -77,7 +78,7 @@ export async function readActivationContracts(packageRoot, read = path => readFi
|
|
|
77
78
|
*/
|
|
78
79
|
export async function delegateActivation(request, callbacks) {
|
|
79
80
|
const entry = resolve(request.packageRoot, UPDATE_ACTIVATION_ENTRY);
|
|
80
|
-
const child =
|
|
81
|
+
const child = await spawnReleaseEntry(entry, ["--data-dir", request.dataDir, "--target-version", request.targetVersion], {
|
|
81
82
|
env: request.environment,
|
|
82
83
|
stdio: ["ignore", "pipe", "pipe"],
|
|
83
84
|
windowsHide: true,
|
|
@@ -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) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { spawnReleaseEntry } from "./release-use.js";
|
|
3
3
|
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -303,7 +303,7 @@ export async function cleanupUpdateRecoveryCapsules(dataDir) {
|
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
305
|
async function spawnRecoveryWorker(entry, manifestPath, environment) {
|
|
306
|
-
const child =
|
|
306
|
+
const child = await spawnReleaseEntry(entry, ["--worker", manifestPath], { detached: true, stdio: "ignore", windowsHide: true, env: environment });
|
|
307
307
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
308
308
|
child.once("spawn", resolvePromise);
|
|
309
309
|
child.once("error", rejectPromise);
|
|
@@ -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>;
|