@worca/app 1.3.0 → 1.4.0-rc.1
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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// src/core/ask/script-deps.mjs
|
|
2
|
+
// The WRITE-CAPABLE dep bundle of the chat's script tools (scripts-workbench-design.md §9.1,
|
|
3
|
+
// W7/W19/W20). Deliberately separate from tool-deps.mjs, whose source is scanned as read-only:
|
|
4
|
+
// every script file the chat writes and every bench child it starts is reachable ONLY through
|
|
5
|
+
// here, and test/ask-script-tools.test.mjs pins this module's import surface. One namespaced
|
|
6
|
+
// sub-object, exactly like worktree-deps.mjs / comment-deps.mjs / memory-deps.mjs.
|
|
7
|
+
//
|
|
8
|
+
// Everything writes through src/core/script-store.mjs (meta validation, layer rules, atomic
|
|
9
|
+
// writes, createdBy/updatedBy stamps) and runs through src/core/script-bench.mjs#runBenchOnce —
|
|
10
|
+
// the ONE bench engine the Scripts page and the CLI use — so "the chat saved it" and "the page
|
|
11
|
+
// saved it" cannot drift. W19: the user layer only, no delete, an existing key needs
|
|
12
|
+
// overwrite: true. Refusals are RETURNED, never thrown, so the model corrects itself.
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import {
|
|
15
|
+
listScripts, readScript, createScript, updateScript, writeCases, userScriptsDir,
|
|
16
|
+
} from '../script-store.mjs';
|
|
17
|
+
import { runBenchOnce } from '../script-bench.mjs';
|
|
18
|
+
import { normalizeCases } from '../../shared/graph/script-cases.mjs';
|
|
19
|
+
import { DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS } from '../../shared/graph/script-meta.mjs';
|
|
20
|
+
import { probePython } from '../graph/python-probe.mjs';
|
|
21
|
+
import { chatPrefs } from '../settings.mjs';
|
|
22
|
+
import { ASK_LIMITS } from './limits.mjs';
|
|
23
|
+
|
|
24
|
+
/** Every sentence the model can act on. Unprefixed: they ride inside an `errors` array
|
|
25
|
+
* (the PROPOSAL_ERRORS shape), never as a thrown tool error. */
|
|
26
|
+
export const SCRIPT_ERRORS = Object.freeze({
|
|
27
|
+
keyRequired: 'key is required',
|
|
28
|
+
metaRequired: 'meta is required: a script meta v2 object with displayName, description, runtime and ports',
|
|
29
|
+
sourceRequired: 'source is required: the program text',
|
|
30
|
+
casesType: 'cases must be an array of case objects',
|
|
31
|
+
turnEnded: 'the chat turn ended before the script ran',
|
|
32
|
+
exists: (key) => `script "${key}" exists — pass overwrite: true to replace it`,
|
|
33
|
+
builtin: (key) => `script "${key}" is a built-in — save your version under a new key instead`,
|
|
34
|
+
plugin: (key, plugin) => `script "${key}" is shipped by plugin "${plugin}" — save your version under a new key instead`,
|
|
35
|
+
notFound: (key) => `no script "${key}" — use list_scripts`,
|
|
36
|
+
caseNotFound: (key, caseId) => `no case "${caseId}" on script "${key}" — get_script lists its saved cases`,
|
|
37
|
+
caseProject: (caseId, projectKey) => `case "${caseId}" runs in project "${projectKey}", which is not the project pinned for this chat — pin it, or run the case's inputs without caseId`,
|
|
38
|
+
caseTimeout: (caseId, sec, max) => `case "${caseId}" would run for up to ${sec} s (its own or the script's timeoutMs) — test_script allows ${max}; lower that timeoutMs or run the case's inputs without caseId`,
|
|
39
|
+
inputShape: (port) => `bench input "${port}" must be { text } (an md or json port) or { fired: true } (a void port)`,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** W20: absent means on — only an explicit false switches the write tools off. An unreadable
|
|
43
|
+
* settings file is "on", like every other pref reader here. */
|
|
44
|
+
export function scriptToolsEnabled() {
|
|
45
|
+
try { return chatPrefs().scriptTools !== false; } catch { return true; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SCRIPT_RUNTIMES_BASE = Object.freeze(['node', 'shell']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The runtimes the prompt may name. python joins only when P2's probe finds an interpreter
|
|
52
|
+
* (spec §7). The probe is called BARE on purpose: probePython() caches its bare call for
|
|
53
|
+
* 60 s per process, while any option at all bypasses the cache and would spawn python on
|
|
54
|
+
* every chat turn. A probe that throws or answers { ok: false } means node + shell.
|
|
55
|
+
*/
|
|
56
|
+
export async function scriptHostRuntimes({ probe = probePython } = {}) {
|
|
57
|
+
try {
|
|
58
|
+
const r = await probe();
|
|
59
|
+
return r && r.ok ? [...SCRIPT_RUNTIMES_BASE, 'python'] : [...SCRIPT_RUNTIMES_BASE];
|
|
60
|
+
} catch { return [...SCRIPT_RUNTIMES_BASE]; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What prompt.mjs needs for its "Scripts you can create" section, or null when W20 is off. */
|
|
64
|
+
export async function askScriptPromptInput({ enabled = scriptToolsEnabled(), runtimes = null } = {}) {
|
|
65
|
+
if (!enabled) return null;
|
|
66
|
+
return { runtimes: runtimes ?? await scriptHostRuntimes() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The store speaks agent-store's coded vocabulary and the bench answers NOT_FOUND / BAD_REQUEST /
|
|
70
|
+
// BUSY (its 2-global, 1-per-key cap). BAD_REQUEST carries the validator's sentences joined with
|
|
71
|
+
// '; ', which is exactly the list the model needs to fix its meta. Anything uncoded is a real
|
|
72
|
+
// failure (an unwritable home, a broken disk) and is re-thrown: the MCP child logs it and answers
|
|
73
|
+
// isError, rather than asking the model to fix a filesystem.
|
|
74
|
+
const REFUSAL_CODES = new Set(['BAD_REQUEST', 'BUILTIN', 'PLUGIN', 'DUPLICATE', 'NOT_FOUND', 'REFERENCED', 'BUSY']);
|
|
75
|
+
function refusal(err) {
|
|
76
|
+
const code = err && typeof err.code === 'string' ? err.code : '';
|
|
77
|
+
if (!REFUSAL_CODES.has(code)) throw err;
|
|
78
|
+
const parts = String(err.message ?? '').split('; ').map((s) => s.trim()).filter(Boolean);
|
|
79
|
+
return parts.length ? parts : ['the script store refused the write'];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Host paths are this machine's business (the readRunMemory rule), and the registry's computed
|
|
83
|
+
// fields (portSummary, caseCount, the resolved command) are not part of a sidecar: the model
|
|
84
|
+
// gets the meta it could write back, plus `origin`, and the #scripts/<key> link from the tool.
|
|
85
|
+
const COMPUTED_META = new Set(['scriptPath', 'scriptsDir', 'commandResolved', 'caseCount', 'sourcePath', 'portSummary', 'frontmatter']);
|
|
86
|
+
const publicMeta = (m) => Object.fromEntries(Object.entries(m).filter(([k]) => !COMPUTED_META.has(k)));
|
|
87
|
+
|
|
88
|
+
/** The first `maxBytes` bytes, never cut inside a UTF-8 sequence. */
|
|
89
|
+
function clipHead(text, maxBytes) {
|
|
90
|
+
const s = String(text ?? '');
|
|
91
|
+
const buf = Buffer.from(s, 'utf8');
|
|
92
|
+
if (buf.length <= maxBytes) return { text: s, truncated: false };
|
|
93
|
+
let end = maxBytes;
|
|
94
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1;
|
|
95
|
+
return { text: buf.subarray(0, end).toString('utf8'), truncated: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The last N lines, then the last maxBytes of those: a test log fails at its END. */
|
|
99
|
+
function tailLines(lines, L) {
|
|
100
|
+
const all = (Array.isArray(lines) ? lines : []).map((x) => String(x ?? ''));
|
|
101
|
+
const kept = all.slice(-L.scriptLogMaxLines);
|
|
102
|
+
let text = kept.join('\n');
|
|
103
|
+
let truncated = kept.length < all.length;
|
|
104
|
+
const buf = Buffer.from(text, 'utf8');
|
|
105
|
+
if (buf.length > L.scriptLogMaxBytes) {
|
|
106
|
+
let start = buf.length - L.scriptLogMaxBytes;
|
|
107
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start += 1;
|
|
108
|
+
text = buf.subarray(start).toString('utf8');
|
|
109
|
+
truncated = true;
|
|
110
|
+
}
|
|
111
|
+
return { text, lines: kept.length, truncated };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Sentences, not text bodies: a character clip, with a visible mark. */
|
|
115
|
+
function clipChars(s, L) {
|
|
116
|
+
const t = String(s ?? '');
|
|
117
|
+
return t.length > L.scriptResultFieldMaxChars ? `${t.slice(0, L.scriptResultFieldMaxChars)}…` : t;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The verdict a model reads. The runner's normalizeReview caps NOTHING (a program returning
|
|
122
|
+
* 3 000 issues of 1 KB came back as a 3.2 MB tool result): at most N issues, each of the four
|
|
123
|
+
* fields clipped, the real count on `issueCount`, `truncated` when anything was dropped.
|
|
124
|
+
*/
|
|
125
|
+
function trimVerdict(v, L) {
|
|
126
|
+
const all = Array.isArray(v.issues) ? v.issues : [];
|
|
127
|
+
let cut = all.length > L.scriptVerdictMaxIssues;
|
|
128
|
+
const clip = (s) => { const t = clipChars(s, L); if (t !== String(s ?? '')) cut = true; return t; };
|
|
129
|
+
const issues = all.slice(0, L.scriptVerdictMaxIssues).map((i) => (i && typeof i === 'object'
|
|
130
|
+
? { severity: i.severity == null ? null : String(i.severity), title: clip(i.title), detail: clip(i.detail), location: clip(i.location) }
|
|
131
|
+
: { severity: null, title: clip(i), detail: '', location: '' }));
|
|
132
|
+
const out = { summary: v.summary == null ? null : clip(v.summary), issues, issueCount: all.length };
|
|
133
|
+
if (cut) out.truncated = true;
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The engine's evaluateExpect is satisfied by `{ fired: [] }` on a run that never FINISHED (probed:
|
|
139
|
+
* a 1 s case on a 5 s sleeper answers status 'timeout' AND expect.pass true). The CLI (P3) makes a
|
|
140
|
+
* stopped case fail; the chat says the same, with the reason first in `diffs`, so the model never
|
|
141
|
+
* reads "pass" beside a timeout, a stop or an execution error.
|
|
142
|
+
*/
|
|
143
|
+
function trimExpect(r, L) {
|
|
144
|
+
if (!r.expect || typeof r.expect !== 'object') return null;
|
|
145
|
+
const diffs = (Array.isArray(r.expect.diffs) ? r.expect.diffs : []).slice(0, L.scriptVerdictMaxIssues).map((d) => clipChars(d, L));
|
|
146
|
+
if (r.status === 'clean' || r.status === 'blocking') return { pass: r.expect.pass === true, diffs };
|
|
147
|
+
return { pass: false, diffs: [`the run ended ${typeof r.status === 'string' ? r.status : 'unfinished'} — a case passes only when its run finishes`, ...diffs] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The §4.1 bench result → what a model should read (§9.1): the log tail, capped outputs, a
|
|
152
|
+
* capped verdict, and not one host path. Pure; the caller supplies the lines it collected
|
|
153
|
+
* through `onLine`.
|
|
154
|
+
*/
|
|
155
|
+
export function trimBenchResult(result, logLines = [], L = ASK_LIMITS) {
|
|
156
|
+
const r = result && typeof result === 'object' ? result : {};
|
|
157
|
+
const outputs = {};
|
|
158
|
+
for (const [port, o] of Object.entries(r.outputs && typeof r.outputs === 'object' ? r.outputs : {})) {
|
|
159
|
+
if (!o || typeof o !== 'object') continue;
|
|
160
|
+
if (o.type === 'void') { outputs[port] = { type: 'void' }; continue; }
|
|
161
|
+
const cut = clipHead(o.text, L.scriptOutputMaxBytes);
|
|
162
|
+
outputs[port] = {
|
|
163
|
+
type: o.type ?? null,
|
|
164
|
+
bytes: Number.isFinite(o.bytes) ? o.bytes : Buffer.byteLength(String(o.text ?? ''), 'utf8'),
|
|
165
|
+
text: cut.text,
|
|
166
|
+
truncated: cut.truncated || o.truncated === true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
status: typeof r.status === 'string' ? r.status : null,
|
|
171
|
+
exitCode: Number.isFinite(r.exitCode) ? r.exitCode : null,
|
|
172
|
+
runtime: r.runtime ?? null,
|
|
173
|
+
durationMs: Number.isFinite(r.durationMs) ? r.durationMs : null,
|
|
174
|
+
summary: r.summary == null ? null : String(r.summary),
|
|
175
|
+
warnings: (Array.isArray(r.warnings) ? r.warnings : []).slice(0, L.scriptVerdictMaxIssues).map((w) => clipChars(w, L)),
|
|
176
|
+
fired: Array.isArray(r.fired) ? [...r.fired] : [],
|
|
177
|
+
outputs,
|
|
178
|
+
verdict: r.verdict && typeof r.verdict === 'object' ? trimVerdict(r.verdict, L) : null,
|
|
179
|
+
expect: trimExpect(r, L),
|
|
180
|
+
error: r.error && typeof r.error === 'object'
|
|
181
|
+
? { message: clipChars(r.error.message, L), tail: (Array.isArray(r.error.tail) ? r.error.tail : []).slice(-L.scriptVerdictMaxIssues).map((t) => clipChars(t, L)) }
|
|
182
|
+
: null,
|
|
183
|
+
draft: r.draft === true,
|
|
184
|
+
log: tailLines(logLines, L),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The file the model can open: the program file when the meta names one, else the sidecar. */
|
|
189
|
+
function savedPath(io, key, saved) {
|
|
190
|
+
const f = saved && saved.meta ? saved.meta.file : null;
|
|
191
|
+
const base = typeof f === 'string' ? f : (f && typeof f === 'object' ? f.default : null);
|
|
192
|
+
const dir = io.userScriptsDir();
|
|
193
|
+
const name = base || `${key}.meta.json`;
|
|
194
|
+
return dir ? join(dir, name) : name;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The real readers/writers, in ONE object so a test can swap them whole (the createCatalog seam).
|
|
198
|
+
const REAL_IO = Object.freeze({
|
|
199
|
+
listScripts, readScript, createScript, updateScript, writeCases, userScriptsDir,
|
|
200
|
+
runBenchOnce, normalizeCases, enabled: scriptToolsEnabled,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {{threadId: string, signal?: AbortSignal|null, io?: object}} opts
|
|
205
|
+
* every write is stamped `ask:<threadId>` (W19); `signal` is the MCP child's life signal —
|
|
206
|
+
* when the turn ends or is stopped, the live bench is stopped with it.
|
|
207
|
+
*/
|
|
208
|
+
export function defaultScriptDeps({ threadId, signal = null, io = REAL_IO } = {}) {
|
|
209
|
+
const by = `ask:${threadId || 'unknown'}`;
|
|
210
|
+
return {
|
|
211
|
+
scripts: {
|
|
212
|
+
// Read once per bundle = once per MCP child = once per turn: a Settings change lands on
|
|
213
|
+
// the next turn, which is what every other Ask pref does.
|
|
214
|
+
enabled: io.enabled() === true,
|
|
215
|
+
|
|
216
|
+
async list() {
|
|
217
|
+
const rows = await io.listScripts();
|
|
218
|
+
return (Array.isArray(rows) ? rows : []).slice(0, ASK_LIMITS.scriptListMaxRows).map((m) => ({
|
|
219
|
+
key: m.key,
|
|
220
|
+
displayName: m.displayName ?? m.key,
|
|
221
|
+
description: m.description ?? '',
|
|
222
|
+
origin: m.origin ?? null,
|
|
223
|
+
runtime: m.runtime ?? null,
|
|
224
|
+
portLine: m.ports === 'config' ? 'ports per card' : String(m.portSummary ?? ''),
|
|
225
|
+
caseCount: Number.isFinite(m.caseCount) ? m.caseCount : 0,
|
|
226
|
+
writable: m.origin === 'user',
|
|
227
|
+
}));
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async read(key) {
|
|
231
|
+
if (typeof key !== 'string' || !key.trim()) return null;
|
|
232
|
+
const r = await io.readScript(key);
|
|
233
|
+
if (!r || !r.meta) return null;
|
|
234
|
+
const m = r.meta;
|
|
235
|
+
return {
|
|
236
|
+
key: m.key ?? key,
|
|
237
|
+
origin: m.origin ?? null,
|
|
238
|
+
runtime: m.runtime ?? null,
|
|
239
|
+
writable: m.origin === 'user',
|
|
240
|
+
meta: publicMeta(m),
|
|
241
|
+
source: typeof r.source === 'string' ? r.source : '',
|
|
242
|
+
// The store answers '' for "no Windows variant"; the model reads null.
|
|
243
|
+
sourceWin32: typeof r.sourceWin32 === 'string' && r.sourceWin32 ? r.sourceWin32 : null,
|
|
244
|
+
sourceTruncated: r.sourceTruncated === true,
|
|
245
|
+
cases: Array.isArray(r.cases) ? r.cases : [],
|
|
246
|
+
userCases: Array.isArray(r.userCases) ? r.userCases : [],
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/** Create or replace a USER-layer script (W19). Every refusal is a return value. */
|
|
251
|
+
async save({ key, meta, source, sourceWin32 = null, cases = null, overwrite = false } = {}) {
|
|
252
|
+
const errors = [];
|
|
253
|
+
if (typeof key !== 'string' || !key.trim()) errors.push(SCRIPT_ERRORS.keyRequired);
|
|
254
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) errors.push(SCRIPT_ERRORS.metaRequired);
|
|
255
|
+
if (typeof source !== 'string') errors.push(SCRIPT_ERRORS.sourceRequired);
|
|
256
|
+
if (cases !== null && cases !== undefined && !Array.isArray(cases)) errors.push(SCRIPT_ERRORS.casesType);
|
|
257
|
+
if (errors.length) return { ok: false, errors };
|
|
258
|
+
|
|
259
|
+
// The model reads a meta with get_script and saves it back: every computed or
|
|
260
|
+
// store-owned field is dropped here, so an echoed origin/scriptPath can never ride
|
|
261
|
+
// into a write. `file` is the store's (recomputed from key + runtime, §3.3).
|
|
262
|
+
const { file: _f, origin: _o, scriptPath: _sp, scriptsDir: _sd, commandResolved: _cr,
|
|
263
|
+
createdBy: _cb, updatedBy: _ub, caseCount: _cc, sourcePath: _spath, portSummary: _ps,
|
|
264
|
+
frontmatter: _fm, ...rest } = meta;
|
|
265
|
+
const full = { ...rest, key, metaVersion: 2 };
|
|
266
|
+
|
|
267
|
+
const existing = await io.readScript(key);
|
|
268
|
+
if (existing) {
|
|
269
|
+
const origin = String(existing.meta?.origin ?? '');
|
|
270
|
+
if (origin === 'builtin') return { ok: false, errors: [SCRIPT_ERRORS.builtin(key)] };
|
|
271
|
+
if (origin.startsWith('plugin:')) return { ok: false, errors: [SCRIPT_ERRORS.plugin(key, origin.slice('plugin:'.length))] };
|
|
272
|
+
if (overwrite !== true) return { ok: false, errors: [SCRIPT_ERRORS.exists(key)] };
|
|
273
|
+
}
|
|
274
|
+
// Cases are normalized BEFORE anything is written: a bad case must not leave a saved
|
|
275
|
+
// script beside the cases it was rejected for.
|
|
276
|
+
let normalized = null;
|
|
277
|
+
if (Array.isArray(cases)) {
|
|
278
|
+
const n = io.normalizeCases({ version: 1, cases }, full);
|
|
279
|
+
if (n.errors.length) return { ok: false, errors: n.errors };
|
|
280
|
+
normalized = n.cases;
|
|
281
|
+
}
|
|
282
|
+
let saved;
|
|
283
|
+
try {
|
|
284
|
+
saved = existing
|
|
285
|
+
? await io.updateScript(key, { meta: full, source, sourceWin32, by })
|
|
286
|
+
: await io.createScript({ meta: full, source, sourceWin32, by });
|
|
287
|
+
} catch (err) { return { ok: false, errors: refusal(err) }; }
|
|
288
|
+
|
|
289
|
+
const out = { ok: true, key, created: !existing, path: savedPath(io, key, saved), link: `#scripts/${key}` };
|
|
290
|
+
if (Array.isArray(saved?.warnings) && saved.warnings.length) out.warnings = saved.warnings.map(String);
|
|
291
|
+
if (normalized) {
|
|
292
|
+
// The shape was validated above, so a failure here is a filesystem failure. The
|
|
293
|
+
// script IS saved; saying otherwise would send the model round the loop again.
|
|
294
|
+
try { await io.writeCases(key, normalized); out.cases = normalized.length; }
|
|
295
|
+
catch (err) { out.caseErrors = refusal(err); }
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/** One bench run, in-process in the MCP child (§9.1): no WS family, no runs-Map entry.
|
|
301
|
+
* An EXECUTION failure is a RESULT ('error' | 'timeout' | 'stopped') — that is what the
|
|
302
|
+
* model must read; only a refused request (unknown key, bad input, the bench cap) comes
|
|
303
|
+
* back as errors. */
|
|
304
|
+
async test({ key, caseId = null, params = null, ports = null, inputs = null, cwd = { kind: 'scratch' }, timeoutMs = null, pinnedProjectKey = null } = {}) {
|
|
305
|
+
if (typeof key !== 'string' || !key.trim()) return { ok: false, errors: [SCRIPT_ERRORS.keyRequired] };
|
|
306
|
+
if (signal && signal.aborted) return { ok: false, errors: [SCRIPT_ERRORS.turnEnded] };
|
|
307
|
+
// The bench refuses `{ text: 123 }` but leaves a bare string UNBOUND (probed: the program then
|
|
308
|
+
// throws on inputs.<port>.path, which reads as a bug in the program, not in the call): every
|
|
309
|
+
// input value is an object before the request goes out.
|
|
310
|
+
if (inputs && typeof inputs === 'object') {
|
|
311
|
+
const bad = Object.entries(inputs).filter(([, v]) => !v || typeof v !== 'object' || Array.isArray(v)).map(([p]) => SCRIPT_ERRORS.inputShape(p));
|
|
312
|
+
if (bad.length) return { ok: false, errors: bad };
|
|
313
|
+
}
|
|
314
|
+
// Bench §4.2: a saved case supplies EVERYTHING — with caseId set the engine ignores the
|
|
315
|
+
// request's cwd and timeoutMs (probed: a case naming a project ran there with the request
|
|
316
|
+
// saying scratch). So the case is read first: its folder must be the scratch folder or the
|
|
317
|
+
// project the USER pinned — never a project the case (which the chat may have saved) names —
|
|
318
|
+
// and its effective timeout must fit the tool's ceiling, or a 24 h timeoutMs holds the turn.
|
|
319
|
+
if (typeof caseId === 'string' && caseId) {
|
|
320
|
+
const stored = await io.readScript(key);
|
|
321
|
+
if (!stored || !stored.meta) return { ok: false, errors: [SCRIPT_ERRORS.notFound(key)] };
|
|
322
|
+
const kase = [...(Array.isArray(stored.cases) ? stored.cases : []), ...(Array.isArray(stored.userCases) ? stored.userCases : [])]
|
|
323
|
+
.find((c) => c && c.id === caseId) || null;
|
|
324
|
+
if (!kase) return { ok: false, errors: [SCRIPT_ERRORS.caseNotFound(key, caseId)] };
|
|
325
|
+
const kc = kase.cwd && typeof kase.cwd === 'object' ? kase.cwd : { kind: 'scratch' };
|
|
326
|
+
if (kc.kind === 'project' && (!pinnedProjectKey || kc.projectKey !== pinnedProjectKey)) {
|
|
327
|
+
return { ok: false, errors: [SCRIPT_ERRORS.caseProject(caseId, String(kc.projectKey ?? ''))] };
|
|
328
|
+
}
|
|
329
|
+
// The bench's own arithmetic (_prepare): the case's timeoutMs, else the script's, else the default.
|
|
330
|
+
const effective = Number.isInteger(kase.timeoutMs) && kase.timeoutMs >= MIN_TIMEOUT_MS
|
|
331
|
+
? Math.min(kase.timeoutMs, MAX_TIMEOUT_MS)
|
|
332
|
+
: (Number.isInteger(stored.meta.timeoutMs) ? stored.meta.timeoutMs : DEFAULT_TIMEOUT_MS);
|
|
333
|
+
const maxMs = ASK_LIMITS.scriptTestMaxTimeoutSec * 1000;
|
|
334
|
+
if (effective > maxMs) return { ok: false, errors: [SCRIPT_ERRORS.caseTimeout(caseId, Math.round(effective / 1000), ASK_LIMITS.scriptTestMaxTimeoutSec)] };
|
|
335
|
+
}
|
|
336
|
+
const lines = [];
|
|
337
|
+
let live = null;
|
|
338
|
+
// A stopped turn must not leave a test suite running: P1c hands the live bench over
|
|
339
|
+
// through onBench, and stop() aborts it into the runner's tree kill (spec §4.1 step 6).
|
|
340
|
+
const onAbort = () => { try { live?.stop(); } catch { /* the run is already over */ } };
|
|
341
|
+
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
342
|
+
let result;
|
|
343
|
+
try {
|
|
344
|
+
result = await io.runBenchOnce({ key, caseId, params, ports, inputs, cwd, timeoutMs }, {
|
|
345
|
+
onLine: (l) => { lines.push(l.text); },
|
|
346
|
+
onBench: (b) => { live = b; },
|
|
347
|
+
});
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return { ok: false, errors: refusal(err) };
|
|
350
|
+
} finally {
|
|
351
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
352
|
+
}
|
|
353
|
+
return { ok: true, result: trimBenchResult(result, lines) };
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// src/core/ask/source-deps.mjs
|
|
2
|
+
// The plugin task-source dependency bundle of the Ask Worca tools (source-spec.mjs): the ONE module
|
|
3
|
+
// that calls a task-source connector on the tools' behalf. tools.mjs imports nothing.
|
|
4
|
+
// • the MCP child: deps.sources.* behind list_task_sources, find_tasks and get_task — the same two
|
|
5
|
+
// read ops (listTasks, getTask) the New pipeline pane may drive, nothing else;
|
|
6
|
+
// • the parent: turn.mjs verifies a proposed task with lookupTask (title + link for the card).
|
|
7
|
+
// Tasks are fetched through the plugin shim, so a connector runs in its own scrubbed child process
|
|
8
|
+
// with its own secrets — the model never sees a token, only the task text (untrusted DATA).
|
|
9
|
+
import { listTaskSources } from '../sources.mjs';
|
|
10
|
+
import { callSource } from '../plugin-shim.mjs';
|
|
11
|
+
import { listProfileIds } from '../plugin-config.mjs';
|
|
12
|
+
import { resolveProfile } from '../source-bindings.mjs';
|
|
13
|
+
import { readWorkspace } from '../workspaces.mjs';
|
|
14
|
+
import { shapeSources, shapeTask } from './source-spec.mjs';
|
|
15
|
+
|
|
16
|
+
/** A read op against one source; `profile` resolved by the caller. */
|
|
17
|
+
export function sourceCall({ plugin, sourceId, op, args, profile }) {
|
|
18
|
+
if (op !== 'listTasks' && op !== 'getTask') throw new Error(`op "${op}" is not allowed from Ask Worca`);
|
|
19
|
+
return callSource({ plugin, sourceId, op, args, profile: profile || undefined, timeoutMs: 20000, logger: () => {} });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The parent's task check (proposal.mjs lookupTask). */
|
|
23
|
+
export const lookupTask = ({ plugin, sourceId, taskId, profile }) => sourceCall({ plugin, sourceId, op: 'getTask', args: { id: taskId }, profile });
|
|
24
|
+
|
|
25
|
+
function safeIds(plugin) { try { return listProfileIds(plugin); } catch { return []; } }
|
|
26
|
+
|
|
27
|
+
/** The installed plugin sources, each multi-profile one with its profile ids. */
|
|
28
|
+
export function installedSources() {
|
|
29
|
+
return listTaskSources().filter((s) => s.type === 'plugin')
|
|
30
|
+
.map((s) => (s.multiProfile ? { ...s, profiles: safeIds(s.plugin) } : s));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultSourceDeps() {
|
|
34
|
+
return {
|
|
35
|
+
taskSourceShapes: { shapeSources, shapeTask },
|
|
36
|
+
sources: {
|
|
37
|
+
list: installedSources,
|
|
38
|
+
/** The profile to call a multi-profile source with, for a project / workspace scope. */
|
|
39
|
+
async resolve({ plugin, sourceId, projectKey = null, workspaceId = null }) {
|
|
40
|
+
const available = safeIds(plugin);
|
|
41
|
+
if (workspaceId) {
|
|
42
|
+
let memberKeys = [];
|
|
43
|
+
try { memberKeys = (await readWorkspace(workspaceId))?.projectKeys || []; } catch { memberKeys = []; }
|
|
44
|
+
return resolveProfile({ scopeType: 'workspace', scopeKey: workspaceId, memberKeys, plugin, sourceId, available });
|
|
45
|
+
}
|
|
46
|
+
if (projectKey) return resolveProfile({ scopeType: 'project', scopeKey: projectKey, plugin, sourceId, available });
|
|
47
|
+
return available.length === 1 ? { profile: available[0], via: 'only' } : { profile: null, via: 'none', candidates: available };
|
|
48
|
+
},
|
|
49
|
+
call: sourceCall,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/core/ask/source-spec.mjs
|
|
2
|
+
// Plugin task sources in Ask Worca: "fix jira bug PROJ-123" as a run whose task is the ISSUE,
|
|
3
|
+
// not a copy of it. The card stores a reference (plugin, source, task id, profile, inputs) and
|
|
4
|
+
// the run fetches the task when it starts — exactly what New pipeline's source pane sends to
|
|
5
|
+
// POST /api/run, so a scheduled run reads the issue as it is at start time.
|
|
6
|
+
//
|
|
7
|
+
// Pure: every reader is injected (source-deps.mjs binds the real ones), so the MCP child and the
|
|
8
|
+
// parent's authoritative re-validation build the same card. Nothing here writes or fetches; the
|
|
9
|
+
// optional `lookupTask` (the parent only) is how the card learns the task's title and link.
|
|
10
|
+
import { ASK_LIMITS } from './limits.mjs';
|
|
11
|
+
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
const BREAKS_RE = /[\x00-\x1f\x7f-\x9f\u2028\u2029]/g;
|
|
14
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
15
|
+
const clip = (v, n) => String(v ?? '').replace(BREAKS_RE, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
16
|
+
const PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
17
|
+
/** Plugin failures that mean "try again later", not "this task is wrong". */
|
|
18
|
+
export const TRANSIENT_SOURCE_KINDS = Object.freeze(['network', 'rate-limit', 'timeout']);
|
|
19
|
+
/** The input a task browser fills — the task id itself, never a run input. */
|
|
20
|
+
const TASK_INPUT_TYPES = new Set(['task-browser']);
|
|
21
|
+
|
|
22
|
+
/** The model-facing view of the installed plugin task sources (list_task_sources). */
|
|
23
|
+
export function shapeSources(sources) {
|
|
24
|
+
return (Array.isArray(sources) ? sources : []).filter((s) => s && s.type === 'plugin').map((s) => ({
|
|
25
|
+
plugin: s.plugin, sourceId: s.sourceId, displayName: clip(s.displayName, 120),
|
|
26
|
+
inputs: (s.inputs || []).filter((i) => i && !TASK_INPUT_TYPES.has(i.type)).map((i) => ({
|
|
27
|
+
key: i.key, type: i.type, label: clip(i.label || i.key, 80),
|
|
28
|
+
...(i.default !== undefined ? { default: i.default } : {}),
|
|
29
|
+
...(Array.isArray(i.options) ? { options: i.options.slice(0, 20) } : {}),
|
|
30
|
+
})),
|
|
31
|
+
multiProfile: s.multiProfile === true,
|
|
32
|
+
profiles: s.multiProfile === true ? (s.profiles || []).map((p) => (typeof p === 'string' ? p : p && p.id)).filter(Boolean) : [],
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate the `source` of a propose_run input against the installed sources and the target.
|
|
38
|
+
* @param {object|undefined} raw { plugin, sourceId, taskId, profile?, inputs? }
|
|
39
|
+
* @param {{target:object, listTaskSources:() => Array, resolveProfile:(ref) => {profile, via, candidates?}}} readers
|
|
40
|
+
* @returns {{ok:true, source:object|null}|{ok:false, errors:string[]}}
|
|
41
|
+
*/
|
|
42
|
+
export function validateRunSource(raw, { target, listTaskSources, resolveProfile }) {
|
|
43
|
+
if (raw === undefined || raw === null) return { ok: true, source: null };
|
|
44
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['source must be an object: { plugin, sourceId, taskId, profile?, inputs? }'] };
|
|
45
|
+
const plugin = str(raw.plugin);
|
|
46
|
+
const sourceId = str(raw.sourceId);
|
|
47
|
+
const taskId = clip(raw.taskId, 200);
|
|
48
|
+
for (const [k, v] of [['plugin', plugin], ['sourceId', sourceId], ['taskId', taskId]]) {
|
|
49
|
+
if (!v) return { ok: false, errors: [`source.${k} is required — list_task_sources and find_tasks give it`] };
|
|
50
|
+
}
|
|
51
|
+
let all = [];
|
|
52
|
+
try { all = listTaskSources() || []; } catch { all = []; }
|
|
53
|
+
const src = all.find((s) => s && s.type === 'plugin' && s.plugin === plugin && s.sourceId === sourceId);
|
|
54
|
+
if (!src) {
|
|
55
|
+
const known = all.filter((s) => s && s.type === 'plugin').map((s) => `${s.plugin}/${s.sourceId}`);
|
|
56
|
+
return { ok: false, errors: [`no task source ${plugin}/${sourceId} is installed and enabled${known.length ? ` (installed: ${known.join(', ')})` : ' — none is installed'}`] };
|
|
57
|
+
}
|
|
58
|
+
const errors = [];
|
|
59
|
+
// Inputs: only the ones the source declares (never the task browser), string or boolean
|
|
60
|
+
// values; a declared default fills an absent one, as the New pipeline pane pre-fills it.
|
|
61
|
+
const inputs = {};
|
|
62
|
+
const declared = (src.inputs || []).filter((i) => i && i.key && !TASK_INPUT_TYPES.has(i.type));
|
|
63
|
+
const given = raw.inputs && typeof raw.inputs === 'object' && !Array.isArray(raw.inputs) ? raw.inputs : {};
|
|
64
|
+
for (const k of Object.keys(given)) {
|
|
65
|
+
if (!declared.some((i) => i.key === k)) errors.push(`source.inputs.${k} is not an input of ${src.displayName} (inputs: ${declared.map((i) => i.key).join(', ') || 'none'})`);
|
|
66
|
+
}
|
|
67
|
+
for (const i of declared) {
|
|
68
|
+
const v = Object.prototype.hasOwnProperty.call(given, i.key) ? given[i.key] : i.default;
|
|
69
|
+
if (v === undefined || v === null) continue;
|
|
70
|
+
if (typeof v !== 'string' && typeof v !== 'boolean' && typeof v !== 'number') { errors.push(`source.inputs.${i.key} must be a string`); continue; }
|
|
71
|
+
if (Array.isArray(i.options) && i.options.length && !i.options.map(String).includes(String(v))) {
|
|
72
|
+
errors.push(`source.inputs.${i.key} must be one of ${i.options.join(' | ')}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
inputs[i.key] = typeof v === 'string' ? clip(v, 500) : v;
|
|
76
|
+
}
|
|
77
|
+
// Profile: which configuration (tracker instance) of a multi-profile source. The project's
|
|
78
|
+
// binding decides when the model names none; two candidates are the user's call, never a guess.
|
|
79
|
+
let profile = null;
|
|
80
|
+
let profileVia = null;
|
|
81
|
+
const available = (src.profiles || []).map((p) => (typeof p === 'string' ? p : p && p.id)).filter(Boolean);
|
|
82
|
+
const named = str(raw.profile);
|
|
83
|
+
if (src.multiProfile) {
|
|
84
|
+
if (named) {
|
|
85
|
+
if (!PROFILE_RE.test(named) || !available.includes(named)) errors.push(`${src.displayName} has no profile "${named}" (profiles: ${available.join(', ') || 'none — set one up in Settings › Plugins'})`);
|
|
86
|
+
else { profile = named; profileVia = 'named'; }
|
|
87
|
+
} else {
|
|
88
|
+
const scope = target.workspaceId ? { scopeType: 'workspace', scopeKey: target.workspaceId, memberKeys: (target.members || []).map((m) => m.projectKey) }
|
|
89
|
+
: { scopeType: 'project', scopeKey: target.projectKey };
|
|
90
|
+
let r = { profile: null, via: 'none' };
|
|
91
|
+
try { r = resolveProfile({ ...scope, plugin, sourceId, available }) || r; } catch { /* unresolved */ }
|
|
92
|
+
if (r.profile) { profile = r.profile; profileVia = r.via; }
|
|
93
|
+
else if (!available.length) errors.push(`${src.displayName} has no profile yet — the user sets one up in Settings › Plugins`);
|
|
94
|
+
else errors.push(`${src.displayName} has several profiles and this ${target.workspaceId ? 'workspace' : 'project'} is not bound to one — ask the user which: ${(r.candidates && r.candidates.length ? r.candidates : available).join(', ')}`);
|
|
95
|
+
}
|
|
96
|
+
} else if (named) errors.push(`${src.displayName} does not use profiles — omit source.profile`);
|
|
97
|
+
if (errors.length) return { ok: false, errors };
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
source: {
|
|
101
|
+
type: 'plugin', plugin, sourceId, taskId, displayName: clip(src.displayName, 120),
|
|
102
|
+
...(profile ? { profile, profileVia } : {}),
|
|
103
|
+
...(Object.keys(inputs).length ? { inputs } : {}),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The parent's check that the task exists, and its title and link for the card. A transient
|
|
110
|
+
* failure keeps the card (the run fetches it again at start) and says so; anything else refuses.
|
|
111
|
+
* @param {(ref) => Promise<object|null>} lookupTask getTask through the plugin shim
|
|
112
|
+
* @returns {Promise<{ok:true, task:{title, url}|null, warning?:string}|{ok:false, error:string}>}
|
|
113
|
+
*/
|
|
114
|
+
export async function checkTask(source, lookupTask) {
|
|
115
|
+
if (typeof lookupTask !== 'function') return { ok: true, task: null };
|
|
116
|
+
try {
|
|
117
|
+
const t = await lookupTask({ plugin: source.plugin, sourceId: source.sourceId, taskId: source.taskId, profile: source.profile });
|
|
118
|
+
if (!t) return { ok: false, error: `${source.displayName} has no task "${source.taskId}" — find_tasks lists what it has` };
|
|
119
|
+
return { ok: true, task: { title: clip(t.title || '', 200) || null, url: typeof t.url === 'string' && /^https?:\/\//i.test(t.url) ? t.url.slice(0, 500) : null } };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const kind = err && err.kind;
|
|
122
|
+
if (TRANSIENT_SOURCE_KINDS.includes(kind)) {
|
|
123
|
+
return { ok: true, task: null, warning: `could not reach ${source.displayName} just now (${kind}); the run fetches the task when it starts` };
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, error: `${source.displayName}: ${clip(err && err.message ? err.message : String(err), 300)}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The POST /api/run `source` a card's reference becomes. */
|
|
130
|
+
export function runSourceOf(s) {
|
|
131
|
+
if (!s || s.type !== 'plugin') return null;
|
|
132
|
+
return {
|
|
133
|
+
type: 'plugin', plugin: s.plugin, sourceId: s.sourceId, taskId: s.taskId,
|
|
134
|
+
...(s.profile ? { profile: s.profile } : {}), ...(s.inputs ? { inputs: s.inputs } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A task as the model reads it (find_tasks rows / get_task): untrusted text, clipped. */
|
|
139
|
+
export function shapeTask(t, { redact = (x) => x, withBody = false } = {}) {
|
|
140
|
+
if (!t || typeof t !== 'object') return null;
|
|
141
|
+
const out = {
|
|
142
|
+
id: clip(t.id, 200), title: redact(clip(t.title, 300)),
|
|
143
|
+
...(typeof t.url === 'string' ? { url: t.url.slice(0, 500) } : {}),
|
|
144
|
+
...(t.state || t.status ? { state: clip(t.state || t.status, 40) } : {}),
|
|
145
|
+
...(Array.isArray(t.labels) ? { labels: t.labels.slice(0, 12).map((l) => clip(typeof l === 'string' ? l : l && l.name, 60)) } : {}),
|
|
146
|
+
...(t.updatedAt ? { updatedAt: clip(t.updatedAt, 40) } : {}),
|
|
147
|
+
};
|
|
148
|
+
if (withBody) {
|
|
149
|
+
const body = String(t.body ?? '');
|
|
150
|
+
out.body = redact(body.slice(0, ASK_LIMITS.taskBodyMaxChars ?? 20000));
|
|
151
|
+
if (body.length > (ASK_LIMITS.taskBodyMaxChars ?? 20000)) out.truncated = true;
|
|
152
|
+
if (t.meta && typeof t.meta === 'object') {
|
|
153
|
+
try { out.meta = JSON.parse(redact(JSON.stringify(t.meta).slice(0, 4000))); } catch { /* an unparsable clip is dropped */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
package/src/core/ask/spawn.mjs
CHANGED
|
@@ -91,6 +91,7 @@ export const SANDBOX_NOTE =
|
|
|
91
91
|
'The one other place Read may go is the file path read_attachment returns for an image or PDF attachment of this chat; never read anywhere else on disk. ' +
|
|
92
92
|
"Never call propose_workflow or propose_run yourself: proposals belong to the assistant's own turn (a sub-agent's call produces no card). " +
|
|
93
93
|
"Never call remember or forget yourself: saving or removing memory belongs to the assistant's own turn (list_memory and read_memory are fine). " +
|
|
94
|
+
"Never call save_script or test_script yourself: writing a script or running one belongs to the assistant's own turn (list_scripts and get_script are fine). " +
|
|
94
95
|
'Answer from tool results only; never invent run data; return a short report.';
|
|
95
96
|
|
|
96
97
|
/** System-prompt-only mock markers (the runner parses the ask role from the SYSTEM prompt, Task 16). */
|
package/src/core/ask/store.mjs
CHANGED
|
@@ -261,10 +261,12 @@ export function findCard(threadId, cardId) {
|
|
|
261
261
|
return null;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
|
|
264
|
+
// scheduledFor: a run card the user scheduled (state 'scheduled'); scheduleId + sentence: one that became a
|
|
265
|
+
// repeating schedule (docs/scheduled-runs.md "Ask Worca").
|
|
266
|
+
const CARD_PATCH_KEYS = ['state', 'runId', 'error', 'workflowId', 'scheduledFor', 'scheduleId', 'sentence', 'after'];
|
|
265
267
|
|
|
266
268
|
/** Patch ⊆ {state, runId, error, workflowId} on one card block. A WORKFLOW card (card.type === 'workflow') and a
|
|
267
|
-
* METRICS card (card.type
|
|
269
|
+
* METRICS, POLICY, SCHEDULE or MODEL card (card.type 'metrics' | 'policy' | 'schedule' | 'model', whose `result` lands at Apply) also take a SHALLOW `card` sub-patch; a run
|
|
268
270
|
* card's `card` is never touched (its key set is pinned). The 'proposed' precondition is the caller's (route) business. */
|
|
269
271
|
export function updateCardBlock(threadId, cardId, patch = {}) {
|
|
270
272
|
return tx(() => {
|
|
@@ -275,7 +277,8 @@ export function updateCardBlock(threadId, cardId, patch = {}) {
|
|
|
275
277
|
const sub = patch.card && typeof patch.card === 'object' && !Array.isArray(patch.card) ? patch.card : null;
|
|
276
278
|
const blocks = found.message.blocks.map((b) => {
|
|
277
279
|
if (!(b && b.kind === 'card' && b.id === cardId)) return b;
|
|
278
|
-
const subPatchable = !!(b.card && (b.card.type === 'workflow' || b.card.type === 'metrics'
|
|
280
|
+
const subPatchable = !!(b.card && (b.card.type === 'workflow' || b.card.type === 'metrics'
|
|
281
|
+
|| b.card.type === 'policy' || b.card.type === 'schedule' || b.card.type === 'model'));
|
|
279
282
|
return { ...b, ...allowed, ...(sub && subPatchable ? { card: { ...(b.card || {}), ...sub } } : {}) };
|
|
280
283
|
});
|
|
281
284
|
prepare('UPDATE ask_messages SET blocks = ? WHERE id = ?').run(JSON.stringify(blocks), found.message.id);
|
|
@@ -15,6 +15,7 @@ import { validateProposal } from './proposal.mjs';
|
|
|
15
15
|
import { readAttachmentText, getAttachment, attachmentPath, getThread, listAttachments } from './store.mjs';
|
|
16
16
|
import { redactAskText } from './redact.mjs';
|
|
17
17
|
import { ASK_LIMITS } from './limits.mjs';
|
|
18
|
+
import { askProgress } from '../ask-projection.mjs';
|
|
18
19
|
|
|
19
20
|
/** The patch file of a run row, or null when there is none (results.mjs#DIFF_PATCH_FILE only — never a caller path). */
|
|
20
21
|
export async function readDiffPatch(row) {
|
|
@@ -64,6 +65,9 @@ export function defaultToolDeps({ threadId }) {
|
|
|
64
65
|
listRunArtifacts: (row, filter) => listRunArtifacts(row.id, filter),
|
|
65
66
|
readRunArtifact: (row, rel) => resolveIndexedArtifactForRow(row, rel), // {rel, text}|null
|
|
66
67
|
readRunProgress: (row) => readRunProgress(row.id),
|
|
68
|
+
// Ask forms (spec D9, ruling X17): a persisted form round as text for the model.
|
|
69
|
+
// Injected, never imported — tools.mjs stays import-free by house rule.
|
|
70
|
+
askProgress,
|
|
67
71
|
readAttachment: (id) => {
|
|
68
72
|
const row = threadId ? getAttachment(threadId, id) : null;
|
|
69
73
|
if (!row) return null;
|