@north-light/crouter 0.3.192 → 0.3.194
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/client.d.ts +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -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/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +18 -30
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +43 -0
- package/dist/core/bash-jobs.js +96 -2
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- 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/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +90 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- 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.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/scripts/install-runtime.mjs +172 -5
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -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) {
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import type { Deck, InteractionResponse } from '../../core/human/types.js';
|
|
2
|
-
import type { Key } from './tui/keys.js';
|
|
3
|
-
export interface DeckAdapterOptions {
|
|
4
|
-
dir: string;
|
|
5
|
-
deck: Deck;
|
|
6
|
-
cols: number;
|
|
7
|
-
rows: number;
|
|
8
|
-
onProgress?: (responses: InteractionResponse[]) => void;
|
|
9
|
-
onComplete: (responses: InteractionResponse[]) => void;
|
|
10
|
-
onBack: () => void;
|
|
11
|
-
onDirty: () => void;
|
|
12
|
-
onEditorRequest?: () => void;
|
|
13
|
-
}
|
|
14
|
-
/** Embeds the single deck renderer in a controller-owned rectangle. */
|
|
15
|
-
export declare class DeckAdapter {
|
|
16
|
-
private readonly opts;
|
|
17
|
-
private panel;
|
|
18
|
-
private responses;
|
|
19
|
-
constructor(opts: DeckAdapterOptions);
|
|
20
|
-
render(): string[];
|
|
21
|
-
resize(cols: number, rows: number): string[];
|
|
22
|
-
inputBuffer(): string | undefined;
|
|
23
|
-
setInputBuffer(text: string): void;
|
|
24
|
-
canAcceptHostKeys(): boolean;
|
|
25
|
-
handleKey(input: string, key: Key): void;
|
|
26
|
-
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
27
|
-
reload(deck: Deck): void;
|
|
28
|
-
close(): void;
|
|
29
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import { progressPath } from '../../core/human/convention.js';
|
|
2
|
-
import { readJsonOrNull } from '../../core/fs-utils.js';
|
|
3
|
-
import { mountPanel } from './tui/panel.js';
|
|
4
|
-
/** Embeds the single deck renderer in a controller-owned rectangle. */
|
|
5
|
-
export class DeckAdapter {
|
|
6
|
-
opts;
|
|
7
|
-
panel;
|
|
8
|
-
responses = [];
|
|
9
|
-
constructor(opts) {
|
|
10
|
-
this.opts = opts;
|
|
11
|
-
this.responses = initialResponses(opts.deck, opts.dir);
|
|
12
|
-
this.panel = mountPanel({
|
|
13
|
-
deck: opts.deck,
|
|
14
|
-
progressPath: progressPath(opts.dir),
|
|
15
|
-
cols: opts.cols,
|
|
16
|
-
rows: opts.rows,
|
|
17
|
-
onProgress: (responses) => { this.responses = responses; opts.onProgress?.(responses); },
|
|
18
|
-
onComplete: opts.onComplete,
|
|
19
|
-
// A partial-deck exit is a valid completion for ordinary asks, but a
|
|
20
|
-
// notification is resolved only by its explicit acknowledgement.
|
|
21
|
-
onExit: () => { if (notificationsAcknowledged(opts.deck, this.responses))
|
|
22
|
-
opts.onComplete(this.responses); },
|
|
23
|
-
onDirty: opts.onDirty,
|
|
24
|
-
onEditorRequest: opts.onEditorRequest,
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
render() { return this.panel.render(); }
|
|
28
|
-
resize(cols, rows) { return this.panel.handleResize(cols, rows); }
|
|
29
|
-
inputBuffer() { return this.panel.getInputBuffer(); }
|
|
30
|
-
setInputBuffer(text) { this.panel.setInputBuffer(text); }
|
|
31
|
-
canAcceptHostKeys() { return this.panel.canAcceptHostKeys(); }
|
|
32
|
-
handleKey(input, key) {
|
|
33
|
-
if (key.escape && this.panel.atDeckTop()) {
|
|
34
|
-
this.opts.onBack();
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
this.panel.handleKey(input, key);
|
|
38
|
-
}
|
|
39
|
-
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
40
|
-
reload(deck) {
|
|
41
|
-
this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir) });
|
|
42
|
-
this.opts.onDirty();
|
|
43
|
-
}
|
|
44
|
-
close() { this.panel.unmount(); }
|
|
45
|
-
}
|
|
46
|
-
function notificationsAcknowledged(deck, responses) {
|
|
47
|
-
const byId = new Map(responses.map((response) => [response.id, response]));
|
|
48
|
-
return deck.interactions.filter((interaction) => interaction.kind === 'notify').every((interaction) => {
|
|
49
|
-
const response = byId.get(interaction.id);
|
|
50
|
-
return response !== undefined && (response.selectedOptionId !== undefined || (response.selectedOptionIds?.length ?? 0) > 0 || (response.freetext?.trim() ?? '') !== '');
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
function initialResponses(deck, dir) {
|
|
54
|
-
const saved = readJsonOrNull(progressPath(dir))?.responses;
|
|
55
|
-
if (Array.isArray(saved))
|
|
56
|
-
return saved;
|
|
57
|
-
return deck.interactions.flatMap((interaction) => {
|
|
58
|
-
const answer = interaction.preAnswered;
|
|
59
|
-
if (answer === undefined)
|
|
60
|
-
return [];
|
|
61
|
-
return [{ id: interaction.id, ...(answer.selectedOptionId === undefined ? {} : { selectedOptionId: answer.selectedOptionId }), ...(answer.selectedOptionIds === undefined ? {} : { selectedOptionIds: [...answer.selectedOptionIds] }), ...(answer.freetext === undefined ? {} : { freetext: answer.freetext }) }];
|
|
62
|
-
});
|
|
63
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { Deck } from './types.js';
|
|
2
|
-
export interface NotifyDeckOpts {
|
|
3
|
-
/** One plain-English sentence stating the notification's status and stakes. */
|
|
4
|
-
subtitle: string;
|
|
5
|
-
body?: string;
|
|
6
|
-
}
|
|
7
|
-
/** Build a validated single-option notify deck. id: 'notify', kind: 'notify'. */
|
|
8
|
-
export declare function notifyDeck(title: string, opts: NotifyDeckOpts): Deck;
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { validateDeck } from './deck-schema.js';
|
|
2
|
-
/** Build a validated single-option notify deck. id: 'notify', kind: 'notify'. */
|
|
3
|
-
export function notifyDeck(title, opts) {
|
|
4
|
-
return validateDeck({
|
|
5
|
-
title,
|
|
6
|
-
interactions: [{
|
|
7
|
-
id: 'notify',
|
|
8
|
-
title,
|
|
9
|
-
subtitle: opts.subtitle,
|
|
10
|
-
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
11
|
-
kind: 'notify',
|
|
12
|
-
options: [{ id: 'ok', label: 'OK' }],
|
|
13
|
-
}],
|
|
14
|
-
});
|
|
15
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import type { Deck, ReviewDescriptor } from './types.js';
|
|
3
|
-
export declare const interactionOptionSchema: z.ZodObject<{
|
|
4
|
-
id: z.ZodString;
|
|
5
|
-
label: z.ZodString;
|
|
6
|
-
description: z.ZodOptional<z.ZodString>;
|
|
7
|
-
}, z.core.$strip>;
|
|
8
|
-
export declare const preAnswerSchema: z.ZodObject<{
|
|
9
|
-
selectedOptionId: z.ZodOptional<z.ZodString>;
|
|
10
|
-
selectedOptionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
11
|
-
freetext: z.ZodOptional<z.ZodString>;
|
|
12
|
-
label: z.ZodOptional<z.ZodString>;
|
|
13
|
-
}, z.core.$strip>;
|
|
14
|
-
export declare const deckSchema: z.ZodObject<{
|
|
15
|
-
title: z.ZodString;
|
|
16
|
-
source: z.ZodOptional<z.ZodObject<{
|
|
17
|
-
sessionName: z.ZodOptional<z.ZodString>;
|
|
18
|
-
askedBy: z.ZodOptional<z.ZodString>;
|
|
19
|
-
blockedSince: z.ZodOptional<z.ZodString>;
|
|
20
|
-
nodeId: z.ZodOptional<z.ZodString>;
|
|
21
|
-
}, z.core.$strip>>;
|
|
22
|
-
interactions: z.ZodArray<z.ZodObject<{
|
|
23
|
-
id: z.ZodString;
|
|
24
|
-
title: z.ZodString;
|
|
25
|
-
subtitle: z.ZodString;
|
|
26
|
-
body: z.ZodOptional<z.ZodString>;
|
|
27
|
-
bodyPath: z.ZodOptional<z.ZodString>;
|
|
28
|
-
options: z.ZodArray<z.ZodObject<{
|
|
29
|
-
id: z.ZodString;
|
|
30
|
-
label: z.ZodString;
|
|
31
|
-
description: z.ZodOptional<z.ZodString>;
|
|
32
|
-
}, z.core.$strip>>;
|
|
33
|
-
multiSelect: z.ZodOptional<z.ZodBoolean>;
|
|
34
|
-
allowFreetext: z.ZodOptional<z.ZodBoolean>;
|
|
35
|
-
freetextLabel: z.ZodOptional<z.ZodString>;
|
|
36
|
-
kind: z.ZodOptional<z.ZodEnum<{
|
|
37
|
-
error: "error";
|
|
38
|
-
context: "context";
|
|
39
|
-
notify: "notify";
|
|
40
|
-
review: "review";
|
|
41
|
-
decision: "decision";
|
|
42
|
-
}>>;
|
|
43
|
-
preAnswered: z.ZodOptional<z.ZodObject<{
|
|
44
|
-
selectedOptionId: z.ZodOptional<z.ZodString>;
|
|
45
|
-
selectedOptionIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
46
|
-
freetext: z.ZodOptional<z.ZodString>;
|
|
47
|
-
label: z.ZodOptional<z.ZodString>;
|
|
48
|
-
}, z.core.$strip>>;
|
|
49
|
-
}, z.core.$strip>>;
|
|
50
|
-
}, z.core.$strip>;
|
|
51
|
-
export declare const reviewDescriptorSchema: z.ZodObject<{
|
|
52
|
-
schema: z.ZodLiteral<"humanloop.review/v1">;
|
|
53
|
-
file: z.ZodString;
|
|
54
|
-
output: z.ZodString;
|
|
55
|
-
title: z.ZodString;
|
|
56
|
-
subtitle: z.ZodString;
|
|
57
|
-
source: z.ZodObject<{
|
|
58
|
-
sessionName: z.ZodOptional<z.ZodString>;
|
|
59
|
-
askedBy: z.ZodOptional<z.ZodString>;
|
|
60
|
-
blockedSince: z.ZodOptional<z.ZodString>;
|
|
61
|
-
nodeId: z.ZodOptional<z.ZodString>;
|
|
62
|
-
}, z.core.$strip>;
|
|
63
|
-
blockedSince: z.ZodString;
|
|
64
|
-
}, z.core.$strict>;
|
|
65
|
-
export declare function resolveDeckBodyPaths(deck: Deck, dir: string): Deck;
|
|
66
|
-
export declare function parseDeck(path: string): Deck;
|
|
67
|
-
export declare function validateDeck(parsed: unknown): Deck;
|
|
68
|
-
export declare function validateReviewDescriptor(parsed: unknown): ReviewDescriptor;
|
|
69
|
-
/** Canonicalize and authorize the one review projection boundary before every write. */
|
|
70
|
-
export declare function validateReviewProjection(dir: string, parsed: unknown): ReviewDescriptor;
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
-
import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
-
import { claimPath, deckPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
4
|
-
import { z } from 'zod';
|
|
5
|
-
import { INTERACTION_KINDS } from './types.js';
|
|
6
|
-
import { checkMarkdown } from '../termrender/termrender.js';
|
|
7
|
-
import { checkReviewableFile } from './visible-paths.js';
|
|
8
|
-
export const interactionOptionSchema = z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional() });
|
|
9
|
-
export const preAnswerSchema = z.object({ selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), label: z.string().optional() });
|
|
10
|
-
const interactionSchema = z.object({
|
|
11
|
-
id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
|
|
12
|
-
title: z.string().trim().min(1), subtitle: z.string().trim().min(1), body: z.string().optional(), bodyPath: z.string().optional(),
|
|
13
|
-
options: z.array(interactionOptionSchema), multiSelect: z.boolean().optional(), allowFreetext: z.boolean().optional(), freetextLabel: z.string().optional(), kind: z.enum(INTERACTION_KINDS).optional(), preAnswered: preAnswerSchema.optional(),
|
|
14
|
-
});
|
|
15
|
-
const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional() });
|
|
16
|
-
export const deckSchema = z.object({ title: z.string().trim().min(1), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
|
|
17
|
-
const seen = new Set();
|
|
18
|
-
input.interactions.forEach((interaction, index) => {
|
|
19
|
-
if (interaction.body !== undefined && interaction.bodyPath !== undefined)
|
|
20
|
-
ctx.addIssue({ code: 'custom', message: 'body and bodyPath are mutually exclusive', path: ['interactions', index] });
|
|
21
|
-
if (seen.has(interaction.id))
|
|
22
|
-
ctx.addIssue({ code: 'custom', message: `duplicate interaction id "${interaction.id}"`, path: ['interactions', index, 'id'] });
|
|
23
|
-
seen.add(interaction.id);
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
const reviewSourceSchema = deckSourceSchema;
|
|
27
|
-
export const reviewDescriptorSchema = z.object({
|
|
28
|
-
schema: z.literal('humanloop.review/v1'),
|
|
29
|
-
file: z.string().min(1).refine(isAbsolute, 'file must be absolute'),
|
|
30
|
-
output: z.string().min(1).refine(isAbsolute, 'output must be absolute'),
|
|
31
|
-
title: z.string().trim().min(1),
|
|
32
|
-
subtitle: z.string().trim().min(1),
|
|
33
|
-
source: reviewSourceSchema,
|
|
34
|
-
blockedSince: z.string().datetime({ offset: true }),
|
|
35
|
-
}).strict();
|
|
36
|
-
function readBodyPathFile(dir, bodyPath) {
|
|
37
|
-
const joined = resolve(dir, bodyPath);
|
|
38
|
-
if (!existsSync(joined))
|
|
39
|
-
throw new Error(`bodyPath does not exist: '${bodyPath}'`);
|
|
40
|
-
if (!lstatSync(joined).isFile())
|
|
41
|
-
throw new Error(`bodyPath must be a regular file: ${bodyPath}`);
|
|
42
|
-
const realResolved = realpathSync(joined);
|
|
43
|
-
const realDeckDir = realpathSync(dir);
|
|
44
|
-
if (!realResolved.startsWith(realDeckDir + sep))
|
|
45
|
-
throw new Error(`bodyPath '${bodyPath}' escapes the deck directory`);
|
|
46
|
-
return readFileSync(joined, 'utf8');
|
|
47
|
-
}
|
|
48
|
-
export function resolveDeckBodyPaths(deck, dir) {
|
|
49
|
-
return { ...deck, interactions: deck.interactions.map((interaction) => {
|
|
50
|
-
if (interaction.bodyPath === undefined)
|
|
51
|
-
return interaction;
|
|
52
|
-
const { bodyPath: _bodyPath, ...rest } = interaction;
|
|
53
|
-
return { ...rest, body: readBodyPathFile(dir, interaction.bodyPath) };
|
|
54
|
-
}) };
|
|
55
|
-
}
|
|
56
|
-
export function parseDeck(path) {
|
|
57
|
-
let raw;
|
|
58
|
-
try {
|
|
59
|
-
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
throw new Error('deck is not valid JSON');
|
|
63
|
-
}
|
|
64
|
-
const deck = resolveDeckBodyPaths(deckSchema.parse(raw), dirname(path));
|
|
65
|
-
for (const interaction of deck.interactions)
|
|
66
|
-
if (interaction.body !== undefined) {
|
|
67
|
-
const check = checkMarkdown(interaction.body);
|
|
68
|
-
if (!check.ok)
|
|
69
|
-
throw new Error(check.error);
|
|
70
|
-
}
|
|
71
|
-
return deck;
|
|
72
|
-
}
|
|
73
|
-
export function validateDeck(parsed) { return deckSchema.parse(parsed); }
|
|
74
|
-
export function validateReviewDescriptor(parsed) { return reviewDescriptorSchema.parse(parsed); }
|
|
75
|
-
/** Canonicalize and authorize the one review projection boundary before every write. */
|
|
76
|
-
export function validateReviewProjection(dir, parsed) {
|
|
77
|
-
const descriptor = validateReviewDescriptor(parsed);
|
|
78
|
-
// Any reviewable UTF-8 text file: a .md artifact renders as a document, a
|
|
79
|
-
// source file as a syntax-highlighted code panel.
|
|
80
|
-
if (!existsSync(descriptor.file))
|
|
81
|
-
throw new Error('review file must be an existing absolute path');
|
|
82
|
-
const reviewable = checkReviewableFile(descriptor.file);
|
|
83
|
-
if (!reviewable.ok)
|
|
84
|
-
throw new Error(`review file is not reviewable: ${reviewable.reason}`);
|
|
85
|
-
const file = realpathSync(descriptor.file);
|
|
86
|
-
const output = resolve(realpathSync(dirname(descriptor.output)), basename(descriptor.output));
|
|
87
|
-
const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json']);
|
|
88
|
-
const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir)]);
|
|
89
|
-
if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
|
|
90
|
-
throw new Error('review output must not alias the source or ticket protocol files');
|
|
91
|
-
return { ...descriptor, file, output };
|
|
92
|
-
}
|