@timurproko/a1 0.2.7-dev.1275 → 0.2.7-dev.1285
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/apps-dialog.d.ts +1 -1
- package/dist/app/session-shell/apps-dialog.js +2 -1
- package/dist/app/session-shell/dialog-coordinator.d.ts +4 -5
- package/dist/app/session-shell/dialog-coordinator.js +41 -12
- package/dist/app/session-shell/input-route.d.ts +23 -0
- package/dist/app/session-shell/input-route.js +59 -0
- package/dist/app/session-shell/session-selectors.d.ts +3 -3
- package/dist/app/session-shell/session-selectors.js +7 -2
- package/dist/app/session-shell/session-shell-root.d.ts +4 -68
- package/dist/app/session-shell/session-shell-root.js +6 -2
- package/dist/app/session-shell/session-shell.js +9 -13
- 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/viewport-pointer.js +5 -1
- package/dist/app/session-shell/workflow-router.d.ts +4 -2
- 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/composition/settings-route-host.js +1 -0
- 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/owned-ui/shell-components.d.ts +10 -0
- package/dist/contracts/presentation/component.d.ts +7 -0
- package/dist/contracts/presentation/index.d.ts +3 -0
- package/dist/contracts/presentation/index.js +4 -0
- package/dist/contracts/presentation/progress-palette.generated.d.ts +25 -1
- package/dist/contracts/presentation/progress-palette.generated.js +2 -135
- 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/project-trust-dialog.d.ts +1 -1
- package/dist/features/owned-ui/project-trust-dialog.js +5 -5
- package/dist/features/owned-ui/project-trust-prompt.js +2 -2
- package/dist/features/owned-ui/reference-screen-app.d.ts +2 -0
- package/dist/features/owned-ui/reference-screen-app.js +12 -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 +20 -13
- package/dist/features/owned-ui/settings-model-dialog.js +3 -3
- 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/release-gc.js +20 -5
- package/dist/foundation/release/release-state.d.ts +12 -3
- package/dist/foundation/release/release-state.js +55 -24
- package/dist/foundation/release/release-use.d.ts +10 -0
- package/dist/foundation/release/release-use.js +70 -0
- package/dist/foundation/release/update-activation.js +2 -2
- package/dist/foundation/release/update-recovery.js +2 -2
- package/dist/foundation/release/update.js +1 -1
- 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/deferred-selector.d.ts +6 -0
- package/dist/integrations/pi/components/deferred-selector.js +40 -0
- package/dist/integrations/pi/components/login-dialog-loader.d.ts +3 -0
- package/dist/integrations/pi/components/login-dialog-loader.js +57 -0
- package/dist/integrations/pi/components/login-links.d.ts +6 -0
- package/dist/integrations/pi/components/login-links.js +31 -0
- package/dist/integrations/pi/components/login-text.d.ts +7 -0
- package/dist/integrations/pi/components/login-text.js +17 -0
- package/dist/integrations/pi/components/modal-frame.d.ts +5 -0
- package/dist/integrations/pi/components/modal-frame.js +30 -2
- 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-selectors-dialogs.d.ts +11 -8
- package/dist/integrations/pi/components/shell-selectors-dialogs.js +36 -35
- package/dist/integrations/pi/components/shell-session-info-reference.d.ts +1 -1
- package/dist/integrations/pi/components/shell-session-info-reference.js +55 -49
- package/dist/integrations/pi/components/theme.d.ts +2 -0
- package/dist/integrations/pi/components/theme.js +4 -0
- 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 +16 -0
- package/dist/integrations/pi/components/upstream/components/auth-url.js +49 -0
- 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.d.ts +1 -0
- package/dist/integrations/pi/components/upstream/components/extension-selector.js +22 -11
- package/dist/integrations/pi/components/upstream/components/login-dialog.d.ts +3 -0
- package/dist/integrations/pi/components/upstream/components/login-dialog.js +216 -0
- package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
- package/dist/integrations/pi/components/upstream/components/oauth-selector.d.ts +19 -0
- package/dist/integrations/pi/components/upstream/components/oauth-selector.js +123 -0
- 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 +22 -13
- 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.d.ts +2 -0
- package/dist/integrations/pi/components/upstream/components/trust-selector.js +30 -13
- 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 +12 -10
- package/dist/integrations/pi/session-presenters/presenters.d.ts +3 -1
- package/dist/integrations/pi/session-presenters/presenters.js +2 -2
- 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/list-block.d.ts +2 -2
- package/dist/ui/components/list-block.js +4 -3
- 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/dist/ui/settings/early-accent.d.ts +24 -0
- 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
|
@@ -4,6 +4,7 @@ import { deliverExitNotice, restoreAfterOwnedExit } from "../terminal-cleanup/in
|
|
|
4
4
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
6
|
import { selectOrdinaryLaunchReleaseId } from "./ordinary-launch-selection.js";
|
|
7
|
+
import { acquireReleaseUse } from "./release-use.js";
|
|
7
8
|
import { ReleaseStateStore } from "./release-state.js";
|
|
8
9
|
import { assertLaunchProfileId, resolveProductPaths, sessionSelectionArguments } from "../lifecycle/index.js";
|
|
9
10
|
import { UpdateTransactionStore } from "./update-transaction.js";
|
|
@@ -31,7 +32,7 @@ async function runBootstrapAttempt(options, retriesRemaining) {
|
|
|
31
32
|
const paths = resolveProductPaths(environment);
|
|
32
33
|
await mkdir(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
33
34
|
const launchSelected = async (release) => {
|
|
34
|
-
const result = await launchUi(release, environment, sessionArgs, paths.runtimeDir);
|
|
35
|
+
const result = await launchUi(release, environment, sessionArgs, paths.runtimeDir, paths.dataDir);
|
|
35
36
|
if (!result.releaseSuperseded)
|
|
36
37
|
return result.code;
|
|
37
38
|
if (retriesRemaining > 0)
|
|
@@ -205,7 +206,16 @@ export async function recordParentCertifiedRelease(release, dataDir) {
|
|
|
205
206
|
* Run the selected release's UI as the one attached child. The launcher still owns the terminal,
|
|
206
207
|
* so when the child ends uncleanly it restores the terminal and prints any notice the UI left armed.
|
|
207
208
|
*/
|
|
208
|
-
async function launchUi(release, environment, sessionArgs, runtimeDir) {
|
|
209
|
+
async function launchUi(release, environment, sessionArgs, runtimeDir, dataDir) {
|
|
210
|
+
const use = await acquireReleaseUse(dataDir, release.releaseId);
|
|
211
|
+
try {
|
|
212
|
+
return await launchClaimedUi(release, environment, sessionArgs, runtimeDir, use);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await use.release().catch(() => undefined);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function launchClaimedUi(release, environment, sessionArgs, runtimeDir, use) {
|
|
209
219
|
await markStartupPhase(environment, "bootstrap-selected");
|
|
210
220
|
const entry = await resolveReleaseEntryPoint(release, "bin/ui.js");
|
|
211
221
|
const ownsTerminal = readLaunchContext(environment).launchProfile === "a1";
|
|
@@ -223,13 +233,19 @@ async function launchUi(release, environment, sessionArgs, runtimeDir) {
|
|
|
223
233
|
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
224
234
|
windowsHide: false,
|
|
225
235
|
});
|
|
236
|
+
let handoff = Promise.resolve();
|
|
237
|
+
child.once("spawn", () => {
|
|
238
|
+
// Security: failure keeps the durable launching reservation rather than guessing the child dead.
|
|
239
|
+
handoff = use.started(child.pid).catch(() => undefined);
|
|
240
|
+
});
|
|
226
241
|
child.on("message", message => {
|
|
227
242
|
if (message && typeof message === "object" && "type" in message && message.type === RELEASE_RESELECTION_MESSAGE) {
|
|
228
243
|
releaseSuperseded = true;
|
|
229
244
|
}
|
|
230
245
|
});
|
|
231
246
|
child.once("error", rejectPromise);
|
|
232
|
-
child.once("close", (code, signal) => {
|
|
247
|
+
child.once("close", async (code, signal) => {
|
|
248
|
+
await handoff;
|
|
233
249
|
const outcome = releaseSuperseded ? code ?? 1 : restoreAfterOwnedExit(ownsTerminal, code, signal);
|
|
234
250
|
// Invariant: the UI removes its notice on a clean exit; one still armed means the UI died.
|
|
235
251
|
if (ownsTerminal)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
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, } from "./release-state.js";
|
|
6
|
+
import { ReleaseStateStore, releaseUseIsLive, } 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";
|
|
@@ -233,7 +233,8 @@ async function collectDisposition(store, dataDir, disposition, options) {
|
|
|
233
233
|
try {
|
|
234
234
|
const inputs = await protectionInputs(options.transactionStore ?? new UpdateTransactionStore(dataDir), options);
|
|
235
235
|
const current = await store.read();
|
|
236
|
-
if (isProtectedDetachedRelease(current, inputs, releaseId)
|
|
236
|
+
if (isProtectedDetachedRelease(current, inputs, releaseId)
|
|
237
|
+
|| (disposition.release.approval === "approved" && disposition.release.lifetimeClaims !== true))
|
|
237
238
|
return false;
|
|
238
239
|
const storeRoot = await canonicalReleaseStore(dataDir);
|
|
239
240
|
const trashRoot = await canonicalTrashRoot(storeRoot);
|
|
@@ -252,7 +253,19 @@ async function collectDisposition(store, dataDir, disposition, options) {
|
|
|
252
253
|
else {
|
|
253
254
|
await assertReleaseDirectory(source, storeRoot, disposition.release, releaseId);
|
|
254
255
|
trashPath = resolve(trashRoot, `${releaseId}--${randomUUID()}`);
|
|
255
|
-
|
|
256
|
+
let moved = false;
|
|
257
|
+
const destination = trashPath;
|
|
258
|
+
// Concurrency: claim admission and destructive rename share the state gate. A launch recorded
|
|
259
|
+
// after discovery cannot slip between the last protection read and deletion.
|
|
260
|
+
await store.update(async (latest) => {
|
|
261
|
+
if (!isProtectedDetachedRelease(latest, inputs, releaseId)) {
|
|
262
|
+
await (options.operations?.rename ?? rename)(source, destination);
|
|
263
|
+
moved = true;
|
|
264
|
+
}
|
|
265
|
+
return latest;
|
|
266
|
+
});
|
|
267
|
+
if (!moved)
|
|
268
|
+
return false;
|
|
256
269
|
}
|
|
257
270
|
await store.markCleanupTrash(releaseId, trashPath);
|
|
258
271
|
}
|
|
@@ -274,6 +287,7 @@ function isProtectedDetachedRelease(state, inputs, releaseId) {
|
|
|
274
287
|
.some(reference => reference === releaseId);
|
|
275
288
|
// Concurrency: a launch may have recorded the same release again after it was detached.
|
|
276
289
|
return selected
|
|
290
|
+
|| Object.values(state.uses ?? {}).some(claim => claim.releaseId === releaseId && releaseUseIsLive(claim))
|
|
277
291
|
|| state.releases[releaseId] !== undefined
|
|
278
292
|
|| (inputs.ownReleaseIds ?? []).includes(releaseId)
|
|
279
293
|
|| (inputs.transaction?.status === "active" && inputs.transaction.priorActiveReleaseId === releaseId);
|
|
@@ -392,6 +406,7 @@ async function readReleaseRecord(path, parent, expectedName, dataDir) {
|
|
|
392
406
|
releaseRoot: resolve(parent, manifest.releaseId),
|
|
393
407
|
packageVersion: manifest.packageVersion,
|
|
394
408
|
contentDigest: manifest.contentDigest,
|
|
409
|
+
...(Array.isArray(manifest.files) && manifest.files.some(file => file?.path === "dist/foundation/release/release-use.js") ? { lifetimeClaims: true } : {}),
|
|
395
410
|
approval: "approved",
|
|
396
411
|
materializedAt: new Date(0).toISOString(),
|
|
397
412
|
certifiedAt: null,
|
|
@@ -661,7 +676,7 @@ function sortArtifacts(artifacts, state) {
|
|
|
661
676
|
|| artifactName(left).localeCompare(artifactName(right)));
|
|
662
677
|
}
|
|
663
678
|
async function spawnCleanupWorker(entry, environment) {
|
|
664
|
-
const child =
|
|
679
|
+
const child = await spawnReleaseEntry(entry, [], {
|
|
665
680
|
detached: true,
|
|
666
681
|
windowsHide: true,
|
|
667
682
|
stdio: "ignore",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MaterializedRelease } from "./release-store.js";
|
|
2
2
|
export declare const RELEASE_STATE_SCHEMA: string;
|
|
3
|
-
/**
|
|
3
|
+
/** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
|
|
4
4
|
export declare const RELEASE_RETENTION_MS: number;
|
|
5
5
|
export type ReleaseApproval = "candidate" | "approved" | "rejected";
|
|
6
6
|
export interface ReleaseRecord {
|
|
@@ -9,6 +9,8 @@ export interface ReleaseRecord {
|
|
|
9
9
|
readonly packageVersion: string;
|
|
10
10
|
readonly contentDigest: string;
|
|
11
11
|
readonly launchContract?: string;
|
|
12
|
+
/** Only binaries implementing launch claims may become age-collectible. */
|
|
13
|
+
readonly lifetimeClaims?: true;
|
|
12
14
|
readonly approval: ReleaseApproval;
|
|
13
15
|
readonly materializedAt: string;
|
|
14
16
|
/** When the release was approved; retention measures its age from this instant. */
|
|
@@ -66,12 +68,19 @@ export interface ReleaseCleanupState {
|
|
|
66
68
|
readonly diagnostics: readonly ReleaseCleanupDiagnostic[];
|
|
67
69
|
readonly workerRuns: readonly ReleaseCleanupWorkerRun[];
|
|
68
70
|
}
|
|
71
|
+
export interface ReleaseUseClaim {
|
|
72
|
+
readonly releaseId: string;
|
|
73
|
+
readonly pid: number;
|
|
74
|
+
/** An interrupted handoff is uncertain and must remain protected. */
|
|
75
|
+
readonly phase: "launching" | "running";
|
|
76
|
+
}
|
|
69
77
|
export interface ReleaseState {
|
|
70
78
|
readonly schema: typeof RELEASE_STATE_SCHEMA;
|
|
71
79
|
readonly revision: number;
|
|
72
80
|
readonly releases: Readonly<Record<string, ReleaseRecord>>;
|
|
73
81
|
readonly references: ReleaseReferences;
|
|
74
82
|
readonly cleanup: ReleaseCleanupState;
|
|
83
|
+
readonly uses?: Readonly<Record<string, ReleaseUseClaim>>;
|
|
75
84
|
}
|
|
76
85
|
export interface ProtectedReleaseInputs {
|
|
77
86
|
/** Releases the reconciling command itself runs from; they are never collected under it. */
|
|
@@ -101,8 +110,7 @@ export interface ReleaseRetentionReconciliation {
|
|
|
101
110
|
* Age-based retention. A release stays while a selector names it (active, its rollback, or a
|
|
102
111
|
* candidate still being certified), while an unfinished update may return to it, while the
|
|
103
112
|
* 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.
|
|
113
|
+
* Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
|
|
106
114
|
*/
|
|
107
115
|
export declare function planProtectedReleases(state: ReleaseState, inputs?: ProtectedReleaseInputs): ProtectedReleasePlan;
|
|
108
116
|
/**
|
|
@@ -134,3 +142,4 @@ export declare class ReleaseStateStore {
|
|
|
134
142
|
recordCleanupWorkerFinished(runId: string, status: Exclude<ReleaseCleanupWorkerStatus, "scheduled" | "running">, summary: ReleaseCleanupWorkerSummary, error?: unknown): Promise<ReleaseState>;
|
|
135
143
|
}
|
|
136
144
|
export declare function emptyState(): ReleaseState;
|
|
145
|
+
export declare function releaseUseIsLive(claim: ReleaseUseClaim): boolean;
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, rename, rm
|
|
2
|
+
import { mkdir, open, 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
6
|
export const RELEASE_STATE_SCHEMA = PRODUCT_IDENTITY.protocol.releaseCohortSchema;
|
|
7
7
|
const RELEASE_ID_PATTERN = /^[0-9A-Za-z.+_-]+-[a-f0-9]{20}$/;
|
|
8
|
-
/**
|
|
8
|
+
/** Minimum age retention; live, uncertain, and legacy ownership independently overrides age. */
|
|
9
9
|
export const RELEASE_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
10
10
|
/**
|
|
11
11
|
* Age-based retention. A release stays while a selector names it (active, its rollback, or a
|
|
12
12
|
* candidate still being certified), while an unfinished update may return to it, while the
|
|
13
13
|
* 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.
|
|
14
|
+
* Process-lifetime claims override age; legacy approved binaries remain conservatively retained.
|
|
16
15
|
*/
|
|
17
16
|
export function planProtectedReleases(state, inputs = {}) {
|
|
18
17
|
const protectedIds = new Set();
|
|
18
|
+
for (const claim of Object.values(state.uses ?? {}))
|
|
19
|
+
if (releaseUseIsLive(claim))
|
|
20
|
+
protectedIds.add(claim.releaseId);
|
|
19
21
|
for (const id of [state.references.active, state.references.pending, state.references.approved, state.references.rollback]) {
|
|
20
22
|
if (id !== null)
|
|
21
23
|
protectedIds.add(id);
|
|
@@ -28,7 +30,7 @@ export function planProtectedReleases(state, inputs = {}) {
|
|
|
28
30
|
const now = inputs.now ?? Date.now();
|
|
29
31
|
const window = inputs.retentionMs ?? RELEASE_RETENTION_MS;
|
|
30
32
|
for (const release of Object.values(state.releases)) {
|
|
31
|
-
if (withinRetention(release, now, window))
|
|
33
|
+
if (withinRetention(release, now, window) || (release.approval === "approved" && release.lifetimeClaims !== true))
|
|
32
34
|
protectedIds.add(release.releaseId);
|
|
33
35
|
}
|
|
34
36
|
const protectedReleaseIds = [...protectedIds].sort();
|
|
@@ -104,6 +106,7 @@ export class ReleaseStateStore {
|
|
|
104
106
|
packageVersion: release.packageVersion,
|
|
105
107
|
contentDigest: release.contentDigest,
|
|
106
108
|
...(release.launchContract === undefined ? {} : { launchContract: release.launchContract }),
|
|
109
|
+
...(release.files.some(file => file.path === "dist/foundation/release/release-use.js") ? { lifetimeClaims: true } : {}),
|
|
107
110
|
approval: "candidate",
|
|
108
111
|
materializedAt: new Date().toISOString(),
|
|
109
112
|
certifiedAt: null,
|
|
@@ -363,7 +366,7 @@ async function acquireLock(path) {
|
|
|
363
366
|
while (true) {
|
|
364
367
|
try {
|
|
365
368
|
const lock = await open(path, "wx", 0o600);
|
|
366
|
-
await lock.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
|
|
369
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, token: randomUUID(), createdAt: new Date().toISOString() }));
|
|
367
370
|
await lock.sync();
|
|
368
371
|
return lock;
|
|
369
372
|
}
|
|
@@ -382,31 +385,35 @@ async function acquireLock(path) {
|
|
|
382
385
|
}
|
|
383
386
|
}
|
|
384
387
|
async function reclaimAbandonedLock(path) {
|
|
385
|
-
let
|
|
388
|
+
let owner;
|
|
386
389
|
try {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
390
|
+
owner = await readFile(path, "utf8");
|
|
391
|
+
const value = JSON.parse(owner);
|
|
392
|
+
if (!Number.isSafeInteger(value.pid) || value.pid <= 0)
|
|
393
|
+
return false;
|
|
394
|
+
try {
|
|
395
|
+
process.kill(value.pid, 0);
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
if (!(error instanceof Error && "code" in error && error.code === "ESRCH"))
|
|
400
|
+
return false;
|
|
395
401
|
}
|
|
396
402
|
}
|
|
397
403
|
catch {
|
|
398
|
-
|
|
399
|
-
|
|
404
|
+
return false;
|
|
405
|
+
} // Security: unknown ownership never becomes safe merely through age.
|
|
406
|
+
const gate = `${path}.reclaim`;
|
|
407
|
+
try {
|
|
408
|
+
await mkdir(gate);
|
|
400
409
|
}
|
|
401
|
-
|
|
410
|
+
catch {
|
|
402
411
|
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 });
|
|
412
|
+
}
|
|
407
413
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
414
|
+
if (await readFile(path, "utf8") !== owner)
|
|
415
|
+
return false;
|
|
416
|
+
const quarantine = `${path}.abandoned-${randomUUID()}`;
|
|
410
417
|
await rename(path, quarantine);
|
|
411
418
|
await rm(quarantine, { force: true });
|
|
412
419
|
return true;
|
|
@@ -414,6 +421,9 @@ async function reclaimAbandonedLock(path) {
|
|
|
414
421
|
catch {
|
|
415
422
|
return false;
|
|
416
423
|
}
|
|
424
|
+
finally {
|
|
425
|
+
await rm(gate, { recursive: true, force: true });
|
|
426
|
+
}
|
|
417
427
|
}
|
|
418
428
|
function normalizeState(value) {
|
|
419
429
|
if (!value || typeof value !== "object")
|
|
@@ -424,6 +434,8 @@ function normalizeState(value) {
|
|
|
424
434
|
const cleanup = state.cleanup;
|
|
425
435
|
return {
|
|
426
436
|
...state,
|
|
437
|
+
// Compatibility: one-way control-state upgrade fences pre-claim cleanup workers; session files are untouched.
|
|
438
|
+
...(state.schema === "a1-release-cohort-v1" ? { schema: RELEASE_STATE_SCHEMA } : {}),
|
|
427
439
|
cleanup: cleanup
|
|
428
440
|
? { ...cleanup, workerRuns: cleanup.workerRuns ?? [] }
|
|
429
441
|
: { pending: {}, diagnostics: [], workerRuns: [] },
|
|
@@ -436,6 +448,13 @@ function validateState(state) {
|
|
|
436
448
|
|| !Array.isArray(state.cleanup.workerRuns)) {
|
|
437
449
|
throw new Error("invalid release state");
|
|
438
450
|
}
|
|
451
|
+
if (state.uses !== undefined && (state.uses === null || typeof state.uses !== "object" || Array.isArray(state.uses)))
|
|
452
|
+
throw new Error("invalid release use claims");
|
|
453
|
+
for (const [token, claim] of Object.entries(state.uses ?? {})) {
|
|
454
|
+
if (!/^[a-f0-9-]{36}$/.test(token) || !claim || !RELEASE_ID_PATTERN.test(claim.releaseId)
|
|
455
|
+
|| !Number.isSafeInteger(claim.pid) || claim.pid <= 0 || !["launching", "running"].includes(claim.phase))
|
|
456
|
+
throw new Error("invalid release use claim");
|
|
457
|
+
}
|
|
439
458
|
for (const run of state.cleanup.workerRuns) {
|
|
440
459
|
if (!run || typeof run.runId !== "string" || run.runId.length === 0
|
|
441
460
|
|| !["scheduled", "running", "completed", "blocked", "continued", "failed", "skipped"].includes(run.status)
|
|
@@ -473,6 +492,18 @@ function validateTransition(current, next) {
|
|
|
473
492
|
if (next.revision !== current.revision)
|
|
474
493
|
throw new Error("release revision is controlled by the state store");
|
|
475
494
|
}
|
|
495
|
+
export function releaseUseIsLive(claim) {
|
|
496
|
+
if (claim.phase === "launching")
|
|
497
|
+
return true;
|
|
498
|
+
// Security: PID reuse only over-retains: no live/uncertain process identity ever proves a claim dead.
|
|
499
|
+
try {
|
|
500
|
+
process.kill(claim.pid, 0);
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
476
507
|
function boundedError(error) {
|
|
477
508
|
return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 1_000);
|
|
478
509
|
}
|
|
@@ -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,70 @@
|
|
|
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, 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
|
+
return acquireReleaseUse(dataDir, basename(root));
|
|
15
|
+
}
|
|
16
|
+
export async function spawnReleaseEntry(entry, args, options) {
|
|
17
|
+
const use = await claimReleaseEntry(entry, options.env ?? process.env);
|
|
18
|
+
try {
|
|
19
|
+
const child = spawn(process.execPath, [entry, ...args], options);
|
|
20
|
+
const releaseSettled = use?.track(child) ?? Promise.resolve();
|
|
21
|
+
// Concurrency: detached callers need no waiter; awaited callers still observe release failures.
|
|
22
|
+
void releaseSettled.catch(() => undefined);
|
|
23
|
+
return Object.assign(child, { releaseSettled });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
await use?.release();
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** A durable launch reservation closes selection/collection and spawn/handoff races. */
|
|
31
|
+
export async function acquireReleaseUse(dataDir, releaseId) {
|
|
32
|
+
const store = new ReleaseStateStore(dataDir);
|
|
33
|
+
const token = randomUUID();
|
|
34
|
+
await store.update(state => {
|
|
35
|
+
if (!state.releases[releaseId])
|
|
36
|
+
throw new Error("Selected release is no longer available for launch");
|
|
37
|
+
const uses = Object.fromEntries(Object.entries(state.uses ?? {}).filter(([, claim]) => releaseUseIsLive(claim)));
|
|
38
|
+
return { ...state, uses: { ...uses, [token]: { releaseId, pid: process.pid, phase: "launching" } } };
|
|
39
|
+
});
|
|
40
|
+
let released = false;
|
|
41
|
+
return {
|
|
42
|
+
track(child) {
|
|
43
|
+
let handoff = Promise.resolve();
|
|
44
|
+
child.once("spawn", () => { handoff = this.started(child.pid).catch(() => undefined); });
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
child.once("close", () => { void handoff.then(() => this.release()).then(resolve, reject); });
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
async started(pid) {
|
|
50
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || released)
|
|
51
|
+
throw new Error("Invalid release process handoff");
|
|
52
|
+
await store.update(state => {
|
|
53
|
+
if (!state.uses?.[token])
|
|
54
|
+
throw new Error("Release launch reservation was lost");
|
|
55
|
+
const claim = { releaseId, pid, phase: "running" };
|
|
56
|
+
return { ...state, uses: { ...state.uses, [token]: claim } };
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
async release() {
|
|
60
|
+
if (released)
|
|
61
|
+
return;
|
|
62
|
+
await store.update(state => {
|
|
63
|
+
const uses = { ...state.uses };
|
|
64
|
+
delete uses[token];
|
|
65
|
+
return { ...state, uses };
|
|
66
|
+
});
|
|
67
|
+
released = true;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -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";
|
|
@@ -77,7 +77,7 @@ export async function readActivationContracts(packageRoot, read = path => readFi
|
|
|
77
77
|
*/
|
|
78
78
|
export async function delegateActivation(request, callbacks) {
|
|
79
79
|
const entry = resolve(request.packageRoot, UPDATE_ACTIVATION_ENTRY);
|
|
80
|
-
const child =
|
|
80
|
+
const child = await spawnReleaseEntry(entry, ["--data-dir", request.dataDir, "--target-version", request.targetVersion], {
|
|
81
81
|
env: request.environment,
|
|
82
82
|
stdio: ["ignore", "pipe", "pipe"],
|
|
83
83
|
windowsHide: true,
|
|
@@ -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);
|
|
@@ -164,7 +164,7 @@ export function renderUpdateProgressBar(percent, accent = SHARED_PROGRESS_ACCENT
|
|
|
164
164
|
// percentage retain the update meter's neutral treatment.
|
|
165
165
|
const gray = "\u001b[38;2;128;128;128m";
|
|
166
166
|
const track = "\u001b[38;2;102;102;102m";
|
|
167
|
-
return `${accent}${"━".repeat(filled)}${track}${"
|
|
167
|
+
return `${accent}${"━".repeat(filled)}${track}${"━".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
|
|
168
168
|
}
|
|
169
169
|
function updateProgressAccent(environment) {
|
|
170
170
|
return earlyAccentPalette(resolveEarlyAccentColor(environment), earlyTerminalAppearance(environment), earlyTerminalColorMode(environment)).accent;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnReleaseEntry } from "./release-use.js";
|
|
2
2
|
import { resolveReleaseEntryPoint } from "./release-store.js";
|
|
3
3
|
import { releaseEnvironment } from "./bootstrap.js";
|
|
4
4
|
import { launchContractTarget, launchEnvironmentKeys } from "../launch-context/index.js";
|
|
@@ -11,15 +11,13 @@ function warmupDiagnostics(text) {
|
|
|
11
11
|
/** Import the exact immutable startup graph in a terminal-free bounded child process. */
|
|
12
12
|
export async function warmMaterializedRelease(release, environment, timeoutMs = 30_000) {
|
|
13
13
|
const entry = await resolveReleaseEntryPoint(release, "bin/warmup.js");
|
|
14
|
+
const child = await spawnReleaseEntry(entry, [], {
|
|
15
|
+
env: { ...releaseEnvironment(environment, release), [launchEnvironmentKeys(launchContractTarget(release)).immutableWarmup]: "1" },
|
|
16
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
});
|
|
14
19
|
await new Promise((resolvePromise, rejectPromise) => {
|
|
15
|
-
// Rationale:
|
|
16
|
-
// update reporting only an exit status. Its own diagnostics are read and bounded so the
|
|
17
|
-
// transaction journal records why the graph could not be imported.
|
|
18
|
-
const child = spawn(process.execPath, [entry], {
|
|
19
|
-
env: { ...releaseEnvironment(environment, release), [launchEnvironmentKeys(launchContractTarget(release)).immutableWarmup]: "1" },
|
|
20
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
21
|
-
windowsHide: true,
|
|
22
|
-
});
|
|
20
|
+
// Rationale: bounded diagnostics explain graph-import failures without attaching a terminal.
|
|
23
21
|
let diagnostics = "";
|
|
24
22
|
child.stderr?.setEncoding("utf8");
|
|
25
23
|
child.stderr?.on("data", chunk => { diagnostics = `${diagnostics}${chunk}`.slice(-WARMUP_DIAGNOSTIC_LIMIT); });
|
|
@@ -44,4 +42,5 @@ export async function warmMaterializedRelease(release, environment, timeoutMs =
|
|
|
44
42
|
}, timeoutMs);
|
|
45
43
|
timer.unref?.();
|
|
46
44
|
});
|
|
45
|
+
await child.releaseSettled;
|
|
47
46
|
}
|
|
@@ -20,8 +20,8 @@ export interface SessionWriterHandle {
|
|
|
20
20
|
release(): void;
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Heartbeats diagnose ownership loss, but never authorize takeover of a live writer.
|
|
24
|
+
* Dead owners are recovered by process absence; legacy or uncertain owners stay protected.
|
|
25
25
|
*/
|
|
26
26
|
export declare const SESSION_WRITER_STALE_MS = 10000;
|
|
27
27
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync, unlinkSync, rmdirSync, utimesSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import lockfile from "proper-lockfile";
|
|
6
6
|
/** Reports that a canonical session identity already has a live writer. */
|
|
7
7
|
export class SessionWriterConflict extends Error {
|
|
@@ -15,8 +15,8 @@ export class SessionWriterConflict extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* Heartbeats diagnose ownership loss, but never authorize takeover of a live writer.
|
|
19
|
+
* Dead owners are recovered by process absence; legacy or uncertain owners stay protected.
|
|
20
20
|
*/
|
|
21
21
|
export const SESSION_WRITER_STALE_MS = 10_000;
|
|
22
22
|
let backendOverride;
|
|
@@ -35,15 +35,37 @@ export function lockfileBackend(root = join(tmpdir(), "a1-session-writer-locks")
|
|
|
35
35
|
};
|
|
36
36
|
const acquireAuthority = (path) => {
|
|
37
37
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
stale: staleMs,
|
|
43
|
-
|
|
44
|
-
// stalled one must not crash for it.
|
|
45
|
-
onCompromised: () => { },
|
|
38
|
+
// Compatibility: the nonempty owner record also prevents legacy heartbeat-only clients from reclaiming
|
|
39
|
+
// a stalled live writer: proper-lockfile removes directories non-recursively.
|
|
40
|
+
reclaimDeadWriter(path);
|
|
41
|
+
const release = lockfile.lockSync(path, {
|
|
42
|
+
realpath: false, lockfilePath: path, stale: staleMs,
|
|
43
|
+
onCompromised: () => { throw new Error("Session writer ownership was lost; execution has been stopped."); },
|
|
46
44
|
});
|
|
45
|
+
const owner = { pid: process.pid, token: randomUUID() };
|
|
46
|
+
const stamp = statSync(path).mtime;
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(join(path, "owner.json"), JSON.stringify(owner), { flag: "wx", mode: 0o600 });
|
|
49
|
+
// Concurrency: creating the owner record must not masquerade as a foreign heartbeat.
|
|
50
|
+
utimesSync(path, stamp, stamp);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(join(path, "owner.json"));
|
|
55
|
+
release();
|
|
56
|
+
}
|
|
57
|
+
catch { /* Security: retain uncertain ownership. */ }
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return () => {
|
|
61
|
+
if (readWriterOwner(path)?.token !== owner.token)
|
|
62
|
+
throw new Error("Session writer ownership was lost");
|
|
63
|
+
// Concurrency: a legacy contender must not reclaim the short empty-directory release interval.
|
|
64
|
+
const now = new Date();
|
|
65
|
+
utimesSync(path, now, now);
|
|
66
|
+
unlinkSync(join(path, "owner.json"));
|
|
67
|
+
release();
|
|
68
|
+
};
|
|
47
69
|
};
|
|
48
70
|
return {
|
|
49
71
|
acquire(requested) {
|
|
@@ -92,6 +114,54 @@ export function lockfileBackend(root = join(tmpdir(), "a1-session-writer-locks")
|
|
|
92
114
|
},
|
|
93
115
|
};
|
|
94
116
|
}
|
|
117
|
+
function readWriterOwner(path) {
|
|
118
|
+
try {
|
|
119
|
+
const source = readFileSync(join(path, "owner.json"), "utf8");
|
|
120
|
+
if (source.length > 1024)
|
|
121
|
+
return null;
|
|
122
|
+
const value = JSON.parse(source);
|
|
123
|
+
return Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.token === "string"
|
|
124
|
+
&& /^[a-f0-9-]{36}$/.test(value.token) ? value : null;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function writerAbsent(pid) {
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 0);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return error instanceof Error && "code" in error && error.code === "ESRCH";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function reclaimDeadWriter(path) {
|
|
140
|
+
if (!existsSync(path))
|
|
141
|
+
return;
|
|
142
|
+
const owner = readWriterOwner(path);
|
|
143
|
+
// Security: age and malformed/legacy records cannot prove that a writer is absent.
|
|
144
|
+
if (owner === null || !writerAbsent(owner.pid))
|
|
145
|
+
throw new Error("Session writer is live or unverifiable");
|
|
146
|
+
const gate = join(path, ".reclaim");
|
|
147
|
+
mkdirSync(gate);
|
|
148
|
+
try {
|
|
149
|
+
if (readWriterOwner(path)?.token !== owner.token || !writerAbsent(owner.pid))
|
|
150
|
+
throw new Error("Session writer changed during recovery");
|
|
151
|
+
const now = new Date();
|
|
152
|
+
utimesSync(path, now, now);
|
|
153
|
+
unlinkSync(join(path, "owner.json"));
|
|
154
|
+
rmdirSync(gate);
|
|
155
|
+
rmdirSync(path);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
try {
|
|
159
|
+
rmdirSync(gate);
|
|
160
|
+
}
|
|
161
|
+
catch { /* Security: a partial recovery remains conservatively blocked. */ }
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
95
165
|
function backend() {
|
|
96
166
|
return backendOverride ?? (cachedBackend ??= lockfileBackend());
|
|
97
167
|
}
|
|
@@ -10,7 +10,7 @@ export const STARTUP_DESCRIPTOR = Object.freeze({
|
|
|
10
10
|
],
|
|
11
11
|
"generatedArtifact": {
|
|
12
12
|
"path": "dist/integrations/pi/startup-public.js",
|
|
13
|
-
"sha256": "
|
|
13
|
+
"sha256": "c1fca0ab0fe9d078a80faac1dad7d9f719cfc553645e38c2244a9c9ad381c205"
|
|
14
14
|
},
|
|
15
15
|
"dependencyLayerIdentity": {
|
|
16
16
|
"source": "A1_RELEASE_LAYERS",
|
|
@@ -19,7 +19,7 @@ export const STARTUP_DESCRIPTOR = Object.freeze({
|
|
|
19
19
|
"compileCacheNamespace": {
|
|
20
20
|
"strategy": "node-abi-version-and-ordered-layer-sha256-v1"
|
|
21
21
|
},
|
|
22
|
-
"identity": "
|
|
22
|
+
"identity": "3a54e70a22a20fa71dc2916cff394d4b06a3359677b3423002ff5b2dfbdccd70"
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
export function loadDeclaredStartupGraph() {
|