@north-light/crouter 0.3.193 → 0.3.195
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/dto/bash-jobs.d.ts +2 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/clients/attach/viewer.js +377 -377
- package/dist/commands/node/bash.js +3 -2
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +24 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -0
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.js +9 -9
- package/dist/pages/comments.d.ts +9 -0
- package/dist/pages/comments.js +53 -0
- package/dist/pages/elements/cards.js +19 -2
- package/dist/pages/elements/options.js +27 -2
- package/dist/pages/elements/table.js +31 -2
- package/dist/pages/elements/text.js +19 -2
- package/dist/pages/host.d.ts +26 -0
- package/dist/pages/host.js +33 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/install-runtime.mjs +172 -5
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { registerElement } from '../register.js';
|
|
20
20
|
import { readSlotConfig, readSlotId } from '../slot-config.js';
|
|
21
|
-
import { artifactData, respond } from '../host.js';
|
|
22
|
-
import { anchorLabel, findComment, setComment } from '../comments.js';
|
|
21
|
+
import { artifactData, respond, savedIds, savedResponse } from '../host.js';
|
|
22
|
+
import { adoptComments, anchorLabel, findComment, setComment } from '../comments.js';
|
|
23
23
|
const COMMENT_BUBBLE = 'M3.25 3.5h9.5A1.75 1.75 0 0 1 14.5 5.25v5A1.75 1.75 0 0 1 12.75 12H7.4l-3.15 2.36A.5.5 0 0 1 3.5 14v-2H3.25A1.75 1.75 0 0 1 1.5 10.25v-5A1.75 1.75 0 0 1 3.25 3.5Z';
|
|
24
24
|
const STYLE = `
|
|
25
25
|
:host {
|
|
@@ -421,6 +421,7 @@ class CrtrTableElement extends HTMLElement {
|
|
|
421
421
|
return;
|
|
422
422
|
}
|
|
423
423
|
this.#rows = config.rows;
|
|
424
|
+
this.#hydrate();
|
|
424
425
|
this.#renderTable();
|
|
425
426
|
}
|
|
426
427
|
disconnectedCallback() {
|
|
@@ -440,8 +441,36 @@ class CrtrTableElement extends HTMLElement {
|
|
|
440
441
|
return;
|
|
441
442
|
}
|
|
442
443
|
this.#rows = rows.rows;
|
|
444
|
+
this.#hydrate();
|
|
443
445
|
this.#renderTable();
|
|
444
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Seed local state from the answer the host is already holding, so a reopened page shows
|
|
449
|
+
* the rows and columns that were picked. It runs once the rows are known — after a `source`
|
|
450
|
+
* load, not before — because a saved id is only honoured while it still names something on
|
|
451
|
+
* screen. The select modes are re-imposed for the same reason.
|
|
452
|
+
*/
|
|
453
|
+
#hydrate() {
|
|
454
|
+
if (this.#slotId === undefined)
|
|
455
|
+
return;
|
|
456
|
+
const saved = savedResponse(this.#slotId);
|
|
457
|
+
if (saved === undefined)
|
|
458
|
+
return;
|
|
459
|
+
const rows = this.#orderRows(savedIds(saved.selectedRowIds));
|
|
460
|
+
const columns = this.#orderColumns(savedIds(saved.selectedColumnIds));
|
|
461
|
+
this.#selectedRowIds = this.#rowSelect === 'none' ? [] : this.#rowSelect === 'single' ? rows.slice(0, 1) : rows;
|
|
462
|
+
this.#selectedColumnIds =
|
|
463
|
+
this.#columnSelect === 'none' ? [] : this.#columnSelect === 'single' ? columns.slice(0, 1) : columns;
|
|
464
|
+
const rowIds = new Set(this.#rows.map((row) => row.id));
|
|
465
|
+
const columnIds = new Set((this.#config?.columns ?? []).map((column) => column.id));
|
|
466
|
+
this.#comments = adoptComments(saved.comments).filter((comment) => {
|
|
467
|
+
if (comment.anchor.kind === 'row')
|
|
468
|
+
return rowIds.has(comment.anchor.rowId);
|
|
469
|
+
if (comment.anchor.kind === 'column')
|
|
470
|
+
return columnIds.has(comment.anchor.columnId);
|
|
471
|
+
return true;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
445
474
|
// ── the response ───────────────────────────────────────────────────────────────────────
|
|
446
475
|
get #rowSelect() {
|
|
447
476
|
return this.#config?.rowSelect ?? 'none';
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { registerElement } from '../register.js';
|
|
26
26
|
import { readSlotConfig, readSlotId } from '../slot-config.js';
|
|
27
|
-
import { respond } from '../host.js';
|
|
28
|
-
import { findComment, setComment } from '../comments.js';
|
|
27
|
+
import { respond, savedResponse } from '../host.js';
|
|
28
|
+
import { adoptComments, findComment, setComment } from '../comments.js';
|
|
29
29
|
const WHOLE = { kind: 'whole' };
|
|
30
30
|
const WHOLE_TARGET = 'the whole passage';
|
|
31
31
|
const RANGE_TARGET = 'the selected text';
|
|
@@ -622,6 +622,7 @@ class CrtrTextElement extends HTMLElement {
|
|
|
622
622
|
this.#config = read.config;
|
|
623
623
|
this.#slotId = readSlotId(this);
|
|
624
624
|
this.#text = read.config.initialText;
|
|
625
|
+
this.#hydrate();
|
|
625
626
|
this.#editing = read.config.editable === true && this.#text.length === 0;
|
|
626
627
|
this.#renderToolbar();
|
|
627
628
|
this.#renderSurface();
|
|
@@ -630,6 +631,22 @@ class CrtrTextElement extends HTMLElement {
|
|
|
630
631
|
disconnectedCallback() {
|
|
631
632
|
document.removeEventListener('pointerdown', this.#onDocumentPointerDown, true);
|
|
632
633
|
}
|
|
634
|
+
/**
|
|
635
|
+
* Seed local state from the answer the host is already holding, so a reopened page shows
|
|
636
|
+
* the edited words and the comments on them rather than the agent's original text. An
|
|
637
|
+
* uneditable slot keeps its authored text — only comments carry over — because a saved
|
|
638
|
+
* edit there could not have come from this control.
|
|
639
|
+
*/
|
|
640
|
+
#hydrate() {
|
|
641
|
+
if (this.#slotId === undefined)
|
|
642
|
+
return;
|
|
643
|
+
const saved = savedResponse(this.#slotId);
|
|
644
|
+
if (saved === undefined)
|
|
645
|
+
return;
|
|
646
|
+
if (this.#config.editable === true && typeof saved.text === 'string')
|
|
647
|
+
this.#text = saved.text;
|
|
648
|
+
this.#comments = adoptComments(saved.comments);
|
|
649
|
+
}
|
|
633
650
|
/** The complete current response — what every change hands `respond()`. */
|
|
634
651
|
#response() {
|
|
635
652
|
return { text: this.#text, edited: this.#text !== this.#config.initialText, comments: this.#comments };
|
package/dist/pages/host.d.ts
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* the host autosave the partial map; it does not resolve the ticket. Only the pager's
|
|
17
17
|
* submit affordance calls `submit()`, which posts the complete map once.
|
|
18
18
|
*
|
|
19
|
+
* `respond()` has one counterpart, `savedResponse()`: the host hands back the response it is
|
|
20
|
+
* currently holding for a slot, so a page reopened after a partial answer re-mounts SHOWING
|
|
21
|
+
* that answer instead of blank. Every response-bearing element reads it at mount and seeds
|
|
22
|
+
* its visible state from it — that is what keeps "what submit sends" and "what the screen
|
|
23
|
+
* shows" the same thing.
|
|
24
|
+
*
|
|
19
25
|
* The host is optional at the type level (`window.crtr?`) because a page can be opened
|
|
20
26
|
* bare — dropped in a browser, or rendered by a host that installs nothing. Elements read
|
|
21
27
|
* the bridge through the accessors below rather than touching `window.crtr` directly, so
|
|
@@ -42,6 +48,12 @@ export interface CrtrHost {
|
|
|
42
48
|
* partial map. Called on every user change. NOT ticket resolution.
|
|
43
49
|
*/
|
|
44
50
|
respond(slotId: string, response: SlotResponse): void;
|
|
51
|
+
/**
|
|
52
|
+
* The response the host currently holds for this slot — an autosaved partial answer on a
|
|
53
|
+
* reopened page, or the published answer on a resolved one. `undefined` when the host has
|
|
54
|
+
* nothing for the slot. Elements read it at mount; it is the inbound half of `respond`.
|
|
55
|
+
*/
|
|
56
|
+
savedResponse(slotId: string): SlotResponse | undefined;
|
|
45
57
|
/** Posts the complete response map once. Only the pager's submit affordance calls it. */
|
|
46
58
|
submit(): Promise<void>;
|
|
47
59
|
/** Flushes the pending autosave of the partial map. */
|
|
@@ -98,6 +110,20 @@ export declare function host(): CrtrHost | undefined;
|
|
|
98
110
|
* so an unavailable host only means the answer is not being persisted.
|
|
99
111
|
*/
|
|
100
112
|
export declare function respond(slotId: string, response: SlotResponse): HostCall;
|
|
113
|
+
/**
|
|
114
|
+
* The response the host already holds for this slot, or `undefined` when it holds none — a
|
|
115
|
+
* bare page, a first open, or a host without the channel. Elements call this once at mount
|
|
116
|
+
* and seed their visible state from it, so a reopened page shows the answers the host would
|
|
117
|
+
* submit rather than an empty form beside a full response map.
|
|
118
|
+
*
|
|
119
|
+
* `T` is asserted, not verified, exactly like `readSlotConfig`'s config: the host's saved map
|
|
120
|
+
* was validated against this slot's kind before it was stored. What is not guaranteed is that
|
|
121
|
+
* a host implements the channel at all, which is what the `undefined` covers — so seed with
|
|
122
|
+
* the same defensiveness you would give any outside value (`savedIds`, `adoptComments`).
|
|
123
|
+
*/
|
|
124
|
+
export declare function savedResponse<T extends SlotResponse = SlotResponse>(slotId: string): T | undefined;
|
|
125
|
+
/** The string ids of a saved selection field, with anything else in it dropped. */
|
|
126
|
+
export declare function savedIds(value: unknown): string[];
|
|
101
127
|
/**
|
|
102
128
|
* Posts the complete response map once, resolving the ticket. Only the pager's submit
|
|
103
129
|
* affordance calls this.
|
package/dist/pages/host.js
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
* the host autosave the partial map; it does not resolve the ticket. Only the pager's
|
|
17
17
|
* submit affordance calls `submit()`, which posts the complete map once.
|
|
18
18
|
*
|
|
19
|
+
* `respond()` has one counterpart, `savedResponse()`: the host hands back the response it is
|
|
20
|
+
* currently holding for a slot, so a page reopened after a partial answer re-mounts SHOWING
|
|
21
|
+
* that answer instead of blank. Every response-bearing element reads it at mount and seeds
|
|
22
|
+
* its visible state from it — that is what keeps "what submit sends" and "what the screen
|
|
23
|
+
* shows" the same thing.
|
|
24
|
+
*
|
|
19
25
|
* The host is optional at the type level (`window.crtr?`) because a page can be opened
|
|
20
26
|
* bare — dropped in a browser, or rendered by a host that installs nothing. Elements read
|
|
21
27
|
* the bridge through the accessors below rather than touching `window.crtr` directly, so
|
|
@@ -52,6 +58,33 @@ export function respond(slotId, response) {
|
|
|
52
58
|
return { status: 'failed', reason: reasonOf(error) };
|
|
53
59
|
}
|
|
54
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The response the host already holds for this slot, or `undefined` when it holds none — a
|
|
63
|
+
* bare page, a first open, or a host without the channel. Elements call this once at mount
|
|
64
|
+
* and seed their visible state from it, so a reopened page shows the answers the host would
|
|
65
|
+
* submit rather than an empty form beside a full response map.
|
|
66
|
+
*
|
|
67
|
+
* `T` is asserted, not verified, exactly like `readSlotConfig`'s config: the host's saved map
|
|
68
|
+
* was validated against this slot's kind before it was stored. What is not guaranteed is that
|
|
69
|
+
* a host implements the channel at all, which is what the `undefined` covers — so seed with
|
|
70
|
+
* the same defensiveness you would give any outside value (`savedIds`, `adoptComments`).
|
|
71
|
+
*/
|
|
72
|
+
export function savedResponse(slotId) {
|
|
73
|
+
const bridge = host();
|
|
74
|
+
if (typeof bridge?.savedResponse !== 'function')
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const saved = bridge.savedResponse(slotId);
|
|
78
|
+
return typeof saved === 'object' && saved !== null && !Array.isArray(saved) ? saved : undefined;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** The string ids of a saved selection field, with anything else in it dropped. */
|
|
85
|
+
export function savedIds(value) {
|
|
86
|
+
return Array.isArray(value) ? value.filter((id) => typeof id === 'string') : [];
|
|
87
|
+
}
|
|
55
88
|
/**
|
|
56
89
|
* Posts the complete response map once, resolving the ticket. Only the pager's submit
|
|
57
90
|
* affordance calls this.
|
|
@@ -3,6 +3,8 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
3
3
|
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
4
4
|
* not open with a sleep (or its duration isn't statically knowable). */
|
|
5
5
|
export declare function leadingSleepSeconds(command: string): number | null;
|
|
6
|
-
/** The valve's BashOperations backend.
|
|
7
|
-
|
|
6
|
+
/** The valve's BashOperations backend. Each tool execution closes over its own
|
|
7
|
+
* `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
|
|
8
|
+
* when the command is invalid or refused, leaving no state to leak later. */
|
|
9
|
+
export declare function createValveOperations(nodeId: string, contextDir: string, takePurpose?: () => string | null): BashOperations;
|
|
8
10
|
export default function (pi: ExtensionAPI): void;
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import { spawn } from 'node:child_process';
|
|
28
28
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
29
29
|
import { homedir } from 'node:os';
|
|
30
|
-
import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId } from '../core/bash-jobs.js';
|
|
30
|
+
import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
|
|
31
31
|
import { Type } from 'typebox';
|
|
32
32
|
import { createBashToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
@@ -203,11 +203,14 @@ function resolveExecutionCwd(cwd) {
|
|
|
203
203
|
warning: `[working directory no longer exists: ${cwd}; running from ${fallback}]\n`,
|
|
204
204
|
};
|
|
205
205
|
}
|
|
206
|
-
/** The valve's BashOperations backend.
|
|
207
|
-
|
|
206
|
+
/** The valve's BashOperations backend. Each tool execution closes over its own
|
|
207
|
+
* `takePurpose`, so concurrent calls cannot exchange labels. It is consumed even
|
|
208
|
+
* when the command is invalid or refused, leaving no state to leak later. */
|
|
209
|
+
export function createValveOperations(nodeId, contextDir, takePurpose = () => null) {
|
|
208
210
|
return {
|
|
209
211
|
exec: (command, cwd, { onData, signal, timeout, env }) => {
|
|
210
212
|
return new Promise((resolve, reject) => {
|
|
213
|
+
const purpose = takePurpose();
|
|
211
214
|
if (signal?.aborted) {
|
|
212
215
|
reject(new Error('aborted'));
|
|
213
216
|
return;
|
|
@@ -222,6 +225,8 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
222
225
|
writeFileSync(paths.cmdSh, command);
|
|
223
226
|
writeFileSync(paths.jobLog, '');
|
|
224
227
|
writeFileSync(paths.jobRun, '');
|
|
228
|
+
if (purpose !== null)
|
|
229
|
+
writeFileSync(paths.jobPurpose, purpose);
|
|
225
230
|
let offset = 0;
|
|
226
231
|
let settled = false;
|
|
227
232
|
let pgid;
|
|
@@ -357,8 +362,10 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
357
362
|
// parsing partial JSON can show the label while the command is still being
|
|
358
363
|
// written, rather than after the call is complete and about to finish.
|
|
359
364
|
//
|
|
360
|
-
// The
|
|
361
|
-
//
|
|
365
|
+
// The command execution path ignores it as shell input — this remains a
|
|
366
|
+
// labelling channel, not an execution parameter. The valve only persists the
|
|
367
|
+
// label beside a background job. Optional, so a model that omits it behaves
|
|
368
|
+
// exactly as before.
|
|
362
369
|
//
|
|
363
370
|
// Upstream ask: pi's own bash tool should carry this (Claude Code's bash tool
|
|
364
371
|
// has shipped an equivalent `description` field for years). Until it does, the
|
|
@@ -386,6 +393,18 @@ function withPurpose(definition) {
|
|
|
386
393
|
}),
|
|
387
394
|
};
|
|
388
395
|
}
|
|
396
|
+
/** Bind each purpose directly to its own tool execution. Pi may preflight a
|
|
397
|
+
* batch before starting its calls, so a shared "next purpose" slot could give
|
|
398
|
+
* a concurrent call the wrong label. */
|
|
399
|
+
function createPurposeValveToolDefinition(nodeId, contextDir, cwd) {
|
|
400
|
+
const schemaDefinition = withPurpose(createBashToolDefinition(cwd, { operations: createValveOperations(nodeId, contextDir) }));
|
|
401
|
+
const execute = (toolCallId, params, signal, onUpdate, ctx) => {
|
|
402
|
+
const purpose = normalizeBashJobPurpose(params['purpose']);
|
|
403
|
+
const definition = createBashToolDefinition(cwd, { operations: createValveOperations(nodeId, contextDir, () => purpose) });
|
|
404
|
+
return definition.execute(toolCallId, params, signal, onUpdate, ctx);
|
|
405
|
+
};
|
|
406
|
+
return { ...schemaDefinition, execute };
|
|
407
|
+
}
|
|
389
408
|
export default function (pi) {
|
|
390
409
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
391
410
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
@@ -399,7 +418,6 @@ export default function (pi) {
|
|
|
399
418
|
// is built against. registerTool replaces the builtin by name; re-firing on
|
|
400
419
|
// every session_start is idempotent (last registration wins).
|
|
401
420
|
pi.on('session_start', (_event, ctx) => {
|
|
402
|
-
|
|
403
|
-
pi.registerTool(withPurpose(createBashToolDefinition(ctx.cwd, { operations })));
|
|
421
|
+
pi.registerTool(createPurposeValveToolDefinition(nodeId, contextDir, ctx.cwd));
|
|
404
422
|
});
|
|
405
423
|
}
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.195",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.195",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { hostname } from 'node:os';
|
|
5
6
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { ENTRY_NAMES, GENERATION_ID, MANIFEST_KEYS, PACKAGE_NAME, MANIFEST_SCHEMA, contained, isSealed, publishedMarkerPath, runtimeHome, sortInventory, validateManifestShape } from '../bin/runtime-generation-manifest.mjs';
|
|
@@ -11,9 +12,20 @@ const PACKAGE_ROOT = process.argv[2] ? resolve(process.argv[2]) : resolve(dirnam
|
|
|
11
12
|
const RUNTIME_HOME = runtimeHome();
|
|
12
13
|
const GENERATIONS = join(RUNTIME_HOME, 'generations');
|
|
13
14
|
const LOCK = join(RUNTIME_HOME, 'install.lock');
|
|
15
|
+
const LOCK_OWNER_FILE = 'owner.json';
|
|
14
16
|
const RUNTIME_LOCK = 'runtime.lock.json';
|
|
15
17
|
const KEEP_RECENT_GENERATIONS = 3;
|
|
16
18
|
const FS_CONCURRENCY = 64;
|
|
19
|
+
// How long an install.lock with no readable owner record is given the
|
|
20
|
+
// benefit of the doubt (the owner is mid-write, or on an ancient version of
|
|
21
|
+
// this script that never wrote one) before it is treated as abandoned.
|
|
22
|
+
const STALE_LOCK_RECORD_THRESHOLD_MS = 15 * 60 * 1000;
|
|
23
|
+
// How long a `.staging-*`/`.archive-*` temp dir sits before the sweep in
|
|
24
|
+
// step 4 treats it as wreckage from an earlier killed install rather than
|
|
25
|
+
// work in progress under a lock we don't hold (impossible while we hold the
|
|
26
|
+
// lock, but a conservative margin costs nothing).
|
|
27
|
+
const STALE_TEMP_DIR_THRESHOLD_MS = 60 * 60 * 1000;
|
|
28
|
+
const LOCK_ACQUIRE_MAX_ATTEMPTS = 50;
|
|
17
29
|
|
|
18
30
|
function run(command, args, cwd, capture = false, env = process.env) {
|
|
19
31
|
return new Promise((resolveRun, reject) => {
|
|
@@ -235,14 +247,162 @@ async function pruneGenerations() {
|
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
249
|
|
|
238
|
-
|
|
239
|
-
|
|
250
|
+
/** A process's start time as `ps` reports it — the cheap, boot-unique-enough
|
|
251
|
+
* fellow-traveler of a pid that lets a reused pid be told apart from the
|
|
252
|
+
* process that actually wrote a lock owner record. Empty string when the
|
|
253
|
+
* pid does not exist (or `ps` cannot be run), never rejects. */
|
|
254
|
+
async function pidStartTime(pid) {
|
|
255
|
+
const output = await probe('ps', ['-o', 'lstart=', '-p', String(pid)], RUNTIME_HOME);
|
|
256
|
+
return output.trim();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Whether `pid` currently identifies a live process, from this user's
|
|
260
|
+
* vantage point. `EPERM` means the pid exists but is owned by someone
|
|
261
|
+
* else — still alive, just not ours to signal. */
|
|
262
|
+
function pidAlive(pid) {
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pid, 0);
|
|
265
|
+
return true;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return error?.code === 'EPERM';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Write the lock owner record via temp-file + rename so a concurrent reader
|
|
272
|
+
* never observes a half-written file. Must be called only once `LOCK` is
|
|
273
|
+
* known to be ours (immediately after `mkdir(LOCK)` succeeds). */
|
|
274
|
+
async function writeLockOwner(lockDir) {
|
|
275
|
+
const owner = { pid: process.pid, startTime: await pidStartTime(process.pid), hostname: hostname(), startedAt: new Date().toISOString() };
|
|
276
|
+
const temporary = join(lockDir, `.${LOCK_OWNER_FILE}.${process.pid}-${Date.now()}.tmp`);
|
|
277
|
+
await writeFile(temporary, JSON.stringify(owner));
|
|
278
|
+
await rename(temporary, join(lockDir, LOCK_OWNER_FILE));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function readLockOwner(lockDir) {
|
|
282
|
+
try {
|
|
283
|
+
const owner = JSON.parse(await readFile(join(lockDir, LOCK_OWNER_FILE), 'utf8'));
|
|
284
|
+
return Number.isInteger(owner?.pid) ? owner : null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** True only when `owner`'s pid is both alive AND (when a start time was
|
|
291
|
+
* recorded) still the same process that wrote the record — a recycled pid
|
|
292
|
+
* must not masquerade as a live owner. */
|
|
293
|
+
async function ownerIsAlive(owner) {
|
|
294
|
+
if (!owner || !pidAlive(owner.pid)) return false;
|
|
295
|
+
if (!owner.startTime) return true;
|
|
296
|
+
const currentStartTime = await pidStartTime(owner.pid);
|
|
297
|
+
return currentStartTime === '' || currentStartTime === owner.startTime;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Take `LOCK`, reclaiming it first if its owner is provably dead. A dead
|
|
301
|
+
* owner is reclaimed unconditionally; a missing/corrupt owner record is
|
|
302
|
+
* reclaimed only once the lock dir is older than a conservative threshold
|
|
303
|
+
* (it may belong to a process that has not written its record yet). The
|
|
304
|
+
* reclaim itself is race-safe: the stale dir is renamed to a private,
|
|
305
|
+
* unique path first, and only the process whose rename wins takes the
|
|
306
|
+
* fresh lock — a loser simply retries the ordinary contended path rather
|
|
307
|
+
* than deleting a dir a concurrent winner might still be reading. */
|
|
308
|
+
async function acquireLock() {
|
|
309
|
+
for (let attempt = 0; attempt < LOCK_ACQUIRE_MAX_ATTEMPTS; attempt++) {
|
|
310
|
+
try {
|
|
311
|
+
await mkdir(LOCK);
|
|
312
|
+
await writeLockOwner(LOCK);
|
|
313
|
+
return;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
316
|
+
const owner = await readLockOwner(LOCK);
|
|
317
|
+
if (owner && (await ownerIsAlive(owner))) {
|
|
318
|
+
throw new Error(`runtime installer lock is held by a live process: pid=${owner.pid} started=${owner.startTime || 'unknown'} host=${owner.hostname || 'unknown'}; refusing to install concurrently`);
|
|
319
|
+
}
|
|
320
|
+
if (!owner) {
|
|
321
|
+
let stat;
|
|
322
|
+
try {
|
|
323
|
+
stat = await lstat(LOCK);
|
|
324
|
+
} catch (statError) {
|
|
325
|
+
if (statError?.code === 'ENOENT') continue; // lock vanished between mkdir and lstat; retry
|
|
326
|
+
throw statError;
|
|
327
|
+
}
|
|
328
|
+
const ageMs = Date.now() - stat.mtimeMs;
|
|
329
|
+
if (ageMs < STALE_LOCK_RECORD_THRESHOLD_MS) {
|
|
330
|
+
throw new Error(`runtime installer lock exists without a readable owner record and is only ${Math.round(ageMs / 1000)}s old: ${LOCK}; inspect and remove only after confirming no installation is running`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const graveyard = join(RUNTIME_HOME, `.lock-stale-${process.pid}-${Date.now()}`);
|
|
334
|
+
try {
|
|
335
|
+
await rename(LOCK, graveyard);
|
|
336
|
+
} catch (renameError) {
|
|
337
|
+
if (renameError?.code === 'ENOENT') continue; // another process already reclaimed it; fall back to the contended path
|
|
338
|
+
throw renameError;
|
|
339
|
+
}
|
|
340
|
+
process.stderr.write(`runtime installer: reclaimed a stale lock (dead owner pid=${owner?.pid ?? 'unknown'})\n`);
|
|
341
|
+
await removeStage(graveyard);
|
|
342
|
+
// Loop back and take the now-vacant lock.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
throw new Error(`runtime installer lock: gave up after ${LOCK_ACQUIRE_MAX_ATTEMPTS} contended attempts: ${LOCK}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Sweep `.staging-*`/`.archive-*` temp dirs left behind by an install that
|
|
349
|
+
* was killed before its own `finally` (or signal handler) could run. Only
|
|
350
|
+
* called once this process holds `LOCK`, so any dir old enough to clear
|
|
351
|
+
* the threshold cannot belong to a still-running install. */
|
|
352
|
+
async function sweepOrphanTempDirs() {
|
|
353
|
+
let entries;
|
|
240
354
|
try {
|
|
241
|
-
await
|
|
355
|
+
entries = await readdir(RUNTIME_HOME, { withFileTypes: true });
|
|
356
|
+
} catch {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (!entry.isDirectory() || !/^\.(?:staging|archive)-/.test(entry.name)) continue;
|
|
361
|
+
const absolute = join(RUNTIME_HOME, entry.name);
|
|
362
|
+
let stat;
|
|
363
|
+
try {
|
|
364
|
+
stat = await lstat(absolute);
|
|
365
|
+
} catch {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (Date.now() - stat.mtimeMs < STALE_TEMP_DIR_THRESHOLD_MS) continue;
|
|
369
|
+
process.stderr.write(`runtime installer: sweeping orphan temp dir from an earlier aborted install: ${absolute}\n`);
|
|
370
|
+
await removeStage(absolute);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Populated as install() proceeds so a SIGINT/SIGTERM handler can undo
|
|
375
|
+
// exactly what the normal `finally` below would have undone. SIGKILL
|
|
376
|
+
// remains uncatchable, which is exactly why acquireLock() can reclaim.
|
|
377
|
+
const cleanupState = { lockTaken: false, stage: undefined, archiveDir: undefined, publishedRoot: undefined, committed: false, temporary: undefined, id: undefined, exiting: false };
|
|
378
|
+
|
|
379
|
+
async function cleanupOnSignal(signal) {
|
|
380
|
+
if (cleanupState.exiting) return;
|
|
381
|
+
cleanupState.exiting = true;
|
|
382
|
+
process.stderr.write(`runtime installer: received ${signal}, cleaning up before exit\n`);
|
|
383
|
+
try {
|
|
384
|
+
if (cleanupState.temporary) await rm(cleanupState.temporary, { force: true });
|
|
385
|
+
if (cleanupState.stage) await removeStage(cleanupState.stage);
|
|
386
|
+
if (cleanupState.publishedRoot && !cleanupState.committed) {
|
|
387
|
+
await removeStage(cleanupState.publishedRoot);
|
|
388
|
+
if (cleanupState.id) await rm(publishedMarkerPath(GENERATIONS, cleanupState.id), { force: true });
|
|
389
|
+
}
|
|
390
|
+
if (cleanupState.archiveDir) await rm(cleanupState.archiveDir, { recursive: true, force: true });
|
|
391
|
+
if (cleanupState.lockTaken) await rm(LOCK, { recursive: true, force: true });
|
|
242
392
|
} catch (error) {
|
|
243
|
-
|
|
244
|
-
throw error;
|
|
393
|
+
process.stderr.write(`runtime installer: cleanup on ${signal} failed: ${error?.message ?? error}\n`);
|
|
245
394
|
}
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
process.on('SIGINT', () => { cleanupOnSignal('SIGINT'); });
|
|
399
|
+
process.on('SIGTERM', () => { cleanupOnSignal('SIGTERM'); });
|
|
400
|
+
|
|
401
|
+
async function install() {
|
|
402
|
+
await mkdir(GENERATIONS, { recursive: true });
|
|
403
|
+
await acquireLock();
|
|
404
|
+
cleanupState.lockTaken = true;
|
|
405
|
+
await sweepOrphanTempDirs();
|
|
246
406
|
let stage;
|
|
247
407
|
let archiveDir;
|
|
248
408
|
let publishedRoot;
|
|
@@ -251,9 +411,11 @@ async function install() {
|
|
|
251
411
|
let id;
|
|
252
412
|
try {
|
|
253
413
|
archiveDir = await mkdtemp(join(RUNTIME_HOME, '.archive-'));
|
|
414
|
+
cleanupState.archiveDir = archiveDir;
|
|
254
415
|
const archive = await pack(archiveDir);
|
|
255
416
|
const archiveHash = digest(await readFile(archive));
|
|
256
417
|
stage = await mkdtemp(join(RUNTIME_HOME, '.staging-'));
|
|
418
|
+
cleanupState.stage = stage;
|
|
257
419
|
await run('tar', ['-xzf', archive, '--strip-components=1', '-C', stage], PACKAGE_ROOT);
|
|
258
420
|
await copyFile(join(stage, RUNTIME_LOCK), join(stage, 'package-lock.json'));
|
|
259
421
|
// Humanloop's postinstall only prewarms its external termrender cache; runtime rendering retries lazily with a plaintext fallback.
|
|
@@ -265,6 +427,7 @@ async function install() {
|
|
|
265
427
|
const pkg = JSON.parse(await readFile(join(stage, 'package.json'), 'utf8'));
|
|
266
428
|
const files = sortInventory(await inventory(stage));
|
|
267
429
|
id = digest(`${archiveHash}\n${digest(await readFile(join(stage, 'package-lock.json')))}\n${digest(JSON.stringify(files))}\n${process.version}\n${process.platform}\n${process.arch}\ninstaller-schema-1`);
|
|
430
|
+
cleanupState.id = id;
|
|
268
431
|
const finalRoot = join(GENERATIONS, id);
|
|
269
432
|
const manifest = {
|
|
270
433
|
schema: MANIFEST_SCHEMA,
|
|
@@ -294,8 +457,10 @@ async function install() {
|
|
|
294
457
|
await chmod(stage, 0o755);
|
|
295
458
|
await rename(stage, finalRoot);
|
|
296
459
|
publishedRoot = finalRoot;
|
|
460
|
+
cleanupState.publishedRoot = publishedRoot;
|
|
297
461
|
await chmod(finalRoot, 0o555);
|
|
298
462
|
stage = undefined;
|
|
463
|
+
cleanupState.stage = undefined;
|
|
299
464
|
await validatePublished(finalRoot, id);
|
|
300
465
|
}
|
|
301
466
|
// Only after the full-hash check above has succeeded for this exact id —
|
|
@@ -303,9 +468,11 @@ async function install() {
|
|
|
303
468
|
await markPublished(GENERATIONS, id);
|
|
304
469
|
const selected = join(RUNTIME_HOME, 'selected');
|
|
305
470
|
temporary = join(RUNTIME_HOME, `.selected-${process.pid}-${Date.now()}`);
|
|
471
|
+
cleanupState.temporary = temporary;
|
|
306
472
|
await symlink(`generations/${id}`, temporary);
|
|
307
473
|
await rename(temporary, selected);
|
|
308
474
|
committed = true;
|
|
475
|
+
cleanupState.committed = true;
|
|
309
476
|
try {
|
|
310
477
|
await pruneGenerations();
|
|
311
478
|
} catch (error) {
|