@worca/app 1.0.0 → 1.1.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 +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -108,7 +108,11 @@ export async function rmGuarded(runRoot, { worcaHome, pipelineId } = {}) {
|
|
|
108
108
|
return { ok: false, removed: false, reason: `refusing to remove ${target}: basename !== pipelineId ${pipelineId}` };
|
|
109
109
|
}
|
|
110
110
|
try {
|
|
111
|
-
|
|
111
|
+
// maxRetries: the run root holds git worktree checkouts; on Windows a dir
|
|
112
|
+
// cannot be removed while git's index/packed-refs handle (or a virus scanner)
|
|
113
|
+
// is still open, so a single rm EBUSY/EPERMs under load. Node backs off across
|
|
114
|
+
// attempts (~cumulative seconds); POSIX takes the first try.
|
|
115
|
+
await rm(target, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
|
|
112
116
|
return { ok: true, removed: true };
|
|
113
117
|
} catch (err) {
|
|
114
118
|
return { ok: false, removed: false, reason: err?.message || String(err) };
|
package/src/core/settings.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
|
42
42
|
import { join, resolve } from 'node:path';
|
|
43
43
|
import { homedir } from 'node:os';
|
|
44
44
|
import { randomBytes } from 'node:crypto';
|
|
45
|
-
import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
|
|
45
|
+
import { EFFORTS, isReservedModelEnvKey, assertModelCost } from './model-env.mjs';
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
|
|
@@ -384,6 +384,74 @@ export function assertCostLimitInputs(inputs = {}) {
|
|
|
384
384
|
if (has('costLimitResetPeriod')) assertResetPeriodInput(inputs.costLimitResetPeriod);
|
|
385
385
|
}
|
|
386
386
|
|
|
387
|
+
// ── Ask Worca per-turn limits (ask-worca-design.md §6.9, D12) ────────────────
|
|
388
|
+
// Two keys, both read fresh on every chat turn. `askMaxTurns` is an integer cap
|
|
389
|
+
// on claude's agentic turns (--max-turns); `askMaxBudgetUsd` is the per-turn
|
|
390
|
+
// dollar cap (--max-budget-usd). For the budget key the literal `null` is a
|
|
391
|
+
// STORED value meaning "no cap" (the flag is omitted), while '' / undefined clear
|
|
392
|
+
// the key back to the default — the two semantics the design assigns to that key.
|
|
393
|
+
export const DEFAULT_ASK_MAX_TURNS = 40;
|
|
394
|
+
export const DEFAULT_ASK_MAX_BUDGET_USD = 2;
|
|
395
|
+
|
|
396
|
+
const isAskMaxTurns = (v) => Number.isSafeInteger(v) && v >= 1 && v <= 500;
|
|
397
|
+
const isAskMaxBudget = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0.1 && v <= 100;
|
|
398
|
+
|
|
399
|
+
/** --max-turns for one chat turn: integer 1..500; absent/invalid ⇒ the default (loudly). */
|
|
400
|
+
export function askMaxTurns() {
|
|
401
|
+
const v = readSettings().askMaxTurns;
|
|
402
|
+
if (v === undefined) return DEFAULT_ASK_MAX_TURNS;
|
|
403
|
+
if (isAskMaxTurns(v)) return v;
|
|
404
|
+
console.warn(`[worca] invalid askMaxTurns ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_TURNS})`);
|
|
405
|
+
return DEFAULT_ASK_MAX_TURNS;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** --max-budget-usd for one chat turn: number 0.1..100, or null = no cap; absent/invalid ⇒ the default (loudly). */
|
|
409
|
+
export function askMaxBudgetUsd() {
|
|
410
|
+
const v = readSettings().askMaxBudgetUsd;
|
|
411
|
+
if (v === undefined) return DEFAULT_ASK_MAX_BUDGET_USD;
|
|
412
|
+
if (v === null) return null;
|
|
413
|
+
if (isAskMaxBudget(v)) return v;
|
|
414
|
+
console.warn(`[worca] invalid askMaxBudgetUsd ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_BUDGET_USD})`);
|
|
415
|
+
return DEFAULT_ASK_MAX_BUDGET_USD;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function assertAskMaxTurnsInput(input) {
|
|
419
|
+
if (!isClearInput(input) && !isAskMaxTurns(input)) {
|
|
420
|
+
throw new Error('askMaxTurns must be an integer between 1 and 500');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function assertAskMaxBudgetInput(input) {
|
|
424
|
+
if (input === '' || input === undefined || input === null) return; // clear, or stored no-cap
|
|
425
|
+
if (!isAskMaxBudget(input)) {
|
|
426
|
+
throw new Error('askMaxBudgetUsd must be null (no cap) or a number between 0.1 and 100');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Validate the ask keys PRESENT in `inputs` as a set, before any write (assertCostLimitInputs pattern). */
|
|
431
|
+
export function assertAskLimitInputs(inputs = {}) {
|
|
432
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(inputs, k);
|
|
433
|
+
if (has('askMaxTurns')) assertAskMaxTurnsInput(inputs.askMaxTurns);
|
|
434
|
+
if (has('askMaxBudgetUsd')) assertAskMaxBudgetInput(inputs.askMaxBudgetUsd);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function setAskMaxTurns(input) {
|
|
438
|
+
assertAskMaxTurnsInput(input);
|
|
439
|
+
const settings = readSettings();
|
|
440
|
+
if (isClearInput(input)) delete settings.askMaxTurns;
|
|
441
|
+
else settings.askMaxTurns = input;
|
|
442
|
+
await persistSettings(settings);
|
|
443
|
+
return { askMaxTurns: askMaxTurns() };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function setAskMaxBudgetUsd(input) {
|
|
447
|
+
assertAskMaxBudgetInput(input);
|
|
448
|
+
const settings = readSettings();
|
|
449
|
+
if (input === '' || input === undefined) delete settings.askMaxBudgetUsd;
|
|
450
|
+
else settings.askMaxBudgetUsd = input; // a number, or the literal null (no cap)
|
|
451
|
+
await persistSettings(settings);
|
|
452
|
+
return { askMaxBudgetUsd: askMaxBudgetUsd() };
|
|
453
|
+
}
|
|
454
|
+
|
|
387
455
|
/** Write (or clear) a USD cap key. @throws {Error} unless positive finite number (or empty). */
|
|
388
456
|
async function setUsdCap(key, input) {
|
|
389
457
|
assertUsdCapInput(key, input);
|
|
@@ -486,23 +554,42 @@ function sanitizeGlobalModel(raw) {
|
|
|
486
554
|
const env = {};
|
|
487
555
|
const rawEnv = raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env) ? raw.env : {};
|
|
488
556
|
for (const [k, v] of Object.entries(rawEnv)) {
|
|
489
|
-
|
|
557
|
+
// Trim before storing: an ANTHROPIC_MODEL wire id (#374) reaches `--model`
|
|
558
|
+
// verbatim, so a pasted ' claude-opus-4-8' or whitespace-only value must not
|
|
559
|
+
// land on disk. Whitespace-only (truthy but empty after trim) is dropped.
|
|
560
|
+
const t = typeof v === 'string' ? v.trim() : '';
|
|
561
|
+
if (isReservedModelEnvKey(k) || typeof v !== 'string' || !t) {
|
|
490
562
|
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping env key ${JSON.stringify(k)} (reserved or not a non-empty string)`);
|
|
491
563
|
continue;
|
|
492
564
|
}
|
|
493
|
-
env[k] =
|
|
565
|
+
env[k] = t;
|
|
494
566
|
}
|
|
567
|
+
const cost = sanitizeModelCost(raw.cost, id);
|
|
495
568
|
return {
|
|
496
569
|
id,
|
|
497
570
|
label,
|
|
498
571
|
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
499
572
|
...(Object.keys(env).length ? { env } : {}),
|
|
573
|
+
...(cost ? { cost } : {}),
|
|
500
574
|
};
|
|
501
575
|
}
|
|
502
576
|
|
|
577
|
+
/** Lenient read-side counterpart of assertModelCost (model-env.mjs): drops an
|
|
578
|
+
* invalid `cost` loudly instead of throwing (a corrupt settings.json must never
|
|
579
|
+
* break reads). */
|
|
580
|
+
function sanitizeModelCost(raw, id) {
|
|
581
|
+
if (raw == null) return undefined;
|
|
582
|
+
try {
|
|
583
|
+
return assertModelCost(raw);
|
|
584
|
+
} catch (e) {
|
|
585
|
+
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping invalid cost — ${e.message}`);
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
503
590
|
/**
|
|
504
|
-
* The sanitized global model catalog: [{id, label, efforts, env?}],
|
|
505
|
-
* shape (label/efforts always present). Missing/corrupt -> []. Malformed and
|
|
591
|
+
* The sanitized global model catalog: [{id, label, efforts, env?, cost?}],
|
|
592
|
+
* effective shape (label/efforts always present). Missing/corrupt -> []. Malformed and
|
|
506
593
|
* case-insensitively duplicate entries are dropped loudly (first wins). Never
|
|
507
594
|
* throws.
|
|
508
595
|
*/
|
|
@@ -580,12 +667,13 @@ function assertTestSettingsAccess() {
|
|
|
580
667
|
}
|
|
581
668
|
|
|
582
669
|
/** The MINIMAL stored shape for validated parts (see section comment). */
|
|
583
|
-
function storedModelShape(id, label, efforts, env) {
|
|
670
|
+
function storedModelShape(id, label, efforts, env, cost) {
|
|
584
671
|
return {
|
|
585
672
|
id,
|
|
586
673
|
...(label && label !== id ? { label } : {}),
|
|
587
674
|
...(efforts.length && efforts.length !== EFFORTS.length ? { efforts } : {}),
|
|
588
675
|
...(Object.keys(env).length ? { env } : {}),
|
|
676
|
+
...(cost ? { cost } : {}),
|
|
589
677
|
};
|
|
590
678
|
}
|
|
591
679
|
|
|
@@ -603,21 +691,23 @@ function rawModels(settings) {
|
|
|
603
691
|
|
|
604
692
|
/**
|
|
605
693
|
* Add a global catalog entry. `label` defaults to the id; `efforts` must be a
|
|
606
|
-
* subset of EFFORTS (empty/absent = all); `env` keys must not be reserved
|
|
607
|
-
*
|
|
694
|
+
* subset of EFFORTS (empty/absent = all); `env` keys must not be reserved;
|
|
695
|
+
* `cost` is an optional per-model override ({free} | {perMtok}, see assertModelCost).
|
|
696
|
+
* @returns {Promise<{id:string,label:string,efforts:string[],env?:object,cost?:object}>} the effective entry
|
|
608
697
|
* @throws {Error} on invalid input or a case-insensitively duplicate id
|
|
609
698
|
*/
|
|
610
|
-
export async function addGlobalModel({ id, label, efforts, env } = {}) {
|
|
699
|
+
export async function addGlobalModel({ id, label, efforts, env, cost } = {}) {
|
|
611
700
|
assertTestSettingsAccess();
|
|
612
701
|
const vid = assertModelId(id);
|
|
613
702
|
if (!isClearInput(label) && typeof label !== 'string') throw new Error('label must be a string');
|
|
614
703
|
const vefforts = assertEfforts(efforts);
|
|
615
704
|
const venv = assertEnvPairs(env);
|
|
705
|
+
const vcost = assertModelCost(cost);
|
|
616
706
|
const settings = readSettings();
|
|
617
707
|
const models = rawModels(settings);
|
|
618
708
|
if (findModelIndex(models, vid) !== -1) throw new Error(`a model with id ${JSON.stringify(vid)} already exists`);
|
|
619
709
|
const vlabel = (typeof label === 'string' && label.trim()) || vid;
|
|
620
|
-
settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv)];
|
|
710
|
+
settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv, vcost)];
|
|
621
711
|
await persistSettings(settings);
|
|
622
712
|
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
623
713
|
}
|
|
@@ -626,11 +716,12 @@ export async function addGlobalModel({ id, label, efforts, env } = {}) {
|
|
|
626
716
|
* Patch a global catalog entry. Omitted fields are kept. `label`: ''/null
|
|
627
717
|
* resets to the id. `efforts`: []/null resets to all. `env`: null clears the
|
|
628
718
|
* whole map; an object merges per key, where a null value DELETES that key and
|
|
629
|
-
* a string sets it (write-only PATCH semantics, design §4.10).
|
|
719
|
+
* a string sets it (write-only PATCH semantics, design §4.10). `cost`: null/''
|
|
720
|
+
* removes the override; an object replaces it wholesale.
|
|
630
721
|
* @returns {Promise<object>} the effective entry
|
|
631
722
|
* @throws {Error} on an unknown id or invalid input
|
|
632
723
|
*/
|
|
633
|
-
export async function updateGlobalModel(id, { label, efforts, env } = {}) {
|
|
724
|
+
export async function updateGlobalModel(id, { label, efforts, env, cost } = {}) {
|
|
634
725
|
assertTestSettingsAccess();
|
|
635
726
|
const vid = assertModelId(id);
|
|
636
727
|
const settings = readSettings();
|
|
@@ -658,8 +749,13 @@ export async function updateGlobalModel(id, { label, efforts, env } = {}) {
|
|
|
658
749
|
}
|
|
659
750
|
}
|
|
660
751
|
|
|
752
|
+
// cost: omitted keeps the current override; null/'' removes it; an object
|
|
753
|
+
// replaces it wholesale (not a per-key merge — the table is small).
|
|
754
|
+
let nextCost = current.cost;
|
|
755
|
+
if (cost !== undefined) nextCost = isClearInput(cost) ? undefined : assertModelCost(cost);
|
|
756
|
+
|
|
661
757
|
settings.models = models.slice();
|
|
662
|
-
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv);
|
|
758
|
+
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv, nextCost);
|
|
663
759
|
await persistSettings(settings);
|
|
664
760
|
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
665
761
|
}
|
package/src/core/skills.mjs
CHANGED
|
@@ -105,12 +105,19 @@ export function resolveSkill(name, { repoRoot, projectDir, homeDir = homedir(),
|
|
|
105
105
|
* `registry` is the plain object returned by loadAgentRegistry (keyed by agent
|
|
106
106
|
* key); `plan.steps` is Array<Array<node>> and each node has `.key`.
|
|
107
107
|
* @param {Record<string, {requiresSkills?: string[]}>} registry
|
|
108
|
-
* @param {{steps?: Array<Array<{key:string}>>}} plan
|
|
108
|
+
* @param {{steps?: Array<Array<{key:string}>>}|Iterable<string>} planOrKeys v1 plan, or agent keys
|
|
109
109
|
* @returns {Array<{skill:string, requiredBy:string[]}>}
|
|
110
110
|
*/
|
|
111
|
-
export function collectRequiredSkills(registry,
|
|
111
|
+
export function collectRequiredSkills(registry, planOrKeys) {
|
|
112
|
+
// Two entry shapes: the v1 ExecutablePlan ({steps:[[{key}]]}) and a plain
|
|
113
|
+
// iterable of agent keys (what the engine-agnostic run harness has — it never
|
|
114
|
+
// sees a v1 plan). A plan object is not iterable, so the test is unambiguous.
|
|
112
115
|
const nodeKeys = new Set();
|
|
113
|
-
|
|
116
|
+
if (planOrKeys && typeof planOrKeys !== 'string' && typeof planOrKeys[Symbol.iterator] === 'function') {
|
|
117
|
+
for (const key of planOrKeys) nodeKeys.add(key);
|
|
118
|
+
} else {
|
|
119
|
+
for (const group of planOrKeys?.steps || []) for (const node of group) nodeKeys.add(node.key);
|
|
120
|
+
}
|
|
114
121
|
/** @type {Map<string, Set<string>>} */
|
|
115
122
|
const bySkill = new Map();
|
|
116
123
|
for (const key of nodeKeys) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/core/source-bindings.mjs
|
|
2
|
+
// Which PROFILE of a plugin task source a project (or workspace) pulls from.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves: one worca install drives several projects, and each
|
|
5
|
+
// may belong to a different tracker instance — repo A files tickets in one Jira,
|
|
6
|
+
// repo B in another. Making that a per-run dropdown is how you start a pipeline
|
|
7
|
+
// against the wrong tracker: the mistake is silent (the wrong ticket text simply
|
|
8
|
+
// lands in the prompt) and only visible once the run is underway. So the choice
|
|
9
|
+
// is made ONCE per project and remembered here.
|
|
10
|
+
//
|
|
11
|
+
// Rows are (scope_type, scope_key, plugin, source_id) -> profile. A project that
|
|
12
|
+
// pulls from two different sources binds each independently.
|
|
13
|
+
//
|
|
14
|
+
// Deliberately tolerant on read: an unbound scope, an uninstalled plugin and a
|
|
15
|
+
// profile that was since deleted all collapse to the same answer (null), because
|
|
16
|
+
// every caller's next move is identical — ask the user. But "never bound" and
|
|
17
|
+
// "was bound, then the profile went away" are NOT the same for the single-
|
|
18
|
+
// profile convenience fallback: a scope that once chose a tracker must be ASKED
|
|
19
|
+
// again, never silently re-pointed at whichever profile happens to survive. So
|
|
20
|
+
// deleting a profile TOMBSTONES its bindings (profile = '') instead of dropping
|
|
21
|
+
// the rows; a tombstone reads as unbound everywhere except resolveProfile's
|
|
22
|
+
// 'only' fallback, which it suppresses.
|
|
23
|
+
|
|
24
|
+
import { getDb, prepare } from './db.mjs';
|
|
25
|
+
|
|
26
|
+
/** Deleted-profile marker. Reads as "unbound" (getBinding -> null) but records
|
|
27
|
+
* that this scope HAD chosen — resolveProfile must ask, not guess. */
|
|
28
|
+
const TOMBSTONE = '';
|
|
29
|
+
|
|
30
|
+
/** The two scopes a binding can hang off. */
|
|
31
|
+
const SCOPES = new Set(['project', 'workspace']);
|
|
32
|
+
|
|
33
|
+
function checkScope(scopeType) {
|
|
34
|
+
if (!SCOPES.has(scopeType)) throw new Error(`invalid binding scope "${scopeType}"`);
|
|
35
|
+
return scopeType;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const key = (o = {}) => ({
|
|
39
|
+
scopeType: checkScope(o.scopeType),
|
|
40
|
+
scopeKey: String(o.scopeKey || ''),
|
|
41
|
+
plugin: String(o.plugin || ''),
|
|
42
|
+
sourceId: String(o.sourceId || ''),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Raw row value: null (no row) | '' (tombstone) | profile id. Internal —
|
|
46
|
+
* resolveProfile needs the three-way answer; everyone else wants getBinding. */
|
|
47
|
+
function rawBinding(ref) {
|
|
48
|
+
const k = key(ref);
|
|
49
|
+
if (!k.scopeKey || !k.plugin || !k.sourceId) return null;
|
|
50
|
+
getDb();
|
|
51
|
+
const row = prepare(
|
|
52
|
+
'SELECT profile FROM source_bindings WHERE scope_type = ? AND scope_key = ? AND plugin = ? AND source_id = ?'
|
|
53
|
+
).get(k.scopeType, k.scopeKey, k.plugin, k.sourceId);
|
|
54
|
+
return row ? String(row.profile) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The profile explicitly bound to this scope, or null (a tombstoned binding
|
|
59
|
+
* reads as unbound). No fallback logic — use resolveProfile() for that.
|
|
60
|
+
* @returns {string|null}
|
|
61
|
+
*/
|
|
62
|
+
export function getBinding(ref) {
|
|
63
|
+
const p = rawBinding(ref);
|
|
64
|
+
return p === TOMBSTONE ? null : p;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bind (or re-bind) a scope to a profile. Returns the stored profile. */
|
|
68
|
+
export function setBinding(ref, profile) {
|
|
69
|
+
const k = key(ref);
|
|
70
|
+
const p = String(profile || '').trim();
|
|
71
|
+
if (!k.scopeKey || !k.plugin || !k.sourceId) throw new Error('binding needs scopeKey, plugin and sourceId');
|
|
72
|
+
if (!p) throw new Error('binding needs a profile');
|
|
73
|
+
getDb();
|
|
74
|
+
prepare(`
|
|
75
|
+
INSERT INTO source_bindings (scope_type, scope_key, plugin, source_id, profile, updated_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
77
|
+
ON CONFLICT (scope_type, scope_key, plugin, source_id)
|
|
78
|
+
DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at
|
|
79
|
+
`).run(k.scopeType, k.scopeKey, k.plugin, k.sourceId, p, new Date().toISOString());
|
|
80
|
+
return p;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Remove a binding. Silent when there was none. */
|
|
84
|
+
export function clearBinding(ref) {
|
|
85
|
+
const k = key(ref);
|
|
86
|
+
getDb();
|
|
87
|
+
prepare(
|
|
88
|
+
'DELETE FROM source_bindings WHERE scope_type = ? AND scope_key = ? AND plugin = ? AND source_id = ?'
|
|
89
|
+
).run(k.scopeType, k.scopeKey, k.plugin, k.sourceId);
|
|
90
|
+
return { ok: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every LIVE binding for one scope: [{ plugin, sourceId, profile }].
|
|
94
|
+
* Tombstones are bookkeeping for resolveProfile, not bindings — excluded. */
|
|
95
|
+
export function listBindingsForScope(scopeType, scopeKey) {
|
|
96
|
+
getDb();
|
|
97
|
+
return prepare(
|
|
98
|
+
"SELECT plugin, source_id, profile FROM source_bindings WHERE scope_type = ? AND scope_key = ? AND profile != '' ORDER BY plugin, source_id"
|
|
99
|
+
).all(checkScope(scopeType), String(scopeKey || ''))
|
|
100
|
+
.map((r) => ({ plugin: r.plugin, sourceId: r.source_id, profile: r.profile }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Tombstone every binding naming a profile — called when that profile is
|
|
104
|
+
* deleted, so a project never points at a profile that is gone, and never
|
|
105
|
+
* silently re-points at a survivor either (the tombstone suppresses the
|
|
106
|
+
* single-profile fallback until the user chooses again). Plugin-wide (no
|
|
107
|
+
* sourceId): profiles and their buckets are per-PLUGIN, so deleting one
|
|
108
|
+
* dangles every source's binding to it, not just the source the delete came
|
|
109
|
+
* in through. Returns the row count. */
|
|
110
|
+
export function clearBindingsForProfile(plugin, profile) {
|
|
111
|
+
getDb();
|
|
112
|
+
const p = String(profile || '');
|
|
113
|
+
if (!p) return 0; // '' is the tombstone itself, never a deletable profile
|
|
114
|
+
const r = prepare(
|
|
115
|
+
'UPDATE source_bindings SET profile = ?, updated_at = ? WHERE plugin = ? AND profile = ?'
|
|
116
|
+
).run(TOMBSTONE, new Date().toISOString(), String(plugin || ''), p);
|
|
117
|
+
return Number(r?.changes ?? 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Drop every binding for a plugin — called on uninstall. */
|
|
121
|
+
export function clearBindingsForPlugin(plugin) {
|
|
122
|
+
getDb();
|
|
123
|
+
prepare('DELETE FROM source_bindings WHERE plugin = ?').run(String(plugin || ''));
|
|
124
|
+
return { ok: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The profile a run should use, with the fallbacks that make the common case
|
|
129
|
+
* invisible:
|
|
130
|
+
*
|
|
131
|
+
* 1. an explicit binding on this scope wins;
|
|
132
|
+
* 2. for a WORKSPACE with no binding of its own, the member projects' bindings
|
|
133
|
+
* decide — but only if they AGREE. Members split across two trackers is a
|
|
134
|
+
* real ambiguity, and guessing one would be the silent-wrong-tracker bug
|
|
135
|
+
* this module exists to prevent, so it stays unresolved;
|
|
136
|
+
* 3. a source with exactly one profile needs no ceremony — use it, UNLESS
|
|
137
|
+
* this scope once chose a profile that has since gone away (a tombstoned
|
|
138
|
+
* or stale binding): its next run against a tracker it never picked is
|
|
139
|
+
* the exact silent-wrong-tracker bug again, so it degrades to "ask";
|
|
140
|
+
* 4. otherwise null: the caller must ask.
|
|
141
|
+
*
|
|
142
|
+
* `available` is the source's profile id list (plugin-config#listProfiles). A
|
|
143
|
+
* binding naming a profile that is no longer in it is treated as absent, so
|
|
144
|
+
* deleting a profile degrades to "ask" rather than to a broken run.
|
|
145
|
+
*
|
|
146
|
+
* @param {{scopeType:string, scopeKey:string, plugin:string, sourceId:string,
|
|
147
|
+
* memberKeys?:string[], available?:string[]}} ref
|
|
148
|
+
* @returns {{profile:string|null, via:'binding'|'members'|'only'|'none'|'conflict',
|
|
149
|
+
* candidates?:string[]}}
|
|
150
|
+
*/
|
|
151
|
+
export function resolveProfile(ref = {}) {
|
|
152
|
+
const available = Array.isArray(ref.available) ? ref.available.map(String) : null;
|
|
153
|
+
const known = (p) => !!p && (!available || available.includes(p));
|
|
154
|
+
|
|
155
|
+
const own = rawBinding(ref);
|
|
156
|
+
if (known(own)) return { profile: own, via: 'binding' };
|
|
157
|
+
// A row that no longer names a live profile — tombstoned by a delete, or
|
|
158
|
+
// stale (its profile left the roster while we weren't looking) — means this
|
|
159
|
+
// scope HAD chosen. It must choose again; no fallback may choose for it.
|
|
160
|
+
let hadBinding = own !== null;
|
|
161
|
+
|
|
162
|
+
if (ref.scopeType === 'workspace' && Array.isArray(ref.memberKeys) && ref.memberKeys.length) {
|
|
163
|
+
const found = new Set();
|
|
164
|
+
for (const k of ref.memberKeys) {
|
|
165
|
+
const p = rawBinding({ scopeType: 'project', scopeKey: k, plugin: ref.plugin, sourceId: ref.sourceId });
|
|
166
|
+
if (known(p)) found.add(p);
|
|
167
|
+
else if (p !== null) hadBinding = true; // a member's lapsed choice suppresses the fallback too
|
|
168
|
+
}
|
|
169
|
+
if (found.size === 1) return { profile: [...found][0], via: 'members' };
|
|
170
|
+
if (found.size > 1) return { profile: null, via: 'conflict', candidates: [...found] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!hadBinding && available && available.length === 1) return { profile: available[0], via: 'only' };
|
|
174
|
+
return { profile: null, via: 'none' };
|
|
175
|
+
}
|
package/src/core/sources.mjs
CHANGED
|
@@ -7,18 +7,34 @@
|
|
|
7
7
|
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { readFileSync } from 'node:fs';
|
|
10
|
-
import {
|
|
10
|
+
import { join } from 'node:path';
|
|
11
11
|
import { callSource } from './plugin-shim.mjs';
|
|
12
12
|
import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs';
|
|
13
|
+
import { listProfiles, DEFAULT_PROFILE } from './plugin-config.mjs';
|
|
13
14
|
import { normalizeManifest } from './plugin-manifest.mjs';
|
|
14
15
|
import { getDb } from './db.mjs';
|
|
15
|
-
import { runDirForRow, readStoreMeta } from './artifacts.mjs';
|
|
16
|
+
import { runDirForRow, readStoreMeta, readPromptFile } from './artifacts.mjs';
|
|
16
17
|
import { RESULTS_FILE } from './results.mjs';
|
|
17
18
|
import { hasGh, findPrForBranch } from './git-info.mjs';
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** Profile roster for a plugin; never throws — the pane must render even when a
|
|
21
|
+
* plugin's data dir is unreadable (it degrades to "no profiles yet"). */
|
|
22
|
+
function safeProfiles(name) {
|
|
23
|
+
try { return listProfiles(name); } catch { return []; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether an installed source declares multiProfile; never throws (an
|
|
27
|
+
* uninstalled plugin or broken manifest answers false — the callers below
|
|
28
|
+
* degrade to pre-profiles behavior, which is the only thing they could do). */
|
|
29
|
+
function isMultiProfileSource(plugin, sourceId) {
|
|
30
|
+
try {
|
|
31
|
+
const dir = pluginCurrentDir(plugin);
|
|
32
|
+
const norm = normalizeManifest(JSON.parse(readFileSync(join(dir, 'worca-cc-plugin.json'), 'utf8')), { dir });
|
|
33
|
+
if (!norm.ok) return false;
|
|
34
|
+
return (norm.manifest.taskSources || []).some((s) => s.id === sourceId && s.multiProfile === true);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
22
38
|
}
|
|
23
39
|
|
|
24
40
|
/**
|
|
@@ -50,6 +66,12 @@ export function listTaskSources() {
|
|
|
50
66
|
sourceId: ts.id,
|
|
51
67
|
displayName: ts.displayName || `${name}/${ts.id}`,
|
|
52
68
|
inputs: ts.inputs || [],
|
|
69
|
+
// A multi-profile source cannot be used until the pane knows WHICH
|
|
70
|
+
// configuration to run against, so the roster ships with the listing —
|
|
71
|
+
// the New Pipeline pane resolves the project's binding against it
|
|
72
|
+
// without a second round trip.
|
|
73
|
+
multiProfile: ts.multiProfile === true,
|
|
74
|
+
profiles: ts.multiProfile === true ? safeProfiles(name) : [],
|
|
53
75
|
});
|
|
54
76
|
}
|
|
55
77
|
}
|
|
@@ -68,9 +90,9 @@ function taskPromptText(task) {
|
|
|
68
90
|
/**
|
|
69
91
|
* Resolve a source descriptor to the pipeline's task input.
|
|
70
92
|
* { type:'prompt', prompt } | { type:'markdown', promptText?, promptFile? }
|
|
71
|
-
* | { type:'plugin', plugin, sourceId, taskId, inputs? }
|
|
93
|
+
* | { type:'plugin', plugin, sourceId, taskId, inputs?, profile? }
|
|
72
94
|
* @returns {Promise<{promptText:string, promptFile:string|null,
|
|
73
|
-
* sourceMeta:{plugin,sourceId,taskId,url,title}|null}>}
|
|
95
|
+
* sourceMeta:{plugin,sourceId,taskId,profile,url,title}|null}>}
|
|
74
96
|
*/
|
|
75
97
|
export async function resolveTaskInput(source, { projectDir } = {}) {
|
|
76
98
|
const src = source && typeof source === 'object' ? source : { type: 'prompt', prompt: '' };
|
|
@@ -82,19 +104,21 @@ export async function resolveTaskInput(source, { projectDir } = {}) {
|
|
|
82
104
|
|
|
83
105
|
if (type === 'markdown') {
|
|
84
106
|
if (src.promptFile) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
107
|
+
// A NAMED file that cannot be read is an ERROR, never an empty prompt: the
|
|
108
|
+
// old catch{} degradation here (and the identical one in createPipeline) ran
|
|
109
|
+
// a whole pipeline on "" and exited 0. Throws PROMPT_FILE_UNREADABLE — the
|
|
110
|
+
// CLI fail()s on it and ui/server.mjs answers 400.
|
|
111
|
+
const promptText = await readPromptFile(projectDir, src.promptFile);
|
|
91
112
|
return { promptText, promptFile: src.promptFile, sourceMeta: null };
|
|
92
113
|
}
|
|
93
114
|
return { promptText: typeof src.promptText === 'string' ? src.promptText : '', promptFile: null, sourceMeta: null };
|
|
94
115
|
}
|
|
95
116
|
|
|
96
117
|
if (type === 'plugin') {
|
|
97
|
-
const task = await callSource({
|
|
118
|
+
const task = await callSource({
|
|
119
|
+
plugin: src.plugin, sourceId: src.sourceId, op: 'getTask',
|
|
120
|
+
args: { id: src.taskId }, profile: src.profile,
|
|
121
|
+
});
|
|
98
122
|
if (!task) {
|
|
99
123
|
throw new Error(`task-source ${src.plugin}/${src.sourceId}: task "${src.taskId}" not found`);
|
|
100
124
|
}
|
|
@@ -105,6 +129,14 @@ export async function resolveTaskInput(source, { projectDir } = {}) {
|
|
|
105
129
|
plugin: src.plugin,
|
|
106
130
|
sourceId: src.sourceId,
|
|
107
131
|
taskId: src.taskId,
|
|
132
|
+
// Pinned on the ROW, not re-derived at write-back time: the project's
|
|
133
|
+
// binding may have moved to another tracker by then, and a result must
|
|
134
|
+
// always be reported to the instance the task actually came from.
|
|
135
|
+
profile: src.profile || null,
|
|
136
|
+
// The source-panel inputs at fetch time, pinned for the same reason:
|
|
137
|
+
// a per-RUN choice (e.g. jira-source's "Write result back") must gate
|
|
138
|
+
// this run's report with what the user picked when they started it.
|
|
139
|
+
inputs: src.inputs && typeof src.inputs === 'object' ? src.inputs : null,
|
|
108
140
|
url: task.url ?? null,
|
|
109
141
|
title: task.title ?? null,
|
|
110
142
|
},
|
|
@@ -164,31 +196,61 @@ export async function reportResultForPipeline(pipelineRow, resultsBundle) {
|
|
|
164
196
|
try { ref = pipelineRow.source_ref ? JSON.parse(pipelineRow.source_ref) : null; } catch { ref = null; }
|
|
165
197
|
if (!ref?.plugin || !ref?.sourceId || !ref?.taskId) return { ok: true, skipped: true };
|
|
166
198
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
199
|
+
// Which profile to report against. Normally the one pinned on the row when
|
|
200
|
+
// the task was fetched (resolveTaskInput). A row that PREDATES profiles has
|
|
201
|
+
// none — and if the plugin has since upgraded to multiProfile, refusing to
|
|
202
|
+
// report would strand the row forever (no code path can attach a profile to
|
|
203
|
+
// an existing source_ref, so the results-view retry would fail identically
|
|
204
|
+
// every time). Its task came from the instance whose flat config migrated
|
|
205
|
+
// into the DEFAULT_PROFILE bucket, so that is exactly where the report
|
|
206
|
+
// belongs — allowLegacyDefault is the shim's host-internal opt-in for it.
|
|
207
|
+
const profile = ref.profile || null;
|
|
208
|
+
const allowLegacyDefault = !profile && isMultiProfileSource(ref.plugin, ref.sourceId);
|
|
209
|
+
const callRef = {
|
|
210
|
+
plugin: ref.plugin,
|
|
211
|
+
sourceId: ref.sourceId,
|
|
212
|
+
profile: allowLegacyDefault ? DEFAULT_PROFILE : profile,
|
|
213
|
+
allowLegacyDefault,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Capability probe. capabilities() is optional in the connector contract
|
|
217
|
+
// (§7.1: "defaults: writeBack true"): a connector without the op makes the
|
|
218
|
+
// child answer kind 'unimplemented' -> we default to writeBack:true.
|
|
219
|
+
// The run's pinned source-panel inputs travel to BOTH ops so a connector
|
|
220
|
+
// can make write-back a per-run choice: the opt-out rides the reserved
|
|
221
|
+
// input key `writeBack` (jira-source's "Write result back" input;
|
|
222
|
+
// capabilities({inputs}) answers for THIS run).
|
|
223
|
+
// Every OTHER probe error — an implemented capabilities() that crashed, a
|
|
224
|
+
// transport failure (auth/network/timeout/protocol/rate-limit) — means the
|
|
225
|
+
// connector could not answer, and what happens next depends on whether an
|
|
226
|
+
// answer could have mattered: a run that pinned no `writeBack` input fails
|
|
227
|
+
// OPEN (default writeBack:true — a transient rate-limit on the probe must
|
|
228
|
+
// not silently drop the ticket comment when the report itself would have
|
|
229
|
+
// succeeded), while a run WITH one fails CLOSED (the opt-out may be 'no'
|
|
230
|
+
// and we could not hear it; writing would violate it) — the caller
|
|
231
|
+
// surfaces the error and the results-view retry re-probes.
|
|
232
|
+
const inputs = ref.inputs && typeof ref.inputs === 'object' ? ref.inputs : {};
|
|
174
233
|
let writeBack = true;
|
|
175
234
|
try {
|
|
176
|
-
const caps = await callSource({
|
|
235
|
+
const caps = await callSource({ ...callRef, op: 'capabilities', args: { inputs } });
|
|
177
236
|
if (caps && caps.writeBack === false) writeBack = false;
|
|
178
|
-
} catch {
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if ((err?.kind || 'plugin') !== 'unimplemented' && 'writeBack' in inputs) {
|
|
239
|
+
return { ok: false, error: `write-back capability probe failed: ${err?.message || String(err)}` };
|
|
240
|
+
}
|
|
179
241
|
writeBack = true;
|
|
180
242
|
}
|
|
181
243
|
if (!writeBack) return { ok: true, skipped: true };
|
|
182
244
|
|
|
183
245
|
await callSource({
|
|
184
|
-
|
|
185
|
-
sourceId: ref.sourceId,
|
|
246
|
+
...callRef,
|
|
186
247
|
op: 'reportResult',
|
|
187
248
|
args: {
|
|
188
249
|
id: ref.taskId,
|
|
189
250
|
status: statusToResult(pipelineRow.status),
|
|
190
251
|
summary: buildResultSummary(pipelineRow, resultsBundle),
|
|
191
252
|
links: buildResultLinks(resultsBundle),
|
|
253
|
+
inputs,
|
|
192
254
|
},
|
|
193
255
|
});
|
|
194
256
|
return { ok: true };
|