@worca/app 1.2.0 → 1.3.0-rc.2
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/src/core/settings.mjs
CHANGED
|
@@ -277,6 +277,67 @@ export function contextMaxBytesTotal() {
|
|
|
277
277
|
return readByteCap('contextMaxBytesTotal', DEFAULT_CONTEXT_MAX_BYTES_TOTAL);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// ── Agent memory caps (agent-memory-design.md §2 / §12) ─────────────────────
|
|
281
|
+
export const DEFAULT_MEMORY_SOFT_BYTES_PER_FILE = 8192; // flagged in health above this
|
|
282
|
+
export const DEFAULT_MEMORY_HARD_BYTES_PER_FILE = 32768; // rejected at sync-back / write above this
|
|
283
|
+
export const DEFAULT_MEMORY_MAX_FILES_PER_SCOPE = 50;
|
|
284
|
+
export const DEFAULT_MEMORY_HOOK_MAX_CHARS = 160;
|
|
285
|
+
|
|
286
|
+
export const DEFAULT_MEMORY_DEFRAG_WRITES = 10; // writesSinceDefrag at which a scope is "due"
|
|
287
|
+
export const DEFAULT_MEMORY_DEFRAG_FILES = 30; // file count at which a scope is "due"
|
|
288
|
+
export const DEFAULT_MEMORY_DEFRAG_BYTES_PCT = 60; // % of (maxFilesPerScope × softBytesPerFile) at which a scope is "due"
|
|
289
|
+
export const DEFAULT_MEMORY_DEFRAG_ALWAYS_ON_BYTES = 16384; // bytes of path-less memory (loaded into EVERY agent's context) at which a scope is "due"
|
|
290
|
+
|
|
291
|
+
/** settings.json → { memory: { maxBytesPerFile, softBytesPerFile, maxFilesPerScope, hookMaxChars,
|
|
292
|
+
* defrag: { writes, files, bytesPct, alwaysOnBytes } } }. Every key optional; a bad value warns (naming the full key) and falls back. */
|
|
293
|
+
function memoryBlock() {
|
|
294
|
+
const block = readSettings().memory;
|
|
295
|
+
return block && typeof block === 'object' && !Array.isArray(block) ? block : {};
|
|
296
|
+
}
|
|
297
|
+
function readMemoryCap(key, fallback) {
|
|
298
|
+
const v = memoryBlock()[key];
|
|
299
|
+
if (v === undefined) return fallback;
|
|
300
|
+
if (isByteCap(v)) return v;
|
|
301
|
+
console.warn(`[worca] invalid memory.${key} ${JSON.stringify(v)} — using ${fallback}`);
|
|
302
|
+
return fallback;
|
|
303
|
+
}
|
|
304
|
+
let warnedDefragBlock = false; // module-level: the reader runs once per threshold key, the block is one mistake
|
|
305
|
+
/** `memory.defrag.<key>`: a positive integer; `bytesPct` additionally ≤ 100 (it is a share).
|
|
306
|
+
* A `defrag` that is not a plain object is one mistake, not three: warn ONCE and fall back. */
|
|
307
|
+
function readDefragThreshold(key, fallback, { pct = false } = {}) {
|
|
308
|
+
const d = memoryBlock().defrag;
|
|
309
|
+
const isBlock = Boolean(d) && typeof d === 'object' && !Array.isArray(d);
|
|
310
|
+
if (d !== undefined && !isBlock) {
|
|
311
|
+
if (!warnedDefragBlock) {
|
|
312
|
+
warnedDefragBlock = true;
|
|
313
|
+
console.warn(`[worca] invalid memory.defrag ${JSON.stringify(d)} — using the defaults`);
|
|
314
|
+
}
|
|
315
|
+
return fallback;
|
|
316
|
+
}
|
|
317
|
+
const v = isBlock ? d[key] : undefined;
|
|
318
|
+
if (v === undefined) return fallback;
|
|
319
|
+
if (isByteCap(v) && (!pct || v <= 100)) return v;
|
|
320
|
+
console.warn(`[worca] invalid memory.defrag.${key} ${JSON.stringify(v)} — using ${fallback}`);
|
|
321
|
+
return fallback;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The caps every memory reader/writer takes (memory-store.mjs, memory-sync.mjs). Read fresh per call.
|
|
325
|
+
* `defrag` is the health threshold block (agent-memory-design.md §8 / §12). */
|
|
326
|
+
export function memoryCaps() {
|
|
327
|
+
return {
|
|
328
|
+
softBytesPerFile: readMemoryCap('softBytesPerFile', DEFAULT_MEMORY_SOFT_BYTES_PER_FILE),
|
|
329
|
+
hardBytesPerFile: readMemoryCap('maxBytesPerFile', DEFAULT_MEMORY_HARD_BYTES_PER_FILE),
|
|
330
|
+
maxFilesPerScope: readMemoryCap('maxFilesPerScope', DEFAULT_MEMORY_MAX_FILES_PER_SCOPE),
|
|
331
|
+
hookMaxChars: readMemoryCap('hookMaxChars', DEFAULT_MEMORY_HOOK_MAX_CHARS),
|
|
332
|
+
defrag: {
|
|
333
|
+
writes: readDefragThreshold('writes', DEFAULT_MEMORY_DEFRAG_WRITES),
|
|
334
|
+
files: readDefragThreshold('files', DEFAULT_MEMORY_DEFRAG_FILES),
|
|
335
|
+
bytesPct: readDefragThreshold('bytesPct', DEFAULT_MEMORY_DEFRAG_BYTES_PCT, { pct: true }),
|
|
336
|
+
alwaysOnBytes: readDefragThreshold('alwaysOnBytes', DEFAULT_MEMORY_DEFRAG_ALWAYS_ON_BYTES),
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
280
341
|
/** Skill delivery mechanism (§5.6): 'copy' (default, isolated) | 'symlink' (write-through). */
|
|
281
342
|
export function skillMount() {
|
|
282
343
|
const v = readSettings().skillMount;
|
|
@@ -544,6 +605,8 @@ export const SETTINGS_POST_KEYS = Object.freeze([
|
|
|
544
605
|
'askMaxTurns', 'askMaxBudgetUsd',
|
|
545
606
|
'debugSpawnEnabled',
|
|
546
607
|
'titleModel', 'hideBuiltinModels',
|
|
608
|
+
'theme',
|
|
609
|
+
'autoWorkflowModel', // auto-workflow spec D14
|
|
547
610
|
]);
|
|
548
611
|
|
|
549
612
|
// ── Title-generation model + hidden built-ins (#422) ─────────────────────────
|
|
@@ -606,6 +669,74 @@ export async function setHideBuiltinModels(input) {
|
|
|
606
669
|
return { hideBuiltinModels: hideBuiltinModels() };
|
|
607
670
|
}
|
|
608
671
|
|
|
672
|
+
// ── Theme mode (2026-09-04 dark-mode design §6.1) ────────────────────────────
|
|
673
|
+
// One machine-wide preference: `system` follows the OS, `light`/`dark` force a
|
|
674
|
+
// scheme. The server writes it into the shell's <html data-theme> at serve time
|
|
675
|
+
// (ui/server.mjs sendIndex) and the client keeps it live; the value is read at
|
|
676
|
+
// use time like every other stored setting, so a save reaches the next request.
|
|
677
|
+
export const THEME_MODES = Object.freeze(['system', 'light', 'dark']);
|
|
678
|
+
export const DEFAULT_THEME = 'system';
|
|
679
|
+
const isThemeMode = (v) => THEME_MODES.includes(v);
|
|
680
|
+
|
|
681
|
+
/** STORED theme mode; an absent key is the default, an invalid value is the default (loudly). */
|
|
682
|
+
export function theme() {
|
|
683
|
+
const v = readSettings().theme;
|
|
684
|
+
if (v === undefined) return DEFAULT_THEME;
|
|
685
|
+
if (isThemeMode(v)) return v;
|
|
686
|
+
console.warn(`[worca] invalid theme ${JSON.stringify(v)} — using the default (${DEFAULT_THEME})`);
|
|
687
|
+
return DEFAULT_THEME;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** @throws {Error} unless `input` is system|light|dark, or empty/null (a clear). */
|
|
691
|
+
export function assertThemeInput(input) {
|
|
692
|
+
if (isClearInput(input)) return;
|
|
693
|
+
if (!isThemeMode(input)) throw new Error('theme must be system, light or dark');
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function setTheme(input) {
|
|
697
|
+
assertThemeInput(input);
|
|
698
|
+
const settings = readSettings();
|
|
699
|
+
if (isClearInput(input) || input === DEFAULT_THEME) delete settings.theme;
|
|
700
|
+
else settings.theme = input;
|
|
701
|
+
await persistSettings(settings);
|
|
702
|
+
return { theme: theme() };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ── Auto workflow classifier model (auto-workflow spec D14) ─────────────────
|
|
706
|
+
// '' = unset: the runtime resolves a default from the catalog
|
|
707
|
+
// (src/core/auto/model.mjs). Read at use time like every other stored setting.
|
|
708
|
+
|
|
709
|
+
/** The configured classifier model id, or '' when unset. */
|
|
710
|
+
export function autoWorkflowModel() {
|
|
711
|
+
const v = readSettings().autoWorkflowModel;
|
|
712
|
+
return typeof v === 'string' ? v.trim() : '';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Validate a POST value. With `models` (the effective catalog) the id must name an
|
|
717
|
+
* entry and comes back in the catalog's casing; without it the id is returned as
|
|
718
|
+
* given (CLI, tests). Empty/null/undefined means "clear".
|
|
719
|
+
* @returns {string|null} the canonical id to store, null to clear
|
|
720
|
+
* @throws {Error} on a non-string or an id the catalog does not carry
|
|
721
|
+
*/
|
|
722
|
+
export function assertAutoWorkflowModelInput(input, models = null) {
|
|
723
|
+
if (input === '' || input === null || input === undefined) return null;
|
|
724
|
+
if (typeof input !== 'string' || !input.trim()) throw new Error('autoWorkflowModel must be a catalog model id');
|
|
725
|
+
const id = input.trim();
|
|
726
|
+
if (!Array.isArray(models)) return id;
|
|
727
|
+
const hit = models.find((m) => m && typeof m.id === 'string' && m.id.toLowerCase() === id.toLowerCase());
|
|
728
|
+
if (!hit) throw new Error(`unknown model "${id}" — add it to the catalog first`);
|
|
729
|
+
return hit.id;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export async function setAutoWorkflowModel(input, { models = null } = {}) {
|
|
733
|
+
const id = assertAutoWorkflowModelInput(input, models);
|
|
734
|
+
const settings = readSettings();
|
|
735
|
+
if (id === null) delete settings.autoWorkflowModel; else settings.autoWorkflowModel = id;
|
|
736
|
+
await persistSettings(settings);
|
|
737
|
+
return { autoWorkflowModel: autoWorkflowModel() };
|
|
738
|
+
}
|
|
739
|
+
|
|
609
740
|
// ── Spawn-debug diagnostics toggle (the stored side of WORCA_DEBUG_SPAWN) ────
|
|
610
741
|
// Like every other stored setting (skillMount, the cost caps, the ask caps) this
|
|
611
742
|
// is READ AT USE TIME: claude-runner.mjs#debugSpawnEnabled calls
|
|
@@ -912,3 +1043,34 @@ export async function removeGlobalModel(id) {
|
|
|
912
1043
|
if (!settings.models.length) delete settings.models;
|
|
913
1044
|
await persistSettings(settings);
|
|
914
1045
|
}
|
|
1046
|
+
|
|
1047
|
+
// ── Getting started (onboarding) ─────────────────────────────────────────────
|
|
1048
|
+
// Two machine-wide flags, nothing more: which steps are DONE is derived from
|
|
1049
|
+
// product state by src/core/onboarding.mjs and never stored. `hidden` is the
|
|
1050
|
+
// checklist's Hide (Settings › General › Getting started shows it again);
|
|
1051
|
+
// `welcomeSeen` is the one-time welcome dialog. Both absent = both false.
|
|
1052
|
+
export function onboardingPrefs() {
|
|
1053
|
+
const o = readSettings().onboarding;
|
|
1054
|
+
const obj = o && typeof o === 'object' && !Array.isArray(o) ? o : {};
|
|
1055
|
+
return { hidden: obj.hidden === true, welcomeSeen: obj.welcomeSeen === true };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** @throws {Error} unless every present key is a boolean. Unknown keys are refused. */
|
|
1059
|
+
export function assertOnboardingPrefsInput(patch) {
|
|
1060
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('onboarding prefs must be an object');
|
|
1061
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
1062
|
+
if (k !== 'hidden' && k !== 'welcomeSeen') throw new Error(`unknown onboarding key ${JSON.stringify(k)}`);
|
|
1063
|
+
if (typeof v !== 'boolean') throw new Error(`onboarding.${k} must be true or false`);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Merge `patch` over the stored flags; a store with both flags false drops the key. */
|
|
1068
|
+
export async function setOnboardingPrefs(patch = {}) {
|
|
1069
|
+
assertOnboardingPrefsInput(patch);
|
|
1070
|
+
const settings = readSettings();
|
|
1071
|
+
const next = { ...onboardingPrefs(), ...patch };
|
|
1072
|
+
if (!next.hidden && !next.welcomeSeen) delete settings.onboarding;
|
|
1073
|
+
else settings.onboarding = next;
|
|
1074
|
+
await persistSettings(settings);
|
|
1075
|
+
return onboardingPrefs();
|
|
1076
|
+
}
|
package/src/core/sources.mjs
CHANGED
|
@@ -283,7 +283,10 @@ export async function retryWriteback(pipelineId) {
|
|
|
283
283
|
try {
|
|
284
284
|
const meta = readStoreMeta(row.project_key);
|
|
285
285
|
if (meta?.path && (await hasGh())) {
|
|
286
|
-
|
|
286
|
+
// The persisted pr_url (SELECT * above) resolves a cross-repo PR by URL; the
|
|
287
|
+
// branch search stays the fallback for rows that never recorded one.
|
|
288
|
+
const persisted = row.pr_url || null;
|
|
289
|
+
prUrl = (await findPrForBranch({ projectDir: meta.path, head: branch, prUrl: persisted }))?.url || null;
|
|
287
290
|
}
|
|
288
291
|
} catch { prUrl = null; }
|
|
289
292
|
}
|
package/src/core/store.mjs
CHANGED
|
@@ -32,6 +32,11 @@ export function canonicalProjectRoot(projectDir) {
|
|
|
32
32
|
try { return realpathSync(dir); } catch { return dir; }
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** The SHAPE projectKey() produces — `<slug>-<sha1[:8]>`, lowercase. One source of truth for the
|
|
36
|
+
* readers that validate a key before a lookup or a path join (ui/server.mjs' /api/memory routes),
|
|
37
|
+
* next to WORKSPACE_KEY_RE's role in workspaces.mjs. */
|
|
38
|
+
export const PROJECT_KEY_RE = /^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/;
|
|
39
|
+
|
|
35
40
|
/** Stable key for a project. Memoized by resolved input path. */
|
|
36
41
|
export function projectKey(projectDir) {
|
|
37
42
|
const cacheKey = resolve(projectDir);
|
|
@@ -41,6 +41,7 @@ import { portsOf, findPort } from '../shared/graph/ports.mjs';
|
|
|
41
41
|
/** Canonical leading-`---` YAML frontmatter matcher (was workflows.mjs FRONTMATTER_RE).
|
|
42
42
|
* Group 1 is the inner YAML; the whole match (m[0]) is the fence block incl. its
|
|
43
43
|
* trailing newline when present. */
|
|
44
|
+
// Readers use src/core/frontmatter.mjs; this stays a WRITER's fence matcher (m[0] bounds the stamp insertion — byte-pinned by the export tests).
|
|
44
45
|
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\n?/;
|
|
45
46
|
|
|
46
47
|
/**
|
|
@@ -379,6 +380,7 @@ function deriveNodeIo(graph, tnode, nodeKey, { cycle = 1, channelDefs } = {}) {
|
|
|
379
380
|
async function buildExportSet({ workflowId, destination, projectDir, slug, includeAgents = true, repoRoot }) {
|
|
380
381
|
const tpl = await readWorkflow(workflowId);
|
|
381
382
|
if (!tpl) throw err(`workflow not found: ${workflowId}`, 'NOT_FOUND');
|
|
383
|
+
if (tpl.auto) throw err('the Auto entry has no graph to export — it is decided per run', 'UNSUPPORTED');
|
|
382
384
|
|
|
383
385
|
const dest = resolveDest({ destination, projectDir });
|
|
384
386
|
const finalSlug = assertName(slug || slugify(tpl.name).slice(0, 48) || 'workflow', 'slug');
|
|
@@ -61,6 +61,7 @@ export function nodeDefaultsError(raw, models, where) {
|
|
|
61
61
|
export async function exportGraphJson(id) {
|
|
62
62
|
const tpl = await readWorkflow(id);
|
|
63
63
|
if (!tpl) throw err(`workflow not found: ${id}`, 'NOT_FOUND');
|
|
64
|
+
if (tpl.auto) throw err('the Auto entry has no graph to export — it is decided per run', 'UNSUPPORTED');
|
|
64
65
|
if (tpl.version !== 2) {
|
|
65
66
|
throw err(`workflow ${id} is a v1 template — open and save it in the composer first`, 'UNSUPPORTED');
|
|
66
67
|
}
|
package/src/core/workflows.mjs
CHANGED
|
@@ -25,9 +25,13 @@ import { DEFAULT_AGENTS_DIR, loadAgentRegistry } from './agent-registry.mjs'; //
|
|
|
25
25
|
import { readPluginsLock } from './plugins-lock.mjs'; // a DISABLED plugin's rows are hidden
|
|
26
26
|
import { validateGraph, formatIssue, AGENT_TUNABLES } from '../shared/graph/validate.mjs';
|
|
27
27
|
import { classifyLoops } from '../shared/graph/loops.mjs';
|
|
28
|
-
import {
|
|
29
|
-
|
|
28
|
+
import {
|
|
29
|
+
GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, AUTO_WORKFLOW_NAME, AUTO_WORKFLOW_STUB,
|
|
30
|
+
GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, MEMORY_DEFRAG_WORKFLOW_NAME, isReservedWorkflowId,
|
|
31
|
+
} from './graph/builtin-workflows.mjs';
|
|
32
|
+
export { GRAPH_DEFAULT_WORKFLOW, AUTO_WORKFLOW_ID, GRAPH_MEMORY_DEFRAG_WORKFLOW, MEMORY_DEFRAG_WORKFLOW_ID, isReservedWorkflowId };
|
|
30
33
|
import { registryPortsFn } from './graph/registry-ports.mjs';
|
|
34
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* Default feedback cycle count when run-config does not override it. Matches the
|
|
@@ -70,20 +74,7 @@ export async function loadAgentFile(agentsDir, agentFile, agentPath = null) {
|
|
|
70
74
|
} catch {
|
|
71
75
|
return { prompt: '', tools: [] };
|
|
72
76
|
}
|
|
73
|
-
return { prompt: text, tools:
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Extract a comma-separated `tools:` list from leading --- YAML frontmatter. */
|
|
77
|
-
function parseFrontmatterTools(text) {
|
|
78
|
-
const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
|
|
79
|
-
if (!m) return [];
|
|
80
|
-
const line = m[1].split(/\r?\n/).find((l) => /^tools\s*:/.test(l));
|
|
81
|
-
if (!line) return [];
|
|
82
|
-
return line
|
|
83
|
-
.replace(/^tools\s*:/, '')
|
|
84
|
-
.split(',')
|
|
85
|
-
.map((s) => s.trim())
|
|
86
|
-
.filter(Boolean);
|
|
77
|
+
return { prompt: text, tools: parseFrontmatter(text)?.tools ?? [] };
|
|
87
78
|
}
|
|
88
79
|
|
|
89
80
|
/**
|
|
@@ -310,16 +301,18 @@ export async function writeGraphWorkflow(tpl, opts = {}) {
|
|
|
310
301
|
// The ONE reserved id is the built-in default's; a save may never claim it,
|
|
311
302
|
// so it falls back to the slug.
|
|
312
303
|
const asked = tpl && typeof tpl.id === 'string' ? tpl.id.trim() : '';
|
|
313
|
-
const
|
|
304
|
+
const reserved = (id) => isReservedWorkflowId(id);
|
|
305
|
+
const minted = !(asked && isSafeWorkflowId(asked) && !reserved(asked));
|
|
314
306
|
const id = minted ? `wf_${slugify(name)}` : asked;
|
|
315
307
|
// C-3: the fallback re-mints the reserved id for ANY name slugging to
|
|
316
308
|
// "default" ('Default', ' dEfAuLt ', 'default!!', 'Défault'…). That row is
|
|
317
309
|
// filtered out of listWorkflows(), short-circuited past by readWorkflow() and
|
|
318
310
|
// refused by DELETE — the user's pipeline would vanish behind a 201. Refuse
|
|
319
311
|
// the WRITE instead; only the name is wrong, so the caller can rename.
|
|
320
|
-
if (id
|
|
312
|
+
if (reserved(id)) {
|
|
313
|
+
const which = id === AUTO_WORKFLOW_ID ? AUTO_WORKFLOW_NAME : id === MEMORY_DEFRAG_WORKFLOW_ID ? MEMORY_DEFRAG_WORKFLOW_NAME : GRAPH_DEFAULT_WORKFLOW.name;
|
|
321
314
|
throw Object.assign(
|
|
322
|
-
new Error(`the name "${
|
|
315
|
+
new Error(`the name "${which}" is reserved — choose another name`),
|
|
323
316
|
{ code: 'RESERVED_NAME' });
|
|
324
317
|
}
|
|
325
318
|
const domain = normDomain(tpl && tpl.domain);
|
|
@@ -370,6 +363,8 @@ export async function writeGraphWorkflow(tpl, opts = {}) {
|
|
|
370
363
|
export async function readWorkflow(id, opts = {}) {
|
|
371
364
|
// `wf_default` IS the graph: the v1 default died with the v1 engine.
|
|
372
365
|
if (id === GRAPH_DEFAULT_WORKFLOW.id) return GRAPH_DEFAULT_WORKFLOW;
|
|
366
|
+
if (id === AUTO_WORKFLOW_ID) return AUTO_WORKFLOW_STUB;
|
|
367
|
+
if (id === MEMORY_DEFRAG_WORKFLOW_ID) return GRAPH_MEMORY_DEFRAG_WORKFLOW;
|
|
373
368
|
return readRaw(id, opts);
|
|
374
369
|
}
|
|
375
370
|
|
|
@@ -400,7 +395,7 @@ export async function listWorkflows({ includeArchived = false, includeDisabled =
|
|
|
400
395
|
const rows = prepare(`SELECT ${ROW_COLS} FROM workflows ${where} ORDER BY created_at DESC, id`).all();
|
|
401
396
|
const lock = includeDisabled ? null : readPluginsLock();
|
|
402
397
|
return rows
|
|
403
|
-
.filter((r) => r.id
|
|
398
|
+
.filter((r) => !isReservedWorkflowId(r.id))
|
|
404
399
|
.filter((r) => includeDisabled || !pluginDisabled(r.origin, lock))
|
|
405
400
|
.map(rowToTpl);
|
|
406
401
|
}
|
|
@@ -437,6 +432,8 @@ function assertValidGraph(tpl, registry) {
|
|
|
437
432
|
*/
|
|
438
433
|
export async function assertRunnableWorkflow(id, { registry, checkGraph = true } = {}) {
|
|
439
434
|
const wanted = typeof id === 'string' && id.trim() ? id.trim() : GRAPH_DEFAULT_WORKFLOW.id;
|
|
435
|
+
// The Auto entry has no graph to check: the run decides one (spec §5.1).
|
|
436
|
+
if (wanted === AUTO_WORKFLOW_ID) return AUTO_WORKFLOW_STUB;
|
|
440
437
|
const live = await readWorkflow(wanted);
|
|
441
438
|
if (live) {
|
|
442
439
|
// A disabled plugin's template is neither runnable nor openable: its agents
|
|
@@ -479,6 +476,9 @@ export async function setWorkflowNodeDefaults(id, map) {
|
|
|
479
476
|
if (id === GRAPH_DEFAULT_WORKFLOW.id) {
|
|
480
477
|
throw new Error('the built-in Default workflow cannot store defaults — save a copy in Composer first');
|
|
481
478
|
}
|
|
479
|
+
if (id === MEMORY_DEFRAG_WORKFLOW_ID) {
|
|
480
|
+
throw new Error('the built-in Memory defragment workflow cannot store defaults — save a copy in Composer first');
|
|
481
|
+
}
|
|
482
482
|
const tpl = readRaw(id);
|
|
483
483
|
if (!tpl) throw new Error(`workflow not found: ${id}`);
|
|
484
484
|
const patch = map && typeof map === 'object' ? map : {};
|
|
@@ -523,6 +523,8 @@ export async function setWorkflowNodeDefaults(id, map) {
|
|
|
523
523
|
*/
|
|
524
524
|
export async function deleteWorkflow(id) {
|
|
525
525
|
if (id === GRAPH_DEFAULT_WORKFLOW.id) return false; // built-in default is undeletable
|
|
526
|
+
if (id === AUTO_WORKFLOW_ID) return false; // never a row
|
|
527
|
+
if (id === MEMORY_DEFRAG_WORKFLOW_ID) return false; // a constant, never a row
|
|
526
528
|
if (!isSafeWorkflowId(id)) return false; // SECURITY: reject unsafe ids
|
|
527
529
|
getDb();
|
|
528
530
|
let changed = 0;
|
|
@@ -548,7 +550,7 @@ export async function deleteWorkflow(id) {
|
|
|
548
550
|
* @param {string} workflowId
|
|
549
551
|
* @param {Record<string,object>} registry loadAgentRegistry() output
|
|
550
552
|
* @param {string} [agentsDir] override for tests; defaults to ../../agents
|
|
551
|
-
* @param {{ isWorkspace?: boolean }} [opts] workspace-mode resolve options
|
|
553
|
+
* @param {{ isWorkspace?: boolean, overlay?: {nodes?: object, wires?: object}, ignoreProjectOverrides?: boolean }} [opts] workspace-mode resolve options
|
|
552
554
|
* @returns {Promise<object>} ExecutablePlan
|
|
553
555
|
* @throws {Error} when the workflow id is unknown, or a node resolves the off-pipeline scanner
|
|
554
556
|
*/
|
|
@@ -591,6 +593,9 @@ export function workspaceVariants(registry) {
|
|
|
591
593
|
* @throws {Error} unknown workflow, a v1 row, an unknown/un-ported/unplaceable agent
|
|
592
594
|
*/
|
|
593
595
|
export async function resolveGraph(projectDir, workflowId, registry, agentsDir = DEFAULT_AGENTS_DIR, opts = {}) {
|
|
596
|
+
if (workflowId === AUTO_WORKFLOW_ID) {
|
|
597
|
+
throw new Error('the Auto workflow is decided per run — resolveGraph needs the adopted workflow id');
|
|
598
|
+
}
|
|
594
599
|
const stored = await readWorkflow(workflowId);
|
|
595
600
|
if (!stored) throw new Error(`unknown workflowId "${workflowId}"`);
|
|
596
601
|
if (stored.version !== 2) throw new Error('template is not a graph — runs on the v1 engine');
|
|
@@ -600,12 +605,27 @@ export async function resolveGraph(projectDir, workflowId, registry, agentsDir =
|
|
|
600
605
|
const reg = registry && typeof registry === 'object' ? registry : {};
|
|
601
606
|
const isWorkspace = !!opts.isWorkspace;
|
|
602
607
|
const variants = isWorkspace ? workspaceVariants(reg) : {};
|
|
603
|
-
|
|
608
|
+
// Per-project overlays. An Auto run owns its tuning (spec D7/D9): with
|
|
609
|
+
// `ignoreProjectOverrides` every per-project layer is skipped and `overlay`
|
|
610
|
+
// (the proposal the user accepted) is the ONLY overlay. Otherwise `overlay`
|
|
611
|
+
// merges PER NODE / PER WIRE over the project layer.
|
|
612
|
+
const ignore = !!opts.ignoreProjectOverrides;
|
|
613
|
+
const runCfg = ignore ? { nodes: {}, wires: {} } : await resolveRunConfig(projectDir, workflowId);
|
|
614
|
+
const over = opts.overlay && typeof opts.overlay === 'object' ? opts.overlay : {};
|
|
615
|
+
const mergeMaps = (base, extra) => {
|
|
616
|
+
const out = { ...base };
|
|
617
|
+
for (const [id, sel] of Object.entries(extra && typeof extra === 'object' ? extra : {})) {
|
|
618
|
+
if (sel && typeof sel === 'object') out[id] = { ...(out[id] || {}), ...sel };
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
};
|
|
622
|
+
const nodeCfg = mergeMaps(runCfg.nodes, over.nodes);
|
|
623
|
+
const wireCfg = mergeMaps(runCfg.wires, over.wires);
|
|
604
624
|
// The legacy per-role layer is the Default workflow's storage only (saved rows
|
|
605
625
|
// use nodeCfg); it is addressed by agent KEY, never by node id.
|
|
606
626
|
// A defaults-only global export passes projectDir=null and must not touch the config
|
|
607
627
|
// store (projectKey(null) throws); its legacy per-role layer is empty by definition.
|
|
608
|
-
const stepsCfg = (workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
|
|
628
|
+
const stepsCfg = (!ignore && workflowId === GRAPH_DEFAULT_WORKFLOW.id && projectDir) ? (await readConfig(projectDir)).steps : {};
|
|
609
629
|
const firstDefined = (...vals) => vals.find((v) => v !== undefined);
|
|
610
630
|
|
|
611
631
|
const nodes = {};
|
package/src/core/workspaces.mjs
CHANGED
|
@@ -32,6 +32,27 @@ import { getDb, prepare, tx } from './db.mjs';
|
|
|
32
32
|
/** Object-shaped error carrying a machine code (mirrors pipeline-delete.mjs). */
|
|
33
33
|
function err(message, code) { return Object.assign(new Error(message), { code }); }
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Validate a metricsProject candidate against a workspace's member paths.
|
|
37
|
+
* null/'' means "no home" and returns null. Anything else must resolve (by exact
|
|
38
|
+
* match or by canonical git root) to one of `paths`; the member's OWN stored path
|
|
39
|
+
* is returned (workspace_projects convention), not the normalized candidate.
|
|
40
|
+
* @param {string[]} paths
|
|
41
|
+
* @param {string|null|undefined} candidate
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
* @throws err(code: BAD_REQUEST)
|
|
44
|
+
*/
|
|
45
|
+
function memberPathFor(paths, candidate) {
|
|
46
|
+
if (candidate == null || candidate === '') return null;
|
|
47
|
+
if (typeof candidate !== 'string') throw err('metricsProject must be a project path or null', 'BAD_REQUEST');
|
|
48
|
+
const want = normalizeProjectPath(candidate);
|
|
49
|
+
// normalizeProjectPath(' ') -> null; canonicalProjectRoot(null) would throw ERR_INVALID_ARG_TYPE -> 500.
|
|
50
|
+
if (!want) throw err('metricsProject must be a project path or null', 'BAD_REQUEST');
|
|
51
|
+
const hit = paths.find((p) => p === want || canonicalProjectRoot(p) === canonicalProjectRoot(want));
|
|
52
|
+
if (!hit) throw err('metricsProject must be one of the workspace projects', 'BAD_REQUEST');
|
|
53
|
+
return hit;
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
/**
|
|
36
57
|
* The workspace-key shape: "wks-<slug>-<sha1[:8]>". The server imports this as
|
|
37
58
|
* its single source of truth (M2 route validation), so core + route agree on one
|
|
@@ -108,6 +129,7 @@ function annotate(entry) {
|
|
|
108
129
|
projectPaths: pairs.map((x) => x.path),
|
|
109
130
|
projectKeys: pairs.map((x) => x.key),
|
|
110
131
|
exists: pairs.map((x) => isDir(x.path)),
|
|
132
|
+
metricsProject: entry.metricsProject ?? null,
|
|
111
133
|
createdAt: entry.createdAt,
|
|
112
134
|
updatedAt: entry.updatedAt,
|
|
113
135
|
};
|
|
@@ -127,6 +149,7 @@ function rowToEntry(r) {
|
|
|
127
149
|
name: r.name,
|
|
128
150
|
description: typeof r.description === 'string' ? r.description : '',
|
|
129
151
|
projectPaths: memberPaths(r.id),
|
|
152
|
+
metricsProject: r.metrics_project ?? null,
|
|
130
153
|
createdAt: typeof r.created_at === 'string' ? r.created_at : '',
|
|
131
154
|
updatedAt: typeof r.updated_at === 'string' ? r.updated_at : '',
|
|
132
155
|
};
|
|
@@ -136,7 +159,7 @@ function rowToEntry(r) {
|
|
|
136
159
|
function readEntry(id) {
|
|
137
160
|
getDb();
|
|
138
161
|
const r = prepare(
|
|
139
|
-
'SELECT id, name, description, created_at, updated_at FROM workspaces WHERE id = ?'
|
|
162
|
+
'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces WHERE id = ?'
|
|
140
163
|
).get(id);
|
|
141
164
|
return r ? rowToEntry(r) : null;
|
|
142
165
|
}
|
|
@@ -148,7 +171,7 @@ function readEntry(id) {
|
|
|
148
171
|
export async function listWorkspaces() {
|
|
149
172
|
getDb();
|
|
150
173
|
const rows = prepare(
|
|
151
|
-
'SELECT id, name, description, created_at, updated_at FROM workspaces ORDER BY created_at, name'
|
|
174
|
+
'SELECT id, name, description, metrics_project, created_at, updated_at FROM workspaces ORDER BY created_at, name'
|
|
152
175
|
).all();
|
|
153
176
|
return rows.map(rowToEntry).map(annotate);
|
|
154
177
|
}
|
|
@@ -218,6 +241,7 @@ export async function createWorkspace(input = {}) {
|
|
|
218
241
|
if (!isGitRepo(p)) throw err(`member path is not a git repository: ${p}`, 'BAD_REQUEST');
|
|
219
242
|
}
|
|
220
243
|
|
|
244
|
+
const metricsProject = memberPathFor(members, input.metricsProject ?? null);
|
|
221
245
|
const id = workspaceKey({ name, projectPaths: members });
|
|
222
246
|
const hash = rootsHash(members);
|
|
223
247
|
const now = new Date().toISOString();
|
|
@@ -235,8 +259,8 @@ export async function createWorkspace(input = {}) {
|
|
|
235
259
|
}
|
|
236
260
|
}
|
|
237
261
|
prepare(
|
|
238
|
-
'INSERT INTO workspaces (id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
|
239
|
-
).run(id, name, description, now, now);
|
|
262
|
+
'INSERT INTO workspaces (id, name, description, metrics_project, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
263
|
+
).run(id, name, description, metricsProject, now, now);
|
|
240
264
|
const insMember = prepare(
|
|
241
265
|
'INSERT INTO workspace_projects (workspace_id, project_key, ordinal) VALUES (?, ?, ?)'
|
|
242
266
|
);
|
|
@@ -245,7 +269,9 @@ export async function createWorkspace(input = {}) {
|
|
|
245
269
|
});
|
|
246
270
|
|
|
247
271
|
// Return the annotated entry (derived fields recomputed from the persisted paths).
|
|
248
|
-
return annotate({
|
|
272
|
+
return annotate({
|
|
273
|
+
id, name, description, projectPaths: members, metricsProject, createdAt: now, updatedAt: now,
|
|
274
|
+
});
|
|
249
275
|
}
|
|
250
276
|
|
|
251
277
|
/**
|
|
@@ -270,6 +296,9 @@ export async function updateWorkspace(id, patch = {}) {
|
|
|
270
296
|
if (patch && typeof patch.description === 'string') {
|
|
271
297
|
description = patch.description; // cap-on-freeze, not cap-on-store: persisted whole
|
|
272
298
|
}
|
|
299
|
+
const metricsProject = Object.prototype.hasOwnProperty.call(patch || {}, 'metricsProject')
|
|
300
|
+
? memberPathFor(entry.projectPaths, patch.metricsProject)
|
|
301
|
+
: entry.metricsProject ?? null;
|
|
273
302
|
const now = new Date().toISOString();
|
|
274
303
|
|
|
275
304
|
tx(() => {
|
|
@@ -279,11 +308,11 @@ export async function updateWorkspace(id, patch = {}) {
|
|
|
279
308
|
).get(name, id);
|
|
280
309
|
if (clash) throw err(`a workspace named "${name}" already exists`, 'DUPLICATE_NAME');
|
|
281
310
|
prepare(
|
|
282
|
-
'UPDATE workspaces SET name = ?, description = ?, updated_at = ? WHERE id = ?'
|
|
283
|
-
).run(name, description, now, id);
|
|
311
|
+
'UPDATE workspaces SET name = ?, description = ?, metrics_project = ?, updated_at = ? WHERE id = ?'
|
|
312
|
+
).run(name, description, metricsProject, now, id);
|
|
284
313
|
});
|
|
285
314
|
|
|
286
|
-
return annotate({ ...entry, name, description, updatedAt: now });
|
|
315
|
+
return annotate({ ...entry, name, description, metricsProject, updatedAt: now });
|
|
287
316
|
}
|
|
288
317
|
|
|
289
318
|
/** Thin setter: edit only the description. */
|
|
@@ -16,6 +16,9 @@ const RESERVED_PORT_ID = 'await';
|
|
|
16
16
|
const DOMAIN_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
17
17
|
const COLORS = new Set(['green', 'peach', 'red', 'blue', 'violet', 'amber']);
|
|
18
18
|
const RUNNER_TYPES = new Set(['producer', 'verifier', 'clarifier']);
|
|
19
|
+
/** What an agent may write outside its output ports: 'code' (the worktree — implementer tool
|
|
20
|
+
* set) or 'memory' (worca's memory mount only — Read/Write/Edit/Glob/Grep, no Bash). */
|
|
21
|
+
const SIDE_EFFECTS = new Set(['code', 'memory']);
|
|
19
22
|
const INPUT_AS = new Set(['file', 'answers', 'fix-review', 'worktree']);
|
|
20
23
|
/** The port type each non-default `as` renderer requires. `file` is the default
|
|
21
24
|
* and is materialized on NON-VOID inputs only, which makes `worktree` the only
|
|
@@ -112,7 +115,7 @@ export function normalizeAgentMeta(raw, opts = {}) {
|
|
|
112
115
|
// Scope coercion mirrors color: anything but the explicit 'workspace-only'
|
|
113
116
|
// marker is a normal project agent, so a typo fails safe to a VISIBLE agent.
|
|
114
117
|
const scope = raw.scope === 'workspace-only' ? 'workspace-only' : 'project';
|
|
115
|
-
if (raw.sideEffect !== undefined && raw.sideEffect
|
|
118
|
+
if (raw.sideEffect !== undefined && !SIDE_EFFECTS.has(raw.sideEffect)) err(`sideEffect must be one of ${[...SIDE_EFFECTS].join(', ')}`);
|
|
116
119
|
if (raw.workspaceStrategy !== undefined && !WORKSPACE_STRATEGIES.has(raw.workspaceStrategy)) {
|
|
117
120
|
err(`workspaceStrategy must be one of ${[...WORKSPACE_STRATEGIES].join(', ')}`);
|
|
118
121
|
}
|
|
@@ -164,7 +167,7 @@ export function normalizeAgentMeta(raw, opts = {}) {
|
|
|
164
167
|
// an absent field means "the default", so a v2 entry stays diffable against
|
|
165
168
|
// the sidecar that produced it.
|
|
166
169
|
if (verdict) meta.verdict = verdict;
|
|
167
|
-
if (raw.sideEffect
|
|
170
|
+
if (SIDE_EFFECTS.has(raw.sideEffect)) meta.sideEffect = raw.sideEffect;
|
|
168
171
|
if (mockRole) meta.mockRole = mockRole;
|
|
169
172
|
if (raw.wantsRequest) meta.wantsRequest = true;
|
|
170
173
|
if (raw.workspaceFanOut) meta.workspaceFanOut = true;
|