amicus 4.4.0 → 4.5.0
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +162 -0
- package/README.md +17 -2
- package/bin/amicus.js +10 -0
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +226 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +395 -0
- package/docs/council.md +970 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +56 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +849 -0
- package/electron/fold.js +1 -1
- package/electron/main.js +4 -1
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-ui/live-model.js +12 -1
- package/electron/workspace-ui/md-lite.js +52 -8
- package/electron/workspace-ui/workspace-app.js +39 -17
- package/electron/workspace-ui/workspace-matrix.js +46 -9
- package/electron/workspace-ui/workspace-panels.js +88 -19
- package/electron/workspace-ui/workspace-render.js +17 -1
- package/electron/workspace-ui/workspace-verbs.js +48 -2
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +1 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/error.schema.json +1 -1
- package/schemas/event.schema.json +1 -1
- package/schemas/pack.schema.json +30 -0
- package/schemas/progress.schema.json +13 -1
- package/schemas/run-live.schema.json +1 -1
- package/schemas/run.schema.json +2 -1
- package/schemas/spend.schema.json +52 -4
- package/schemas/wave-live.schema.json +1 -1
- package/schemas/wave.schema.json +2 -1
- package/skills/second-opinion/SKILL.md +5 -0
- package/src/cli-handlers-council-run.js +51 -8
- package/src/cli-handlers-pack.js +238 -0
- package/src/cli-handlers-run.js +36 -8
- package/src/cli-handlers-spend.js +20 -2
- package/src/cli-handlers-template.js +53 -0
- package/src/cli-handlers-watch.js +11 -0
- package/src/cli.js +68 -5
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +24 -1
- package/src/council/findings.js +199 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/presets-cli.js +23 -11
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +64 -11
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +29 -1
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +134 -110
- package/src/council/run-state.js +40 -1
- package/src/council/run.js +45 -47
- package/src/council/tally.js +10 -0
- package/src/headless.js +180 -7
- package/src/mcp-council-run.js +108 -4
- package/src/mcp-server.js +203 -7
- package/src/mcp-tools.js +15 -5
- package/src/observe/council-legs.js +60 -3
- package/src/observe/live-doc.js +18 -1
- package/src/observe/watch-render.js +4 -1
- package/src/pack/pack-cli.js +38 -0
- package/src/pack/pack-forward.js +96 -0
- package/src/pack/pack-resolve.js +297 -0
- package/src/pack/pack-store.js +130 -0
- package/src/pack/pack-validate.js +113 -0
- package/src/sidecar/child-sessions.js +1 -2
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +6 -0
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +82 -74
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +42 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/start.js +5 -4
- package/src/sidecar/workspace-auto-open.js +69 -0
- package/src/sidecar/workspace-window.js +46 -1
- package/src/spend-query.js +17 -5
- package/src/template/apply.js +88 -0
- package/src/template/render.js +86 -0
- package/src/template/store.js +106 -0
- package/src/utils/config.js +65 -25
- package/src/utils/error-doc.js +5 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +39 -1
- package/src/utils/pricing.js +26 -10
- package/src/utils/result-schema-rebuild.js +1 -0
- package/src/utils/result-schema.js +8 -2
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +66 -7
- package/src/workspace/fold-format.js +33 -4
- package/src/workspace/live-normalize.js +28 -15
- package/src/workspace/run-detail.js +13 -1
package/src/utils/config.js
CHANGED
|
@@ -401,14 +401,57 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
401
401
|
return { members: null, builtin: false };
|
|
402
402
|
}
|
|
403
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Per-member alias/catalog classification — the SAME check `resolveCouncilMembers`
|
|
406
|
+
* (below) uses to decide the real run path's bench, extracted so `amicus council
|
|
407
|
+
* show` (council/presets-cli.js) can reuse it verbatim instead of re-deriving a
|
|
408
|
+
* parallel (and, pre-v4.5-Wave-2, drifted) check. Each member is resolved to its
|
|
409
|
+
* full model id (alias → id via effective aliases; a member containing '/' is
|
|
410
|
+
* taken as-is) and that id checked against the cached catalog. Tri-state catalog
|
|
411
|
+
* rule: an EMPTY catalog (offline / never fetched) never drops anything —
|
|
412
|
+
* "unknown" is not "delisted" — and a local-vendor member is never dropped on
|
|
413
|
+
* catalog absence either way (v4.2 §4.4: a local server may simply have been off
|
|
414
|
+
* at the last refresh; the leg itself fails pre-flight with the actionable
|
|
415
|
+
* local_endpoint_unreachable error if it is truly down). Only a NON-EMPTY
|
|
416
|
+
* catalog that omits the resolved id is a definitive drop.
|
|
417
|
+
* @param {string[]} members raw council members (aliases or provider/model ids)
|
|
418
|
+
* @param {Array<{id:string}>} [catalog]
|
|
419
|
+
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>}}
|
|
420
|
+
* `dropped` is the flat member-ref list (unchanged shape, pre-v4.5-Wave-2
|
|
421
|
+
* callers keep working); `droppedMembers` additively pairs each with WHY.
|
|
422
|
+
*/
|
|
423
|
+
function classifyCouncilMembers(members, catalog = []) {
|
|
424
|
+
const aliases = getEffectiveAliases();
|
|
425
|
+
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
426
|
+
const { isLocalProvider } = require('./local-providers');
|
|
427
|
+
const models = [];
|
|
428
|
+
const dropped = [];
|
|
429
|
+
const droppedMembers = [];
|
|
430
|
+
for (const member of members) {
|
|
431
|
+
const id = member.includes('/') ? member : aliases[member];
|
|
432
|
+
if (!id) { // alias no longer resolves
|
|
433
|
+
dropped.push(member);
|
|
434
|
+
droppedMembers.push({ member, reason: 'alias no longer resolves to a known model' });
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
438
|
+
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
439
|
+
if (known.size > 0 && !known.has(id)) { // delisted model
|
|
440
|
+
dropped.push(member);
|
|
441
|
+
droppedMembers.push({ member, reason: 'resolved id is not present in the cached model catalog' });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
models.push(member);
|
|
445
|
+
}
|
|
446
|
+
return { models, dropped, droppedMembers };
|
|
447
|
+
}
|
|
448
|
+
|
|
404
449
|
/**
|
|
405
450
|
* Expand a saved council into a runnable members list, degrading gracefully.
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* skipped when the catalog is empty (offline). Returns members RAW (alias or
|
|
411
|
-
* id) — leg-time validation resolves them again.
|
|
451
|
+
* Unresolvable aliases and delisted ids are dropped with a warning rather than
|
|
452
|
+
* fail-fast-aborting the whole wave (classification: classifyCouncilMembers
|
|
453
|
+
* above). Returns members RAW (alias or id) — leg-time validation resolves
|
|
454
|
+
* them again.
|
|
412
455
|
*
|
|
413
456
|
* Resolution order: user config (`config.councils`) is checked first; when
|
|
414
457
|
* `name` is absent there, the built-in benches (`free`/`budget`/`frontier`)
|
|
@@ -416,7 +459,7 @@ function getCouncilWithSource(name, catalog = []) {
|
|
|
416
459
|
* shadows a built-in of the same name.
|
|
417
460
|
* @param {string} name
|
|
418
461
|
* @param {Array<{id:string}>} [catalog]
|
|
419
|
-
* @returns {{models:string[], dropped:string[]} | {error:string}}
|
|
462
|
+
* @returns {{models:string[], dropped:string[], droppedMembers:Array<{member:string, reason:string}>} | {error:string}}
|
|
420
463
|
*/
|
|
421
464
|
function resolveCouncilMembers(name, catalog = []) {
|
|
422
465
|
const { members } = getCouncilWithSource(name, catalog);
|
|
@@ -426,23 +469,7 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
426
469
|
if (!Array.isArray(members) || members.length === 0) {
|
|
427
470
|
return { error: `Council '${name}' is empty. Run 'amicus setup' to populate it.` };
|
|
428
471
|
}
|
|
429
|
-
const
|
|
430
|
-
const known = new Set((Array.isArray(catalog) ? catalog : []).map(m => m && m.id).filter(Boolean));
|
|
431
|
-
const { isLocalProvider } = require('./local-providers');
|
|
432
|
-
const models = [];
|
|
433
|
-
const dropped = [];
|
|
434
|
-
for (const member of members) {
|
|
435
|
-
const id = member.includes('/') ? member : aliases[member];
|
|
436
|
-
if (!id) { dropped.push(member); continue; } // alias no longer resolves
|
|
437
|
-
const vendor = typeof id === 'string' ? id.split('/')[0] : '';
|
|
438
|
-
// v4.2 §4.4: a local server may simply have been off at the last catalog
|
|
439
|
-
// refresh — that is "unknown", not "delisted". Never drop a local-vendor
|
|
440
|
-
// member on catalog absence; the leg itself fails pre-flight with the
|
|
441
|
-
// actionable local_endpoint_unreachable error if the server is truly down.
|
|
442
|
-
if (isLocalProvider(vendor)) { models.push(member); continue; }
|
|
443
|
-
if (known.size > 0 && !known.has(id)) { dropped.push(member); continue; } // delisted model
|
|
444
|
-
models.push(member);
|
|
445
|
-
}
|
|
472
|
+
const { models, dropped, droppedMembers } = classifyCouncilMembers(members, catalog);
|
|
446
473
|
if (models.length < 2) {
|
|
447
474
|
return {
|
|
448
475
|
error: `Council '${name}' has fewer than 2 usable members` +
|
|
@@ -450,7 +477,7 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
450
477
|
'. Run \'amicus setup\' to refresh it.',
|
|
451
478
|
};
|
|
452
479
|
}
|
|
453
|
-
return { models, dropped };
|
|
480
|
+
return { models, dropped, droppedMembers };
|
|
454
481
|
}
|
|
455
482
|
|
|
456
483
|
/** @returns {{prefer:'direct'|'openrouter', migration_notified:Object}} routing config with defaults */
|
|
@@ -503,6 +530,17 @@ function hasTierOnboarded() {
|
|
|
503
530
|
return !!(config.routing && config.routing.tier_onboarded === true);
|
|
504
531
|
}
|
|
505
532
|
|
|
533
|
+
/**
|
|
534
|
+
* v4.5 auto-open (spec §6 guard 4): the Workspace auto-opens on MCP council
|
|
535
|
+
* runs from Claude Code (local) unless config.workspace.autoOpen === false. Only an
|
|
536
|
+
* explicit false disables — absent/junk values stay ON (opt-out semantics).
|
|
537
|
+
* @returns {boolean}
|
|
538
|
+
*/
|
|
539
|
+
function getWorkspaceAutoOpen() {
|
|
540
|
+
const config = loadConfig() || {};
|
|
541
|
+
return !(config.workspace && config.workspace.autoOpen === false);
|
|
542
|
+
}
|
|
543
|
+
|
|
506
544
|
/**
|
|
507
545
|
* Persist the one-time onboarding-notice flag, preserving any other routing
|
|
508
546
|
* keys (prefer, tier, migration_notified). Best-effort: swallows any
|
|
@@ -562,6 +600,7 @@ module.exports = {
|
|
|
562
600
|
getCouncils,
|
|
563
601
|
getCouncil,
|
|
564
602
|
getCouncilWithSource,
|
|
603
|
+
classifyCouncilMembers,
|
|
565
604
|
resolveCouncilMembers,
|
|
566
605
|
getRoutingConfig,
|
|
567
606
|
resolveGatewayMode,
|
|
@@ -571,4 +610,5 @@ module.exports = {
|
|
|
571
610
|
setCostTier,
|
|
572
611
|
hasTierOnboarded,
|
|
573
612
|
markTierOnboarded,
|
|
613
|
+
getWorkspaceAutoOpen,
|
|
574
614
|
};
|
package/src/utils/error-doc.js
CHANGED
|
@@ -24,6 +24,11 @@ const ERROR_CODES = Object.freeze({
|
|
|
24
24
|
COST_EXCEEDED: 'COST_EXCEEDED', // council run: whole-run --max-cost ceiling hit pre-tally (v4.0 §4)
|
|
25
25
|
// council run: --claude-review file unreadable/invalid, or --chair claude (v4.1 §4.4)
|
|
26
26
|
COUNCIL_CLAUDE_REVIEW_INVALID: 'COUNCIL_CLAUDE_REVIEW_INVALID',
|
|
27
|
+
TEMPLATE_NOT_FOUND: 'TEMPLATE_NOT_FOUND', // --template name/path unresolvable at run time (v4.5 F9)
|
|
28
|
+
TEMPLATE_RENDER: 'TEMPLATE_RENDER', // strict render rule violated: unknown var, slot/data mismatch (v4.5 F9)
|
|
29
|
+
PACK_NOT_FOUND: 'PACK_NOT_FOUND', // --pack name not in packs dir / path unreadable (v4.5 B7/F5)
|
|
30
|
+
PACK_INVALID: 'PACK_INVALID', // pack schema/structural/seat validation failure (v4.5 B7/F5)
|
|
31
|
+
PACK_KIND_MISMATCH: 'PACK_KIND_MISMATCH', // e.g. a council pack passed to fanout (v4.5 B7/F5)
|
|
27
32
|
});
|
|
28
33
|
|
|
29
34
|
/**
|
package/src/utils/lifecycle.js
CHANGED
|
@@ -41,4 +41,40 @@ function armExitWatchdog(code = 0, ms = 1500, deps = {}) {
|
|
|
41
41
|
return t;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* An `exit` hook for armExitWatchdog that REAPS an OpenCode server this process
|
|
46
|
+
* is using but does not own, on its way out (v4.4.1 fix wave, finding F3).
|
|
47
|
+
*
|
|
48
|
+
* The force-exit path is the one place where "not ours to close" stops being the
|
|
49
|
+
* safe answer. A wave running on an injected server deliberately never closes it
|
|
50
|
+
* — the owner does, once, in its own finalize. But if the watchdog fires first
|
|
51
|
+
* the parent dies anyway, and the Go server survives it: an orphan still holding
|
|
52
|
+
* the OpenCode SQLite lock that the per-run shared server exists to stop
|
|
53
|
+
* contending on. Before the external-server seam, fanout's own close() covered
|
|
54
|
+
* this by accident; this restores it deliberately.
|
|
55
|
+
*
|
|
56
|
+
* SIGTERM only, and synchronous: process.exit cannot await server.close()'s
|
|
57
|
+
* escalation, so this sends the one signal that fits in the window and gets out
|
|
58
|
+
* of the way. Never signals this process, and never lets a dead/absent pid throw
|
|
59
|
+
* — the exit must happen regardless.
|
|
60
|
+
*
|
|
61
|
+
* @param {{goPid?: number|null}|null} server the injected server handle
|
|
62
|
+
* @param {{kill?: Function, exit?: Function}} [deps] test seams
|
|
63
|
+
* @returns {(code: number) => void}
|
|
64
|
+
*/
|
|
65
|
+
function exitReaping(server, deps = {}) {
|
|
66
|
+
return (code) => {
|
|
67
|
+
// Resolved at CALL time, not creation time: this hook is built the instant a
|
|
68
|
+
// signal lands and invoked up to 10s later, so binding process.kill/exit
|
|
69
|
+
// early would freeze whatever was installed at signal time.
|
|
70
|
+
const kill = deps.kill || ((p, sig) => process.kill(p, sig));
|
|
71
|
+
const exit = deps.exit || ((c) => process.exit(c));
|
|
72
|
+
const pid = server && server.goPid;
|
|
73
|
+
if (pid && pid !== process.pid) {
|
|
74
|
+
try { kill(pid, 'SIGTERM'); } catch { /* already gone: nothing to reap */ }
|
|
75
|
+
}
|
|
76
|
+
exit(code);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { isOneShotCommand, armExitWatchdog, exitReaping, ONE_SHOT_COMMANDS };
|
package/src/utils/path-fence.js
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* for truthiness by src/council/run-state.js's readPointer, so nothing
|
|
8
8
|
* upstream of this check guarantees it stays inside the project).
|
|
9
9
|
*
|
|
10
|
-
* It is a LEAF: `fs` + `path` and nothing else, no require cycle
|
|
10
|
+
* It is a LEAF: `fs` + `path` at load time and nothing else, no require cycle
|
|
11
|
+
* possible. (SEC-3's debug-mode diagnostic requires src/utils/logger.js lazily,
|
|
12
|
+
* inside the flag branch — logger.js has zero requires of its own, so even that
|
|
13
|
+
* cannot introduce a cycle, and nothing is loaded at all with the flag unset.)
|
|
11
14
|
* That was first needed inside the v4.4 workspace layer —
|
|
12
15
|
* src/workspace/artifact-guard.js requires src/workspace/run-scan.js for
|
|
13
16
|
* readPointer, so if run-scan.js also required artifact-guard.js for this
|
|
@@ -30,6 +33,39 @@
|
|
|
30
33
|
const fs = require('fs');
|
|
31
34
|
const path = require('path');
|
|
32
35
|
|
|
36
|
+
/**
|
|
37
|
+
* ⚠️ SEC-3: isRealpathContained is a pure string-prefix comparison, and it is only SOUND
|
|
38
|
+
* if both arguments were already resolved through realpathSync. Nothing enforced that, so
|
|
39
|
+
* a caller who forgot got a silently WEAKER check rather than an error — a raw
|
|
40
|
+
* (unresolved) path containing a symlink can prefix-match a directory it does not
|
|
41
|
+
* physically live under.
|
|
42
|
+
*
|
|
43
|
+
* This is a DIAGNOSTIC, opt-in via `AMICUS_DEBUG_FENCE=1`, and it MUST NEVER THROW: the
|
|
44
|
+
* fence must never become the failure it exists to prevent. Every step is wrapped, and a
|
|
45
|
+
* non-existent path is a legitimate argument here (callers fence paths before probing
|
|
46
|
+
* them), so it is not reported. The logger is required lazily so this module keeps its
|
|
47
|
+
* `fs` + `path` load-time surface (see the header) for the 99.99% of calls that run with
|
|
48
|
+
* the flag unset.
|
|
49
|
+
* @param {string} dir already-stringified dirRealPath
|
|
50
|
+
* @param {string} target already-stringified targetRealPath
|
|
51
|
+
*/
|
|
52
|
+
function assertResolvedArgs(dir, target) {
|
|
53
|
+
try {
|
|
54
|
+
const { logger } = require('./logger');
|
|
55
|
+
for (const [name, p] of [['dirRealPath', dir], ['targetRealPath', target]]) {
|
|
56
|
+
if (!path.isAbsolute(p)) {
|
|
57
|
+
logger.warn('path-fence: argument is not absolute', { arg: name, value: p });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(p) && fs.realpathSync(p) !== p) {
|
|
62
|
+
logger.warn('path-fence: argument is not realpath-resolved', { arg: name, value: p });
|
|
63
|
+
}
|
|
64
|
+
} catch { /* unreadable / dangling: not this diagnostic's business */ }
|
|
65
|
+
}
|
|
66
|
+
} catch { /* a broken logger must never break the fence */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
/**
|
|
34
70
|
* True when `targetRealPath` is exactly `dirRealPath` or a proper descendant
|
|
35
71
|
* of it. Both arguments MUST already be resolved through realpathSync — this
|
|
@@ -41,6 +77,8 @@ const path = require('path');
|
|
|
41
77
|
function isRealpathContained(dirRealPath, targetRealPath) {
|
|
42
78
|
const dir = String(dirRealPath);
|
|
43
79
|
const target = String(targetRealPath);
|
|
80
|
+
// SEC-3: debug-mode contract check only — never a gate, never a throw (see above).
|
|
81
|
+
if (process.env.AMICUS_DEBUG_FENCE === '1') { assertResolvedArgs(dir, target); }
|
|
44
82
|
if (target === dir) { return true; }
|
|
45
83
|
// ⚠️ COUNCIL REVIEW R2 (A6): when dirRealPath IS a filesystem root, it already
|
|
46
84
|
// ends in a separator ('/' on POSIX, 'C:\\' on Windows) — blindly appending
|
package/src/utils/pricing.js
CHANGED
|
@@ -63,24 +63,37 @@ function lookupPricing(modelId) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Did we actually OBSERVE
|
|
66
|
+
* Did we actually OBSERVE tokens THIS MODULE'S ESTIMATE CAN PRICE? (v4.4 B2,
|
|
67
|
+
* narrowed by v4.4.1 CA-7)
|
|
67
68
|
*
|
|
68
69
|
* This is the predicate that separates "the provider billed us for a $0 tier"
|
|
69
70
|
* from "we never saw a usage payload at all" — a distinction the old
|
|
70
71
|
* `pricing && tokens` guard could not make, because it only inspected the
|
|
71
|
-
* PRICE.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* PRICE. It reads the normalized totals shape (input/output, as produced by
|
|
73
|
+
* sumPerMessageUsage) and OpenCode's raw per-message shape alike, because both
|
|
74
|
+
* carry `input`/`output` at the top level.
|
|
75
|
+
*
|
|
76
|
+
* ⚠️ CA-7: deliberately narrower than "did we see any token count at all". The
|
|
77
|
+
* estimate in resolveLegCost prices input/output ONLY, so accepting
|
|
78
|
+
* cacheRead/cacheWrite — and `reasoning`, which this predicate also used to
|
|
79
|
+
* accept and which the estimate likewise never prices — let a leg observed with
|
|
80
|
+
* none of input/output pass the observation gate and resolve to `estimated $0`:
|
|
81
|
+
* the same false-zero class the v4.4 observed-tokens fix exists to kill, in the
|
|
82
|
+
* one corner its predicate did not cover. Such a leg is now `unknown`, which is
|
|
83
|
+
* true, rather than free, which is not. Pricing cache or reasoning tokens
|
|
84
|
+
* properly needs catalog fields that may not exist; when they do, widen this and
|
|
85
|
+
* the estimate together, never one alone.
|
|
86
|
+
*
|
|
87
|
+
* Live-validated 2026-07-26: a real local leg reports `input: 25953, output: 3`,
|
|
88
|
+
* so the shipped v4.2 free-local `$0` promise (a local seat costs nothing but
|
|
89
|
+
* still reports real input/output) is untouched — tests/pricing-local.test.js
|
|
90
|
+
* is the standing guard on that.
|
|
74
91
|
* @param {object|null|undefined} tokens
|
|
75
92
|
* @returns {boolean}
|
|
76
93
|
*/
|
|
77
94
|
function hasObservedTokens(tokens) {
|
|
78
95
|
if (!tokens || typeof tokens !== 'object') { return false; }
|
|
79
|
-
|
|
80
|
-
const cacheRead = tokens.cacheRead || cache.read || 0;
|
|
81
|
-
const cacheWrite = tokens.cacheWrite || cache.write || 0;
|
|
82
|
-
return (tokens.input || 0) > 0 || (tokens.output || 0) > 0
|
|
83
|
-
|| (tokens.reasoning || 0) > 0 || cacheRead > 0 || cacheWrite > 0;
|
|
96
|
+
return (tokens.input || 0) > 0 || (tokens.output || 0) > 0;
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
/** @returns {{amount:number|null, currency:'USD', source:'reported'|'estimated'|'unknown'}} */
|
|
@@ -96,7 +109,10 @@ function resolveLegCost({ reportedCost, tokens, pricing }) {
|
|
|
96
109
|
// The v4.2 §4.5 free-local-tier carve-out is DELIBERATELY preserved: a local
|
|
97
110
|
// Ollama/LM Studio seat legitimately costs $0 but still reports tokens, so
|
|
98
111
|
// hasObservedTokens() is true for it and it still resolves to `estimated $0`.
|
|
99
|
-
//
|
|
112
|
+
// v4.4.1 CA-7: a leg with no input/output falls through to `unknown` even when
|
|
113
|
+
// it reported cache or reasoning tokens — those are real observations, but not
|
|
114
|
+
// ones THIS estimate can turn into a price, so calling the result $0 would be
|
|
115
|
+
// the same fabrication one corner over. See hasObservedTokens above.
|
|
100
116
|
if (pricing && hasObservedTokens(tokens)) {
|
|
101
117
|
const est = (tokens.input || 0) * pricing.prompt + (tokens.output || 0) * pricing.completion;
|
|
102
118
|
if (est >= 0) { return { amount: est, currency: 'USD', source: 'estimated' }; }
|
|
@@ -90,6 +90,7 @@ function buildWaveResultFromSession(project, waveId) {
|
|
|
90
90
|
waveId,
|
|
91
91
|
legs,
|
|
92
92
|
promptMeta: meta.promptMeta || null,
|
|
93
|
+
...(meta.pack ? { pack: meta.pack } : {}), // v4.5 Task 13: absent-not-null, mirrors promptMeta's sourcing above.
|
|
93
94
|
createdAt: meta.createdAt || null,
|
|
94
95
|
completedAt: meta.completedAt || null,
|
|
95
96
|
});
|
|
@@ -44,7 +44,9 @@ function durationBetween(createdAt, completedAt) {
|
|
|
44
44
|
* @param {string|null} [opts.modelInput] - What the caller typed (alias), if known
|
|
45
45
|
* @param {string|null} [opts.sessionDir]
|
|
46
46
|
* @param {string|null} [opts.waveId] - Explicit wave id (falls back to metadata.parentWave)
|
|
47
|
-
* @returns {object} run document
|
|
47
|
+
* @returns {object} run document; `pack` (v4.5 Task 13) is additive — present only when
|
|
48
|
+
* metadata.pack was recorded (solo session launched via --pack), sourced straight off
|
|
49
|
+
* `metadata` like `usage`/`opencodeSessionId` already are (no new function parameter needed).
|
|
48
50
|
*/
|
|
49
51
|
function buildRunResult({ taskId, metadata = {}, result = null, summary = null, modelInput = null, sessionDir = null, waveId = null, usage = null }) {
|
|
50
52
|
const status = result ? statusFromResult(result) : (metadata.status || 'unknown');
|
|
@@ -68,6 +70,7 @@ function buildRunResult({ taskId, metadata = {}, result = null, summary = null,
|
|
|
68
70
|
sessionDir,
|
|
69
71
|
opencodeSessionId: metadata.opencodeSessionId || null,
|
|
70
72
|
usage: usage !== null ? usage : (metadata.usage || null),
|
|
73
|
+
...(metadata.pack ? { pack: metadata.pack } : {}),
|
|
71
74
|
};
|
|
72
75
|
}
|
|
73
76
|
|
|
@@ -122,9 +125,11 @@ function waveExitCode(waveStatus) {
|
|
|
122
125
|
* @param {string|null} [opts.completedAt]
|
|
123
126
|
* @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
|
|
124
127
|
* @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
|
|
128
|
+
* @param {{name: string, version: string, hash: string, source: string}|null} [opts.pack] - v4.5 Task 13:
|
|
129
|
+
* additive — present only when the wave was launched via --pack (absent, never null, otherwise).
|
|
125
130
|
* @returns {object} wave document
|
|
126
131
|
*/
|
|
127
|
-
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
|
|
132
|
+
function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [], pack = null }) {
|
|
128
133
|
const { sumWaveUsage } = require('./pricing');
|
|
129
134
|
// Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
|
|
130
135
|
// 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
|
|
@@ -151,6 +156,7 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
|
|
|
151
156
|
durationMs,
|
|
152
157
|
usage: sumWaveUsage(legs),
|
|
153
158
|
notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
|
|
159
|
+
...(pack ? { pack } : {}),
|
|
154
160
|
};
|
|
155
161
|
}
|
|
156
162
|
|
|
@@ -75,10 +75,88 @@ function ensurePortAvailable(port = DEFAULT_PORT) {
|
|
|
75
75
|
return false;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A start failure that is a LOCK RACE, not a deterministic error.
|
|
80
|
+
*
|
|
81
|
+
* OpenCode opens one shared SQLite database (~/.local/share/opencode/opencode.db)
|
|
82
|
+
* at startup, so two servers starting in the same instant can collide on it and
|
|
83
|
+
* the loser exits 1 with `database is locked`. Measured: council run v441plan01
|
|
84
|
+
* lost four of five seats in 736ms to exactly this.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately NARROW. A missing binary, a bad key, a busy port and a config
|
|
87
|
+
* error are all deterministic — retrying them only triples the latency before
|
|
88
|
+
* the same failure, so they must fall straight through untouched.
|
|
89
|
+
*/
|
|
90
|
+
const LOCK_CLASS_START_FAILURE = /database is locked|database table is locked|SQLITE_BUSY/i;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Backoff between start attempts; 5 attempts total, ≤3.75s of added latency.
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ WIDENED from 3 attempts at 250/500ms (≤750ms) by v4.4.1 Step 10.5. 750ms is
|
|
96
|
+
* thin against a multi-megabyte WAL and any concurrent process touching the same
|
|
97
|
+
* OpenCode database: run v441plan02 exhausted it, the council degraded to one
|
|
98
|
+
* server per wave, and then lost four of five seats to the very race the retry
|
|
99
|
+
* exists to survive. The wait is bounded and paid at most once per acquisition;
|
|
100
|
+
* the cost of not waiting is a dead bench.
|
|
101
|
+
*
|
|
102
|
+
* Still lock-class ONLY — a deterministic failure (missing binary, bad key, busy
|
|
103
|
+
* port, config error) never sleeps a single millisecond here.
|
|
104
|
+
*/
|
|
105
|
+
const LOCK_RETRY_DELAYS_MS = [250, 500, 1000, 2000];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {Error|null} error
|
|
109
|
+
* @returns {boolean} true only for a lock-class (retryable) start failure
|
|
110
|
+
*/
|
|
111
|
+
function isLockClassStartFailure(error) {
|
|
112
|
+
if (!error) { return false; }
|
|
113
|
+
// The real failure arrives as a message with the server's own stdout inlined
|
|
114
|
+
// ("Server exited with code 1 / Server output: … database is locked"), but
|
|
115
|
+
// check the usual carriers too so a wrapped/spawn-shaped error still matches.
|
|
116
|
+
const carriers = [error.message, error.stderr, error.stdout, error.cause && error.cause.message];
|
|
117
|
+
return carriers.some(c => typeof c === 'string' && LOCK_CLASS_START_FAILURE.test(c));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run `attempt` with a BOUNDED retry on a lock-class failure only.
|
|
122
|
+
*
|
|
123
|
+
* Never fails closed: the final failure is rethrown unchanged, so every caller
|
|
124
|
+
* that already degrades on a start failure (runFanout writes an error wave; a
|
|
125
|
+
* council run falls back to per-wave servers) degrades exactly as before —
|
|
126
|
+
* just later, and far less often. This is the half of the fix that covers what
|
|
127
|
+
* a per-run shared server cannot: two separate `amicus` processes, or a CLI run
|
|
128
|
+
* beside a live MCP server, still contend for the same database.
|
|
129
|
+
*
|
|
130
|
+
* @param {(attempt: number) => Promise<T>} attempt
|
|
131
|
+
* @param {{retryDelayMs?: number}} [opts] retryDelayMs: test seam — collapses
|
|
132
|
+
* every backoff to this value so a retry test does not sleep for real.
|
|
133
|
+
* @returns {Promise<T>}
|
|
134
|
+
* @template T
|
|
135
|
+
*/
|
|
136
|
+
async function retryOnLockRace(attempt, opts = {}) {
|
|
137
|
+
const delays = opts.retryDelayMs === undefined
|
|
138
|
+
? LOCK_RETRY_DELAYS_MS
|
|
139
|
+
: LOCK_RETRY_DELAYS_MS.map(() => opts.retryDelayMs);
|
|
140
|
+
for (let i = 0; ; i += 1) {
|
|
141
|
+
try {
|
|
142
|
+
return await attempt(i);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (i >= delays.length || !isLockClassStartFailure(error)) { throw error; }
|
|
145
|
+
logger.warn('OpenCode server start lost a lock race — retrying', {
|
|
146
|
+
attempt: i + 1, of: delays.length + 1, delayMs: delays[i], error: error.message,
|
|
147
|
+
});
|
|
148
|
+
await new Promise(resolve => setTimeout(resolve, delays[i]));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
78
153
|
module.exports = {
|
|
79
154
|
DEFAULT_PORT,
|
|
155
|
+
LOCK_RETRY_DELAYS_MS,
|
|
80
156
|
isPortInUse,
|
|
81
157
|
getPortPid,
|
|
82
158
|
killPortProcess,
|
|
83
|
-
ensurePortAvailable
|
|
159
|
+
ensurePortAvailable,
|
|
160
|
+
isLockClassStartFailure,
|
|
161
|
+
retryOnLockRace
|
|
84
162
|
};
|
|
@@ -45,7 +45,10 @@ const SPEND_LEDGER_FILE = 'spend-ledger.jsonl';
|
|
|
45
45
|
* @param {string} [opts.waveId] present for a fanout leg
|
|
46
46
|
* @param {string} opts.model resolved model id (or alias, if that's all the caller has)
|
|
47
47
|
* @param {'headless'|'interactive'|'leg'} opts.mode
|
|
48
|
-
* @param {{tokens:object, cost:{amount:number|null,currency:string,source:string}
|
|
48
|
+
* @param {{tokens:object, cost:{amount:number|null,currency:string,source:string},
|
|
49
|
+
* subtreeUnknown?:boolean}|null} opts.usage `subtreeUnknown` (v4.4.1 CA-2) is
|
|
50
|
+
* copied onto the row when truthy — this leg's own cost resolved, but a child
|
|
51
|
+
* session it spawned could not be priced, so the row's `cost` is a FLOOR
|
|
49
52
|
* @param {string} [opts.op] 'leg' | 'start' | 'continue' | 'resume'
|
|
50
53
|
* @param {string} [opts.status] terminal status
|
|
51
54
|
* @param {string} [opts.councilRunId] council run id (additive attribution)
|
|
@@ -86,18 +89,36 @@ function appendSpend({ taskId, waveId, model, mode, usage,
|
|
|
86
89
|
if (attempt !== undefined) { row.attempt = attempt; }
|
|
87
90
|
if (substitutedFor !== undefined) { row.substitutedFor = substitutedFor; }
|
|
88
91
|
if (retryOfWaveId !== undefined) { row.retryOfWaveId = retryOfWaveId; }
|
|
92
|
+
// v4.4.1 CA-2: a leg whose OWN cost is known but which spawned a child
|
|
93
|
+
// session the walk could not price writes a PRICED row — so `unpricedRows`
|
|
94
|
+
// never catches it and `amicus spend` reads as a complete measurement while
|
|
95
|
+
// `council run` says `costExact:false` about the same dollars. Omitted (not
|
|
96
|
+
// `|| false`) so a pre-4.4.1 row and an ordinary row stay identical, matching
|
|
97
|
+
// the linkage-field convention above.
|
|
98
|
+
if (usage.subtreeUnknown) { row.subtreeUnknown = true; }
|
|
89
99
|
fs.appendFileSync(path.join(dir, SPEND_LEDGER_FILE), JSON.stringify(row) + '\n');
|
|
90
100
|
} catch (e) {
|
|
91
101
|
logger.debug('spend-ledger append failed (best-effort, run unaffected)', { taskId, error: e.message });
|
|
92
102
|
}
|
|
93
103
|
}
|
|
94
104
|
|
|
95
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Read the ledger. Corrupt lines are skipped — and v4.4.1 A2 widens "corrupt"
|
|
107
|
+
* from "does not parse" to "does not parse AS A ROW". A line that is valid JSON
|
|
108
|
+
* but not a plain object (`"foo"`, `42`, `[1,2]`) used to survive `filter(Boolean)`
|
|
109
|
+
* and be treated as a row by every consumer: `aggregateSpend` counted it in
|
|
110
|
+
* `runs`, scored it into `unpricedRows`/`sourceMix.unknown` off its absent cost
|
|
111
|
+
* block, and `--rows` echoed it into a published document that says rows are
|
|
112
|
+
* objects. A scalar in a JSONL ledger of row objects is a corrupt line, so it is
|
|
113
|
+
* now dropped like any other — one fewer way the totals can be inflated by damage.
|
|
114
|
+
* @param {string} [dir] @returns {Array<object>} parsed rows; corrupt lines skipped
|
|
115
|
+
*/
|
|
96
116
|
function readSpendRows(dir) {
|
|
97
117
|
const file = path.join(dir || getConfigDir(), SPEND_LEDGER_FILE);
|
|
98
118
|
if (!fs.existsSync(file)) { return []; }
|
|
99
119
|
return fs.readFileSync(file, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean)
|
|
100
|
-
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
120
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
121
|
+
.filter(r => r !== null && typeof r === 'object' && !Array.isArray(r));
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
module.exports = { appendSpend, readSpendRows, SPEND_LEDGER_FILE, SPEND_LEDGER_SCHEMA_VERSION };
|
|
@@ -24,6 +24,9 @@ const FIXED_ARTIFACTS = Object.freeze(['briefing-stage1.md', 'bundle-stage2.md',
|
|
|
24
24
|
// `artifact not allowed: <name>`. Writers: tally-provisional.json = src/council/run.js:199;
|
|
25
25
|
// revote-bundle.md = run-debate.js:119; debate.json = run-debate.js:261; the per-seat
|
|
26
26
|
// rebuttal-/revote- pair = materializeDebate (run-launch.js:127-136).
|
|
27
|
+
// ⚠️ FIVE KINDS, THREE ENTRIES — that is not a miscount (v4.4.1 DOC-7, re-verified). This const
|
|
28
|
+
// holds only the three RUN-LEVEL names; the last two of the five, the rebuttal-/revote- pair, are
|
|
29
|
+
// per BENCH MODEL and are appended inside artifactAllowlist below, next to review-/judge-.
|
|
27
30
|
const DEBATE_ARTIFACTS = Object.freeze(['tally-provisional.json', 'revote-bundle.md', 'debate.json']);
|
|
28
31
|
const MAX_ARTIFACT_BYTES = 200 * 1024;
|
|
29
32
|
|
|
@@ -93,14 +96,37 @@ function artifactAllowlist(run) {
|
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
// ⚠️ Task 18 (RN-1): the collision above is a real run-integrity defect — the run directory
|
|
100
|
+
// physically holds ONE file where two models' artifacts should be, and no renderer trick can
|
|
101
|
+
// recover both. What the renderer CAN stop doing is showing model A's prose under model B's
|
|
102
|
+
// name. Deterministic disambiguation: per colliding sanitized name, sort the RAW models
|
|
103
|
+
// (sorting, not insertion order, is what keeps this reproducible across processes/runs); the
|
|
104
|
+
// first (sorted) keeps the bare sanitized name, the rest get `~2`, `~3`, ... The suffixed
|
|
105
|
+
// names deliberately do not exist on disk — the presence manifest (run-detail.js, via
|
|
106
|
+
// fs.statSync over this same allowlist) marks them absent, so the renderer shows the honest
|
|
107
|
+
// "not written yet" empty state for every model but the first, instead of cross-matching.
|
|
108
|
+
const nameFor = new Map(); // raw model -> its (possibly suffixed) sanitized name
|
|
96
109
|
for (const m of uniqueModels) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
let s = sanitizeName(m);
|
|
111
|
+
const collision = collisionModels.get(s);
|
|
112
|
+
if (collision) {
|
|
113
|
+
const sortedRaw = [...collision].sort();
|
|
114
|
+
const index = sortedRaw.indexOf(m);
|
|
115
|
+
if (index > 0) { s = `${s}~${index + 1}`; }
|
|
116
|
+
}
|
|
117
|
+
nameFor.set(m, s);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const m of uniqueModels) {
|
|
121
|
+
const s = nameFor.get(m);
|
|
122
|
+
names.push(`review-${s}.md`);
|
|
123
|
+
names.push(`judge-${s}.md`);
|
|
124
|
+
// rebuttal-/revote- are keyed on the same BENCH ALIAS through the same (now possibly
|
|
125
|
+
// suffixed) name — materializeDebate is called with `d.raiser` / the revote leg's model
|
|
126
|
+
// (both aliases), so a colliding pair's debate artifacts are disambiguated the same way.
|
|
101
127
|
if (debated) {
|
|
102
|
-
names.push(`rebuttal-${
|
|
103
|
-
names.push(`revote-${
|
|
128
|
+
names.push(`rebuttal-${s}.md`);
|
|
129
|
+
names.push(`revote-${s}.md`);
|
|
104
130
|
}
|
|
105
131
|
}
|
|
106
132
|
// `uniqueModels` already collapsed genuinely-repeated bench entries, so this final Set is
|
|
@@ -112,6 +138,21 @@ function artifactAllowlist(run) {
|
|
|
112
138
|
sanitized, models: [...models],
|
|
113
139
|
}));
|
|
114
140
|
}
|
|
141
|
+
// Consumed by workspace-panels.js (wireLazyPanels' file lists + drillIntoJudge's artifact
|
|
142
|
+
// lookup), which prefers this map over re-deriving names via sanitizeName(model) directly —
|
|
143
|
+
// that re-derivation is exactly what would ignore the suffixing above and misattribute prose.
|
|
144
|
+
// ⚠️ Fix-wave (review finding 1) residual limit this map cannot close: the BARE (unsuffixed)
|
|
145
|
+
// name is still exactly ONE physical file on disk, and its actual bytes belong to whichever
|
|
146
|
+
// colliding model's writer ran LAST — no map can recover which one that was. The guarantee
|
|
147
|
+
// delivered here is narrower than "attribution is fully sound": at most the sorted-first
|
|
148
|
+
// model can still be misattributed under the bare name; artifactCollisions (the run-integrity
|
|
149
|
+
// banner rendered by workspace-app.js's renderBanners) is what covers that residual case.
|
|
150
|
+
list.artifactsByModel = Object.fromEntries(
|
|
151
|
+
[...nameFor].map(([m, s]) => [m, {
|
|
152
|
+
review: `review-${s}.md`, judge: `judge-${s}.md`,
|
|
153
|
+
rebuttal: `rebuttal-${s}.md`, revote: `revote-${s}.md`,
|
|
154
|
+
}]),
|
|
155
|
+
);
|
|
115
156
|
return list;
|
|
116
157
|
}
|
|
117
158
|
|
|
@@ -167,7 +208,25 @@ function readRunArtifact(project, runId, name, deps = {}) {
|
|
|
167
208
|
|
|
168
209
|
let realTarget;
|
|
169
210
|
try { realTarget = realpathSync(path.join(ptr.runDir, name)); }
|
|
170
|
-
catch
|
|
211
|
+
catch (err) {
|
|
212
|
+
// ⚠️ v4.4.1 RN-10: this catch used to answer `not written yet: <name>` for ANY realpath
|
|
213
|
+
// failure — ENOENT, EACCES, EPERM, EIO, ELOOP, a dangling symlink — so a permission problem
|
|
214
|
+
// was indistinguishable from a file the council simply has not produced yet. That is not a
|
|
215
|
+
// cosmetic conflation: electron/ipc-workspace.js's workspace:fold reads chair-output.md
|
|
216
|
+
// through this function, and on a permission error it produced a silent CHAIRLESS fold that
|
|
217
|
+
// still reported {ok: true}. The logger.warn it now emits was the mitigation — but it logged
|
|
218
|
+
// this string, so the log said "not written yet" about a file that was right there.
|
|
219
|
+
//
|
|
220
|
+
// ⚠️ Keep the sanitization. Do NOT re-interpolate `err.message`: a realpath failure's message
|
|
221
|
+
// embeds the full resolved path it tried to open, which round 4 deliberately stopped handing
|
|
222
|
+
// back over IPC (see the run.json catch above). `err.code` is a bare symbolic errno with no
|
|
223
|
+
// path in it, and it is the one piece an operator reading the fold warning actually needs —
|
|
224
|
+
// whitelisted to the errno character class so nothing else can ever ride out through here.
|
|
225
|
+
const code = err && typeof err.code === 'string' && /^[A-Z][A-Z0-9_]{1,15}$/.test(err.code)
|
|
226
|
+
? err.code : 'unknown';
|
|
227
|
+
if (code === 'ENOENT') { return { error: `not written yet: ${name}` }; }
|
|
228
|
+
return { error: `artifact unreadable (${code}): ${name}` };
|
|
229
|
+
}
|
|
171
230
|
if (!isRealpathContained(realDir, realTarget)) {
|
|
172
231
|
return { error: 'artifact escapes run directory' };
|
|
173
232
|
}
|