@worca/app 1.3.0 → 1.4.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/README.md +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The generic execution layer of the node-graph engine: output allocation from
|
|
4
4
|
// filename templates, the "## Ports (this run)" prompt block, prompt assembly, the
|
|
5
|
-
// clarifier gate, and the five flow-node executors.
|
|
5
|
+
// clarifier gate, the script dispatch and the five flow-node executors.
|
|
6
6
|
//
|
|
7
7
|
// GENERICITY CHARTER (hard rule for this module): there is NO agent-key branch
|
|
8
8
|
// anywhere. Executor selection is `node.kind` + `meta.runnerType`; renderer selection
|
|
@@ -32,22 +32,25 @@
|
|
|
32
32
|
// input left UNBOUND. The composite DRIVER is scheduler.mjs; this module owns the
|
|
33
33
|
// document — including the prompt block that tells a producer where to write the
|
|
34
34
|
// task files and what the manifest looks like.
|
|
35
|
-
import { join, dirname, relative
|
|
36
|
-
import { readFileSync, writeFileSync, mkdirSync
|
|
35
|
+
import { join, dirname, relative } from 'node:path';
|
|
36
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
37
37
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
38
38
|
|
|
39
39
|
import {
|
|
40
40
|
runClaude, MOCK_WRITER_ROLES, MOCK_ROLE_CLARIFY, MOCK_ROLE_DECOMPOSER,
|
|
41
41
|
} from '../claude-runner.mjs';
|
|
42
42
|
import { planPath, reviewPath, writeStepQuestions, writeClarify } from '../artifacts.mjs';
|
|
43
|
-
import { readReview,
|
|
43
|
+
import { readReview, classifyAskPayload } from '../protocol.mjs';
|
|
44
|
+
import { prepareFormAsk, formAnswerValidator, downgradeQuestion } from '../ask-forms.mjs';
|
|
44
45
|
import {
|
|
45
46
|
taskHeader, buildSystemPrompt, resolveAgentBody, mockMarkers, runOpts,
|
|
46
47
|
fanOutDirective, ctxFanOut, ctxSubagentModel, ctxEndpointRouted, workspaceFanOutDirective, workspaceDiffInstruction,
|
|
47
48
|
renderAnswers, siblingsBlock, diffInstruction, READ_WRITE_TOOLS, IMPLEMENTER_TOOLS, MEMORY_TOOLS,
|
|
49
|
+
askFormsBlock,
|
|
48
50
|
} from '../phases.mjs';
|
|
49
51
|
import { SUBAGENT_MODELS } from '../model-env.mjs';
|
|
50
52
|
import { AWAIT_PORT } from '../../shared/graph/constants.mjs';
|
|
53
|
+
import { runScriptExecution } from './script-runner.mjs';
|
|
51
54
|
|
|
52
55
|
/** The reserved synthesized gate input. Scheduler-only: it never reaches `bindings`,
|
|
53
56
|
* is never listed in the Ports block, selects no mode, and carries no renderer. */
|
|
@@ -382,68 +385,31 @@ export function resolveMockRole({ meta, expandsPort = null }) {
|
|
|
382
385
|
|
|
383
386
|
// ── verdicts ──────────────────────────────────────────────────────────────────
|
|
384
387
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Read a node's verdict JSON back through the protocol normalizer.
|
|
390
|
-
*
|
|
391
|
-
* Two degenerate cases, deliberately split (they are NOT the same failure):
|
|
392
|
-
* - the file was NEVER WRITTEN -> `{issues: [], summary: '', missing: true}`: a clean
|
|
393
|
-
* pass, v1 parity, because an agent that declares a verdict and writes none must not
|
|
394
|
-
* fail a run. `missing` is the flag the caller turns into a warning (and the reason
|
|
395
|
-
* the reviews table skips the row) instead of a phantom zero-issue review.
|
|
396
|
-
* - the file EXISTS but does not parse, or carries no `issues` array -> THROW. The
|
|
397
|
-
* verifier wrote garbage, and on every shipped seed the clean side is wired straight
|
|
398
|
-
* to End, so "no issues" there is indistinguishable from an approval. Fail-fast owns
|
|
399
|
-
* the rest.
|
|
400
|
-
*
|
|
401
|
-
* `readReview` is untouched for its other callers (it is v1 code with its own tolerant
|
|
402
|
-
* contract); the existsSync + parse-failure branch lives here.
|
|
403
|
-
*/
|
|
404
|
-
export async function readVerdict(verdictPath) {
|
|
405
|
-
if (!verdictPath) return { issues: [], summary: '' };
|
|
406
|
-
if (!existsSync(verdictPath)) return { issues: [], summary: '', missing: true };
|
|
407
|
-
let text;
|
|
408
|
-
try {
|
|
409
|
-
text = await readFile(verdictPath, 'utf8');
|
|
410
|
-
} catch (err) {
|
|
411
|
-
throw Object.assign(new Error(`verdict file unreadable: ${verdictPath} — ${err?.message || err}`),
|
|
412
|
-
{ code: 'BAD_VERDICT' });
|
|
413
|
-
}
|
|
414
|
-
const data = safeParseJson(text);
|
|
415
|
-
if (!data || typeof data !== 'object' || !Array.isArray(data.issues)) {
|
|
416
|
-
throw Object.assign(new Error(`verdict file is not a review JSON: ${verdictPath} — ${BAD_VERDICT_TAIL}`),
|
|
417
|
-
{ code: 'BAD_VERDICT' });
|
|
418
|
-
}
|
|
419
|
-
return normalizeReview(data);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** The warning line a missing verdict raises, relative to the pipeline dir so the
|
|
423
|
-
* run log stays readable. */
|
|
424
|
-
function missingVerdictWarning(ctx, verdictPath) {
|
|
425
|
-
const rel = ctx?.pipelineDir ? relative(ctx.pipelineDir, verdictPath) : basename(verdictPath);
|
|
426
|
-
return `verdict file missing: ${ctx?.nodeId || ctx?.node?.id || '?'} ${rel} — treated as clean`;
|
|
427
|
-
}
|
|
388
|
+
import { readVerdict, missingVerdictWarning, publishable } from './exec-io.mjs';
|
|
389
|
+
export { readVerdict, publishable };
|
|
428
390
|
|
|
429
391
|
// ── prompt assembly ───────────────────────────────────────────────────────────
|
|
430
392
|
|
|
431
393
|
/** The role-free base instruction, keyed by runnerType — never by an agent key. The
|
|
432
394
|
* producer/verifier sentences are v1's generic runners (phases.mjs:1207-1209 /
|
|
433
395
|
* :1245-1246); the clarifier sentence is v1's buildClarifyPrompt (:581-587) minus
|
|
434
|
-
* its parenthetical aside that named two builtin agents a generic graph need not have.
|
|
435
|
-
|
|
396
|
+
* its parenthetical aside that named two builtin agents a generic graph need not have.
|
|
397
|
+
* §4: a clarifier that declares ask forms gets the forms section appended; one that
|
|
398
|
+
* declares none returns today's string byte for byte. */
|
|
399
|
+
function baseInstruction(runnerType, meta = null) {
|
|
436
400
|
if (runnerType === 'verifier') {
|
|
437
401
|
return 'You are a verifier. Inspect the inputs below exactly as your role instructions describe, ' +
|
|
438
402
|
'then write a human-readable review markdown AND a machine-readable review JSON.';
|
|
439
403
|
}
|
|
440
404
|
if (runnerType === 'clarifier') {
|
|
441
|
-
|
|
405
|
+
const clarify = 'Identify the decisions you cannot safely resolve from the task text or the real ' +
|
|
442
406
|
'codebase — including things a downstream agent would otherwise silently assume. For ' +
|
|
443
407
|
'each, produce one conceptual question with 2 to 4 options and a free-text fallback. Ask ' +
|
|
444
408
|
'only what materially changes the plan (up to 8 questions); never pad, and never split one ' +
|
|
445
409
|
'decision. For low-impact details, pick a sensible default rather than asking. If you have ' +
|
|
446
410
|
'no material open questions, write { "questions": [] } to that same path.';
|
|
411
|
+
const forms = askFormsBlock(meta?.ask?.forms);
|
|
412
|
+
return forms ? `${clarify}\n\n${forms}` : clarify;
|
|
447
413
|
}
|
|
448
414
|
return 'You are a pipeline agent. Read every input below, do your job exactly as your role ' +
|
|
449
415
|
'instructions describe, and write EVERY declared output to its exact path.';
|
|
@@ -544,7 +510,7 @@ export function buildAgentPrompt(ctx) {
|
|
|
544
510
|
return (
|
|
545
511
|
taskHeader(headerCtx, title) +
|
|
546
512
|
'\n## What to do\n\n' +
|
|
547
|
-
baseInstruction(meta.runnerType) + '\n\n' +
|
|
513
|
+
baseInstruction(meta.runnerType, meta) + '\n\n' +
|
|
548
514
|
(hints ? hints + '\n\n' : '') +
|
|
549
515
|
modeBlock(selectMode({ ports, bindings, freshPorts: trigger.freshPorts })) +
|
|
550
516
|
fanOutDirective(ctxFanOut(ctx), { omitProjectAgents: relative, subagentModel: ctxSubagentModel(ctx), endpointRouted: routed }) +
|
|
@@ -575,6 +541,16 @@ async function readPriorAnswers(ports, bindings = {}) {
|
|
|
575
541
|
const path = port ? bindings[port.id]?.path : null;
|
|
576
542
|
if (!path) return [];
|
|
577
543
|
const json = await readJsonMaybe(path);
|
|
544
|
+
// A clarifier that answered with a FORM writes {form, version, values}; project
|
|
545
|
+
// it mechanically so a downstream `as:'answers'` port still renders something
|
|
546
|
+
// (E18). P4 may replace this with the shared text projection.
|
|
547
|
+
if (json && typeof json === 'object' && typeof json.form === 'string' && json.values && typeof json.values === 'object') {
|
|
548
|
+
return Object.entries(json.values).map(([field, value]) => ({
|
|
549
|
+
id: field,
|
|
550
|
+
question: field,
|
|
551
|
+
choice: typeof value === 'string' ? value : JSON.stringify(value),
|
|
552
|
+
}));
|
|
553
|
+
}
|
|
578
554
|
return Array.isArray(json?.answers) ? json.answers : [];
|
|
579
555
|
}
|
|
580
556
|
|
|
@@ -645,18 +621,6 @@ async function spawnAgent(full, { role, prompt, systemPrompt, allowedTools }) {
|
|
|
645
621
|
return { text, sessionId };
|
|
646
622
|
}
|
|
647
623
|
|
|
648
|
-
/** The output map the scheduler publishes from: an entry per declared port, with a
|
|
649
|
-
* path where one was allocated and an empty payload for void ports. Exported for
|
|
650
|
-
* P4's composite `finish` arm. */
|
|
651
|
-
export function publishable(ports, outputs) {
|
|
652
|
-
const out = {};
|
|
653
|
-
for (const port of ports?.outputs || []) {
|
|
654
|
-
if (!port) continue;
|
|
655
|
-
out[port.id] = outputs[port.id]?.path ? { path: outputs[port.id].path } : {};
|
|
656
|
-
}
|
|
657
|
-
return out;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
624
|
/**
|
|
661
625
|
* The ONE generic agent executor — the generalization of v1's runGenericProducer and
|
|
662
626
|
* runGenericVerifier and of the nine bespoke runners they replace. Selected for
|
|
@@ -690,6 +654,9 @@ export async function runAgentExecution(ctx) {
|
|
|
690
654
|
* row and the History UI render the full Q&A without a join.
|
|
691
655
|
*/
|
|
692
656
|
function normalizeAnswers(payload, questions) {
|
|
657
|
+
// A form answer is `{form, version, values}` and is NEVER flattened here: the
|
|
658
|
+
// "fill with the first option" fallback below is legacy-kind only (spec §5).
|
|
659
|
+
if (payload && typeof payload === 'object' && typeof payload.form === 'string' && payload.values) return [];
|
|
693
660
|
const arr = Array.isArray(payload?.answers) ? payload.answers : Array.isArray(payload) ? payload : [];
|
|
694
661
|
const byId = new Map();
|
|
695
662
|
for (const a of arr) if (a && a.id != null) byId.set(String(a.id), String(a.choice ?? ''));
|
|
@@ -700,6 +667,52 @@ function normalizeAnswers(payload, questions) {
|
|
|
700
667
|
}));
|
|
701
668
|
}
|
|
702
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Gate 2 for a clarifier's `{form,data}` ask (spec §5). One repair spawn on a
|
|
672
|
+
* refusal — resumed on the SAME session (`sessionId` is the id captured off the
|
|
673
|
+
* FIRST spawn; the repair re-attaches it exactly as _questionsLoop resumes a
|
|
674
|
+
* producer), with formRepairBlock rendering the error list and the schema through
|
|
675
|
+
* runOpts — then a downgrade to one generic free-text question. Never throws. The
|
|
676
|
+
* returned `sessionId` is the repair's own when it reported one, else the first's.
|
|
677
|
+
* @returns {Promise<{ask:object|null, autoValues:object|null, questions:Array, sessionId:string|null}>}
|
|
678
|
+
*/
|
|
679
|
+
async function prepareClarifierForm({ full, meta, node, ordinal, answersPath, payload, warnings, spawn, sessionId: first = null }) {
|
|
680
|
+
const askId = `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`;
|
|
681
|
+
let current = payload;
|
|
682
|
+
let sessionId = first || null;
|
|
683
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
684
|
+
const prepared = await prepareFormAsk({
|
|
685
|
+
agentMeta: meta,
|
|
686
|
+
payload: { form: current.form, data: current.data },
|
|
687
|
+
cwd: full.projectDir,
|
|
688
|
+
pipelineDir: full.runCtx?.pipelineDir || full.pipelineDir,
|
|
689
|
+
askId,
|
|
690
|
+
});
|
|
691
|
+
if (prepared.ok) return { ask: prepared.ask, autoValues: prepared.autoValues, questions: [], sessionId };
|
|
692
|
+
const why = prepared.errors.map((e) => `${e.path ? `${e.path}: ` : ''}${e.message}`).join('; ');
|
|
693
|
+
warnings.push(`${meta.displayName || node.key}: form "${current.form}" was refused — ${why}`);
|
|
694
|
+
if (attempt === 2) break;
|
|
695
|
+
const repairCtx = {
|
|
696
|
+
...full,
|
|
697
|
+
resumeSessionId: sessionId || full.resumeSessionId,
|
|
698
|
+
formRepair: {
|
|
699
|
+
form: current.form, errors: prepared.errors,
|
|
700
|
+
schema: meta?.ask?.forms?.[current.form]?.data || null, file: answersPath,
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
const again = await spawnAgent(repairCtx, spawn);
|
|
704
|
+
sessionId = again.sessionId ?? sessionId;
|
|
705
|
+
const retry = classifyAskPayload(await readJsonMaybe(answersPath));
|
|
706
|
+
if (retry.kind !== 'form') {
|
|
707
|
+
return { ask: null, autoValues: null, questions: retry.kind === 'questions' ? retry.questions : [], sessionId };
|
|
708
|
+
}
|
|
709
|
+
current = retry;
|
|
710
|
+
}
|
|
711
|
+
const title = meta?.ask?.forms?.[current.form]?.title || '';
|
|
712
|
+
warnings.push(`${meta.displayName || node.key}: form "${current.form}" downgraded to a free-text question after two refusals.`);
|
|
713
|
+
return { ask: null, autoValues: null, questions: [downgradeQuestion({ form: current.form, title })], sessionId };
|
|
714
|
+
}
|
|
715
|
+
|
|
703
716
|
/**
|
|
704
717
|
* The clarifier executor — selected by `meta.runnerType === 'clarifier'`, NEVER by an
|
|
705
718
|
* agent key, so any number of clarifier nodes per graph is legal. Spawn → read the
|
|
@@ -716,12 +729,64 @@ export async function runClarifierExecution(ctx) {
|
|
|
716
729
|
const answersPath = outputs[answersPort?.id]?.path;
|
|
717
730
|
if (!answersPath) throw new Error(`clarifier node "${node?.id}": no json output port to write the questions to`);
|
|
718
731
|
|
|
719
|
-
|
|
732
|
+
let sessionId;
|
|
733
|
+
({ sessionId } = await spawnAgent(full, { role, prompt, systemPrompt, allowedTools }));
|
|
720
734
|
|
|
721
|
-
const
|
|
735
|
+
const raw = await readJsonMaybe(answersPath);
|
|
736
|
+
const payload = classifyAskPayload(raw);
|
|
722
737
|
// Non-interactive default (v1 `_ask` auto): no gate ⇒ every question takes its first
|
|
723
738
|
// option (normalizeAnswers' fallback). The scheduler's onAsk never sees clarify asks.
|
|
724
|
-
const ask = typeof ctx.ask === 'function' ? ctx.ask :
|
|
739
|
+
const ask = typeof ctx.ask === 'function' ? ctx.ask : null;
|
|
740
|
+
const warnings = pinIgnoredWarning ? [pinIgnoredWarning] : [];
|
|
741
|
+
let questions = payload.kind === 'questions' ? payload.questions : [];
|
|
742
|
+
|
|
743
|
+
if (payload.kind === 'form') {
|
|
744
|
+
// Gate 2 (spec §5), with the ONE repair spawn the producer loop also gets —
|
|
745
|
+
// a clarifier has no _questionsLoop, so the repair is a resumed spawn here.
|
|
746
|
+
const gate = await prepareClarifierForm({
|
|
747
|
+
full, meta, node, ordinal, answersPath, payload, warnings,
|
|
748
|
+
spawn: { role, systemPrompt, allowedTools, prompt },
|
|
749
|
+
sessionId, // E6: the repair RESUMES the first spawn's session
|
|
750
|
+
});
|
|
751
|
+
if (gate.ask) {
|
|
752
|
+
const answered = await (ask
|
|
753
|
+
? ask({
|
|
754
|
+
// `id` is the QUESTION id — the same string the legacy arm below uses,
|
|
755
|
+
// so `orch.answer(id, …)` is uniform. `askId` is the route token
|
|
756
|
+
// prepareFormAsk minted from it, which may differ whenever a node id
|
|
757
|
+
// carries a character outside [A-Za-z0-9_-] (ruling X1).
|
|
758
|
+
id: `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`,
|
|
759
|
+
kind: 'form', nodeId: node.id, agent: meta.displayName || node.key,
|
|
760
|
+
askId: gate.ask.askId, form: gate.ask.form, version: gate.ask.version, title: gate.ask.title,
|
|
761
|
+
surface: gate.ask.surface,
|
|
762
|
+
data: gate.ask.data, layout: gate.ask.layout, answerSchema: gate.ask.answerSchema,
|
|
763
|
+
fileRefs: gate.ask.fileRefs, files: gate.ask.files,
|
|
764
|
+
autoValues: gate.autoValues, validate: formAnswerValidator(gate.ask),
|
|
765
|
+
})
|
|
766
|
+
// No gate at all (auto): D10's auto answer, exactly what _ask would return.
|
|
767
|
+
: Promise.resolve({ form: gate.ask.form, version: gate.ask.version, values: gate.autoValues || {} }));
|
|
768
|
+
const values = (answered && typeof answered === 'object' && answered.values) || {};
|
|
769
|
+
if (ctx.pipelineId) {
|
|
770
|
+
await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
|
|
771
|
+
agentKey: node?.key, nodeId: node?.id, questions: gate.ask,
|
|
772
|
+
});
|
|
773
|
+
await writeClarify(ctx.pipelineId, { questions: gate.ask });
|
|
774
|
+
const answerRow = { kind: 'form', form: gate.ask.form, version: gate.ask.version, values };
|
|
775
|
+
await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
|
|
776
|
+
agentKey: node?.key, nodeId: node?.id, answers: answerRow,
|
|
777
|
+
});
|
|
778
|
+
await writeClarify(ctx.pipelineId, { answers: answerRow });
|
|
779
|
+
}
|
|
780
|
+
await mkdir(dirname(answersPath), { recursive: true }).catch(() => {});
|
|
781
|
+
await writeFile(answersPath, JSON.stringify({ form: gate.ask.form, version: gate.ask.version, values }, null, 2) + '\n', 'utf8');
|
|
782
|
+
return { outputs: publishable(ports, outputs), questions: [], answers: [],
|
|
783
|
+
form: gate.ask.form, version: gate.ask.version, values,
|
|
784
|
+
sessionId: gate.sessionId ?? sessionId, prompt, warnings };
|
|
785
|
+
}
|
|
786
|
+
questions = gate.questions; // the downgrade takes the legacy arm below
|
|
787
|
+
sessionId = gate.sessionId ?? sessionId;
|
|
788
|
+
}
|
|
789
|
+
|
|
725
790
|
let answers = [];
|
|
726
791
|
if (questions.length) {
|
|
727
792
|
if (ctx.pipelineId) {
|
|
@@ -730,14 +795,14 @@ export async function runClarifierExecution(ctx) {
|
|
|
730
795
|
});
|
|
731
796
|
await writeClarify(ctx.pipelineId, { questions: { questions } });
|
|
732
797
|
}
|
|
733
|
-
const
|
|
798
|
+
const answerPayload = await (ask || (async () => ({ answers: [] })))({
|
|
734
799
|
id: `${CLARIFY_ASK_KIND}-${node.id}-${ordinal}`,
|
|
735
800
|
kind: CLARIFY_ASK_KIND,
|
|
736
801
|
nodeId: node.id,
|
|
737
802
|
agent: meta.displayName || node.key,
|
|
738
803
|
questions,
|
|
739
804
|
});
|
|
740
|
-
answers = normalizeAnswers(
|
|
805
|
+
answers = normalizeAnswers(answerPayload, questions);
|
|
741
806
|
if (ctx.pipelineId) {
|
|
742
807
|
await writeStepQuestions(ctx.pipelineId, ctx.executionId, ordinal, {
|
|
743
808
|
agentKey: node?.key, nodeId: node?.id, answers: { answers },
|
|
@@ -748,8 +813,7 @@ export async function runClarifierExecution(ctx) {
|
|
|
748
813
|
|
|
749
814
|
await mkdir(dirname(answersPath), { recursive: true }).catch(() => {});
|
|
750
815
|
await writeFile(answersPath, JSON.stringify({ questions, answers }, null, 2) + '\n', 'utf8');
|
|
751
|
-
return { outputs: publishable(ports, outputs), questions, answers, sessionId, prompt,
|
|
752
|
-
warnings: pinIgnoredWarning ? [pinIgnoredWarning] : [] };
|
|
816
|
+
return { outputs: publishable(ports, outputs), questions, answers, sessionId, prompt, warnings };
|
|
753
817
|
}
|
|
754
818
|
|
|
755
819
|
// ── flow executors (pure engine: instant, $0, no process spawn) ───────────────
|
|
@@ -891,6 +955,11 @@ export function runExecution(ctx, opts = {}) {
|
|
|
891
955
|
case 'end': return runEndExecution(ctx);
|
|
892
956
|
case 'combine':
|
|
893
957
|
return runCombineExecution({ ...ctx, names: ctx.names || combineNames(ctx.template, node.id) });
|
|
958
|
+
case 'script': {
|
|
959
|
+
// A child process, not a Claude spawn (spec §6.1). `runners.script` is the test seam.
|
|
960
|
+
const injectedScript = (opts.runners || ctx.runners || {}).script;
|
|
961
|
+
return typeof injectedScript === 'function' ? injectedScript(ctx) : runScriptExecution(ctx);
|
|
962
|
+
}
|
|
894
963
|
case 'agent': break;
|
|
895
964
|
default:
|
|
896
965
|
throw new Error(`node "${node.id}": unknown kind "${node.kind}"`);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// src/core/graph/human-evidence.mjs
|
|
2
|
+
// Per-execution EVIDENCE for the human-hours estimate (money-saved design §3). Agent-agnostic
|
|
3
|
+
// by construction: it reads node.kind, the meta's runnerType/sideEffect/humanEffort, the port
|
|
4
|
+
// TYPES and filename templates, the allocated output paths, the bound input paths and the
|
|
5
|
+
// worktree — never an agent key. All file reads are best-effort: a missing or unreadable file
|
|
6
|
+
// contributes nothing.
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { proseWords, jsonItems } from '../../shared/human-estimate.mjs';
|
|
9
|
+
|
|
10
|
+
const REVISION_RE = /-v\d+\.md$/i; // `{vsuffix}` renders as `-vN` for N > 1 (executor.mjs resolveTemplate)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A promise chain: `run(fn)` runs fn after every earlier fn has settled and returns fn's own
|
|
14
|
+
* promise. A rejection reaches that caller only; the chain itself never rejects. The
|
|
15
|
+
* orchestrator puts every cursor measurement of a run through one queue, so two fan-out
|
|
16
|
+
* slices ending together cannot both read the same cursor and credit one delta twice.
|
|
17
|
+
*/
|
|
18
|
+
export function serialQueue() {
|
|
19
|
+
let tail = Promise.resolve();
|
|
20
|
+
return (fn) => {
|
|
21
|
+
const next = tail.then(fn);
|
|
22
|
+
tail = next.catch(() => {});
|
|
23
|
+
return next;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cumulative numstat over every member work dir against its checkpoint ref. `null` when no
|
|
29
|
+
* member has a checkpoint. `stage` (optional) runs ONCE before measuring: `git diff
|
|
30
|
+
* <checkpoint>` never lists an untracked file, so the caller passes the harness's intent-to-add
|
|
31
|
+
* staging (`_stageWorkingTree`) — the same staging the reviewer's diff relies on. The exclude
|
|
32
|
+
* pathspecs are the set `_buildResults` / `_commitWork` use, so the cursor and results.json agree.
|
|
33
|
+
* @param {{workDirs: Map<string,string>, checkpointRefs: Record<string,string>, excludeFor:(key:string)=>string[], numstat:Function, stage?:Function}} o
|
|
34
|
+
* @returns {Promise<{files:number, insertions:number, deletions:number}|null>}
|
|
35
|
+
*/
|
|
36
|
+
export async function measureCodeCursor({ workDirs, checkpointRefs, excludeFor, numstat, stage = null }) {
|
|
37
|
+
if (typeof stage === 'function') { try { await stage(); } catch { /* best-effort: measure what is visible */ } }
|
|
38
|
+
let seen = false;
|
|
39
|
+
const out = { files: 0, insertions: 0, deletions: 0 };
|
|
40
|
+
for (const [key, dir] of workDirs?.entries?.() || []) {
|
|
41
|
+
const base = checkpointRefs?.[key];
|
|
42
|
+
if (!base) continue;
|
|
43
|
+
seen = true;
|
|
44
|
+
let m;
|
|
45
|
+
try { m = await numstat(dir, base, undefined, excludeFor(key) || []); } catch { continue; }
|
|
46
|
+
for (const row of m.values()) {
|
|
47
|
+
if (row.binary) continue;
|
|
48
|
+
out.files += 1;
|
|
49
|
+
out.insertions += row.added | 0;
|
|
50
|
+
out.deletions += row.removed | 0;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return seen ? out : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** now − prev, each component clamped at 0. `prev` null means "from zero". `now` null → null. */
|
|
57
|
+
export function codeDelta(now, prev) {
|
|
58
|
+
if (!now) return null;
|
|
59
|
+
const p = prev || { files: 0, insertions: 0, deletions: 0 };
|
|
60
|
+
return {
|
|
61
|
+
files: Math.max(0, (now.files | 0) - (p.files | 0)),
|
|
62
|
+
insertions: Math.max(0, (now.insertions | 0) - (p.insertions | 0)),
|
|
63
|
+
deletions: Math.max(0, (now.deletions | 0) - (p.deletions | 0)),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readText(read, path) {
|
|
68
|
+
if (!path) return null;
|
|
69
|
+
try { return await read(path, 'utf8'); } catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function jsonItemsOf(text) {
|
|
73
|
+
try { return jsonItems(JSON.parse(text)); } catch { return 0; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {{ctx: object, cursorPrev: object|null, cursorNow: object|null, read?: Function}} o
|
|
78
|
+
* ctx is the orchestrator's execution ctx (_execCtx): node, ordinal, meta, ports, outputs,
|
|
79
|
+
* bindings, verdict. cursorPrev = the cumulative cursor at the previous terminal (this step's
|
|
80
|
+
* start), cursorNow = the cursor measured at this step's terminal.
|
|
81
|
+
*/
|
|
82
|
+
export async function collectStepEvidence({ ctx, cursorPrev = null, cursorNow = null, read = readFile }) {
|
|
83
|
+
const nodeKind = ctx?.node?.kind || 'agent';
|
|
84
|
+
const isAgent = nodeKind === 'agent';
|
|
85
|
+
const meta = ctx?.meta || {};
|
|
86
|
+
const cycle = Number.isInteger(ctx?.ordinal) && ctx.ordinal > 0 ? ctx.ordinal : 1;
|
|
87
|
+
const agent = isAgent ? { runnerType: meta.runnerType, sideEffect: meta.sideEffect, humanEffort: meta.humanEffort } : null;
|
|
88
|
+
|
|
89
|
+
// Outputs: one entry per DISTINCT allocated path (a refiner's plan/revise pair is one file).
|
|
90
|
+
const outputs = [];
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
for (const port of ctx?.ports?.outputs || []) {
|
|
93
|
+
if (!port || (port.type !== 'md' && port.type !== 'json')) continue;
|
|
94
|
+
const path = ctx?.outputs?.[port.id]?.path;
|
|
95
|
+
if (!path || seen.has(path)) continue;
|
|
96
|
+
seen.add(path);
|
|
97
|
+
const text = await readText(read, path);
|
|
98
|
+
if (text == null) continue; // not produced (a `when`-gated port, or the step died early)
|
|
99
|
+
if (port.type === 'md') {
|
|
100
|
+
// A revision: this port versions its file ({vsuffix}) AND the allocated name carries -vN,
|
|
101
|
+
// or the execution itself is a re-run (cycle > 1). The template check keeps a base name
|
|
102
|
+
// that merely ends in "-v2" from reading as a revision.
|
|
103
|
+
const versioned = String(port.filename || '').includes('{vsuffix}') && REVISION_RE.test(path);
|
|
104
|
+
outputs.push({ type: 'md', words: proseWords(text), revision: versioned || cycle > 1 });
|
|
105
|
+
} else {
|
|
106
|
+
outputs.push({ type: 'json', items: jsonItemsOf(text) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const verdictPath = ctx?.verdict?.path;
|
|
110
|
+
if (verdictPath && !seen.has(verdictPath)) {
|
|
111
|
+
const text = await readText(read, verdictPath);
|
|
112
|
+
if (text != null) outputs.push({ type: 'json', items: jsonItemsOf(text) });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reads: verifiers only — the diff as it stood when the step STARTED plus every md input.
|
|
116
|
+
let reads = null;
|
|
117
|
+
if (isAgent && meta.runnerType === 'verifier') {
|
|
118
|
+
const inputs = ctx?.ports?.inputs || [];
|
|
119
|
+
const readsWorktree = inputs.some((p) => p && p.as === 'worktree');
|
|
120
|
+
const diffLines = readsWorktree && cursorPrev ? (cursorPrev.insertions | 0) + (cursorPrev.deletions | 0) : 0;
|
|
121
|
+
let words = 0;
|
|
122
|
+
for (const port of inputs) {
|
|
123
|
+
if (!port || port.type !== 'md') continue;
|
|
124
|
+
const text = await readText(read, ctx?.bindings?.[port.id]?.path);
|
|
125
|
+
if (text != null) words += proseWords(text);
|
|
126
|
+
}
|
|
127
|
+
reads = { diffLines, words };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { nodeKind, agent, cycle, code: isAgent ? codeDelta(cursorNow, cursorPrev) : null, outputs, reads };
|
|
131
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// src/core/graph/python-probe.mjs
|
|
2
|
+
// Can this host run a python script card? (scripts-workbench spec §7.) ONE answer,
|
|
3
|
+
// read by everything: the runner before it spawns, the run preflight, the bench,
|
|
4
|
+
// GET /api/scripts/runtimes and the Scripts page picker. Nothing else probes.
|
|
5
|
+
//
|
|
6
|
+
// Each candidate runs a stdlib one-liner and must print a parseable version
|
|
7
|
+
// array, so the Windows Store stub — an app-execution alias named python.exe that
|
|
8
|
+
// prints nothing and exits 9009 (or pops the Store) — fails the parse and is
|
|
9
|
+
// skipped instead of being reported as a working interpreter.
|
|
10
|
+
//
|
|
11
|
+
// Every seam is injectable (env, platform, spawn, settings, timeoutMs) so the
|
|
12
|
+
// Windows candidate order is unit-tested on macOS and Linux with no python
|
|
13
|
+
// installed. The 60 s cache covers the BARE call only: a unit test that injects a
|
|
14
|
+
// fake spawn can never poison what the server reads next.
|
|
15
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { pythonPath } from '../settings.mjs';
|
|
18
|
+
|
|
19
|
+
/** 3.8 is the floor: importlib.util.spec_from_file_location + asyncio.run + f-strings. */
|
|
20
|
+
export const PYTHON_MIN = Object.freeze([3, 8]);
|
|
21
|
+
export const PROBE_TIMEOUT_MS = 5000;
|
|
22
|
+
export const PROBE_CACHE_MS = 60000;
|
|
23
|
+
/** The version answer is ~12 bytes. Anything that prints more than this is not a python, and an unbounded
|
|
24
|
+
* accumulator here is a HOST crash: `yes` fills a V8 string ("Invalid string length") well inside the 5 s window. */
|
|
25
|
+
export const PROBE_OUTPUT_MAX = 4096;
|
|
26
|
+
/** Standard library, one line, no f-string: it must at least PARSE on whatever the user points at. */
|
|
27
|
+
export const VERSION_SCRIPT = 'import sys,json;print(json.dumps(list(sys.version_info[:3])))';
|
|
28
|
+
|
|
29
|
+
const blank = (v) => typeof v !== 'string' || v.trim() === '';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An explicit interpreter with a directory part is made ABSOLUTE here, once. The
|
|
33
|
+
* probe spawns from the server's cwd and the runner spawns from the RUN's cwd (a
|
|
34
|
+
* worktree somewhere else), so a relative `.venv/bin/python` would pass the probe
|
|
35
|
+
* and then fail every execution with ENOENT. A bare name (`python3.12`) has no
|
|
36
|
+
* directory part and stays a PATH lookup, which is cwd-independent.
|
|
37
|
+
*/
|
|
38
|
+
function explicitCommand(value, platform, cwd) {
|
|
39
|
+
const v = value.trim();
|
|
40
|
+
const p = platform === 'win32' ? path.win32 : path.posix;
|
|
41
|
+
const hasDir = platform === 'win32' ? /[\\/]/.test(v) : v.includes('/');
|
|
42
|
+
return [hasDir && !p.isAbsolute(v) ? p.resolve(cwd, v) : v];
|
|
43
|
+
}
|
|
44
|
+
const label = (command) => command.join(' ');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The candidates, in order (§7). An EXPLICIT interpreter is AUTHORITATIVE: with
|
|
48
|
+
* WORCA_PYTHON (or the pythonPath setting) set, that one is tried and the probe
|
|
49
|
+
* stops — the failure sentence tells the user to set WORCA_PYTHON, so a silent
|
|
50
|
+
* fall-through to some other python would make that advice a lie, and a venv
|
|
51
|
+
* pointer must never be quietly replaced by the system interpreter. The value is
|
|
52
|
+
* ONE executable path, used verbatim (`C:\Program Files\Python312\python.exe`).
|
|
53
|
+
* @param {{env?:object, platform?:string, settings?:object|null, cwd?:string}} [o] `settings` is a test seam; null reads the store
|
|
54
|
+
*/
|
|
55
|
+
export function pythonCandidates({ env = process.env, platform = process.platform, settings = null, cwd = process.cwd() } = {}) {
|
|
56
|
+
if (!blank(env.WORCA_PYTHON)) return [{ command: explicitCommand(env.WORCA_PYTHON, platform, cwd), source: 'WORCA_PYTHON' }];
|
|
57
|
+
const configured = settings ? settings.pythonPath : pythonPath();
|
|
58
|
+
if (!blank(configured)) return [{ command: explicitCommand(configured, platform, cwd), source: 'pythonPath' }];
|
|
59
|
+
if (platform === 'win32') {
|
|
60
|
+
return [{ command: ['py', '-3'], source: 'path' }, { command: ['python'], source: 'path' }, { command: ['python3'], source: 'path' }];
|
|
61
|
+
}
|
|
62
|
+
return [{ command: ['python3'], source: 'path' }, { command: ['python'], source: 'path' }];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `[3, 12, 4]` from the version script's stdout; null for anything else (the Store stub prints nothing). */
|
|
66
|
+
export function parseVersion(text) {
|
|
67
|
+
const lines = String(text || '').trim().split('\n');
|
|
68
|
+
const last = lines[lines.length - 1].trim();
|
|
69
|
+
if (!last) return null;
|
|
70
|
+
let v;
|
|
71
|
+
try { v = JSON.parse(last); } catch { return null; }
|
|
72
|
+
if (!Array.isArray(v) || v.length !== 3 || !v.every((n) => Number.isInteger(n) && n >= 0)) return null;
|
|
73
|
+
return v;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const meetsFloor = (v) => v[0] > PYTHON_MIN[0] || (v[0] === PYTHON_MIN[0] && v[1] >= PYTHON_MIN[1]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Ask ONE candidate for its version. Never throws, never rejects.
|
|
80
|
+
* Windows: `py.exe` launches `python.exe` as a child, so the timeout kill reaches
|
|
81
|
+
* the launcher only — acceptable for a one-liner that exits immediately, and a
|
|
82
|
+
* tree kill here would mean importing script-runner.mjs, which imports this module.
|
|
83
|
+
* @returns {Promise<{version:number[]}|{error:string}>}
|
|
84
|
+
*/
|
|
85
|
+
function askVersion(command, { spawn, timeoutMs }) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
let child;
|
|
88
|
+
try {
|
|
89
|
+
child = spawn(command[0], [...command.slice(1), '-c', VERSION_SCRIPT], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
resolve({ error: err?.message || String(err) });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let out = '';
|
|
95
|
+
let settled = false;
|
|
96
|
+
const done = (v) => {
|
|
97
|
+
if (settled) return;
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
101
|
+
resolve(v);
|
|
102
|
+
};
|
|
103
|
+
const timer = setTimeout(() => done({ error: `no answer in ${Math.round(timeoutMs / 1000)} s` }), timeoutMs);
|
|
104
|
+
child.stdout?.setEncoding('utf8').on('data', (c) => {
|
|
105
|
+
if (settled) return;
|
|
106
|
+
out += c;
|
|
107
|
+
if (out.length > PROBE_OUTPUT_MAX) done({ error: 'printed more than a version' });
|
|
108
|
+
});
|
|
109
|
+
child.stderr?.resume(); // drained, never read: a stub's banner is not an answer
|
|
110
|
+
child.on('error', (err) => done({ error: err?.message || String(err) }));
|
|
111
|
+
child.on('close', (code) => {
|
|
112
|
+
const version = parseVersion(out);
|
|
113
|
+
done(version ? { version } : { error: `exit ${code} with no version` });
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The one sentence the picker shows as its `title` and the run/bench repeat. */
|
|
119
|
+
function failureReason(candidates, tried, tooOld) {
|
|
120
|
+
if (tooOld) return `python ${tooOld.version.join('.')} at "${label(tooOld.command)}" is older than 3.8 — point WORCA_PYTHON at a newer interpreter`;
|
|
121
|
+
const first = candidates[0];
|
|
122
|
+
if (first?.source === 'WORCA_PYTHON') return `WORCA_PYTHON "${label(first.command)}" is not a working python`;
|
|
123
|
+
if (first?.source === 'pythonPath') return `the pythonPath setting "${label(first.command)}" is not a working python`;
|
|
124
|
+
return `no python 3.8 or newer found (tried ${tried.join(', ')})`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function runProbe({ env, platform, spawn, settings, timeoutMs, cwd }) {
|
|
128
|
+
const candidates = pythonCandidates({ env, platform, settings, cwd });
|
|
129
|
+
const tried = [];
|
|
130
|
+
let tooOld = null;
|
|
131
|
+
for (const c of candidates) {
|
|
132
|
+
const r = await askVersion(c.command, { spawn, timeoutMs });
|
|
133
|
+
tried.push(label(c.command));
|
|
134
|
+
if (r.version && meetsFloor(r.version)) return { ok: true, command: c.command, version: r.version };
|
|
135
|
+
if (r.version && !tooOld) tooOld = { command: c.command, version: r.version };
|
|
136
|
+
}
|
|
137
|
+
return { ok: false, reason: failureReason(candidates, tried, tooOld) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let cached = null; // { at, promise } — the BARE call only
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The host's python (§7). Cached 60 s per process when called with NO options;
|
|
144
|
+
* any option at all (a test seam) bypasses and never fills the cache.
|
|
145
|
+
* @returns {Promise<{ok:true, command:string[], version:number[]}|{ok:false, reason:string}>}
|
|
146
|
+
*/
|
|
147
|
+
export function probePython(opts = {}) {
|
|
148
|
+
const bare = Object.keys(opts).length === 0;
|
|
149
|
+
if (bare && cached && Date.now() - cached.at < PROBE_CACHE_MS) return cached.promise;
|
|
150
|
+
// NEVER rejects: express 4 does not catch an async handler's rejection, the bare promise is CACHED for 60 s,
|
|
151
|
+
// and an unhandled rejection takes the server down. Whatever goes wrong here is "no python", with the reason.
|
|
152
|
+
const promise = runProbe({
|
|
153
|
+
env: opts.env || process.env,
|
|
154
|
+
platform: opts.platform || process.platform,
|
|
155
|
+
spawn: opts.spawn || nodeSpawn,
|
|
156
|
+
settings: opts.settings || null,
|
|
157
|
+
timeoutMs: Number.isInteger(opts.timeoutMs) ? opts.timeoutMs : PROBE_TIMEOUT_MS,
|
|
158
|
+
cwd: opts.cwd,
|
|
159
|
+
}).catch((err) => ({ ok: false, reason: `the python probe failed: ${err?.message || err}` }));
|
|
160
|
+
if (bare) cached = { at: Date.now(), promise };
|
|
161
|
+
return promise;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Drop the 60 s cache. Tests only — product code never calls it. */
|
|
165
|
+
export function resetPythonProbe() { cached = null; }
|
|
166
|
+
|
|
167
|
+
/** The `python` arm of GET /api/scripts/runtimes (spec §3.4). */
|
|
168
|
+
export function pythonRuntimeState(probe) {
|
|
169
|
+
return probe?.ok
|
|
170
|
+
? { ok: true, version: probe.version.join('.'), command: probe.command }
|
|
171
|
+
: { ok: false, reason: probe?.reason || 'python was not probed' };
|
|
172
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// src/core/graph/registry-ports.mjs
|
|
2
|
-
// Engine-side glue:
|
|
3
|
-
// src/core (not src/shared) because it exists only
|
|
4
|
-
// registry
|
|
2
|
+
// Engine-side glue: the loaded agent registry AND the loaded script registry ->
|
|
3
|
+
// the shared portsFn. Lives in src/core (not src/shared) because it exists only
|
|
4
|
+
// to bridge the Node-side registry shapes; the resolution logic itself is shared.
|
|
5
5
|
import { portsFnFor } from '../../shared/graph/ports.mjs';
|
|
6
6
|
import { indexByKey } from '../../shared/graph/agent-meta.mjs';
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* @param {Record<string,object>|object[]} registry loadAgentRegistry() output (or a list)
|
|
10
|
+
* @param {Record<string,object>|object[]} [scripts] loadScriptRegistry() output (or a list); absent = no scripts resolve
|
|
11
|
+
*/
|
|
12
|
+
export function registryPortsFn(registry, scripts = {}) {
|
|
10
13
|
const list = Array.isArray(registry) ? registry : Object.values(registry || {});
|
|
11
|
-
|
|
14
|
+
const scriptList = Array.isArray(scripts) ? scripts : Object.values(scripts || {});
|
|
15
|
+
return portsFnFor(indexByKey(list), indexByKey(scriptList));
|
|
12
16
|
}
|