@worca/app 1.0.0 → 1.2.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 +30 -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 +386 -56
- 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 +199 -23
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +545 -0
- package/src/core/ask/follow.mjs +113 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +57 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +286 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +156 -0
- package/src/core/ask/store.mjs +438 -0
- package/src/core/ask/tool-deps.mjs +87 -0
- package/src/core/ask/tools.mjs +879 -0
- package/src/core/ask/turn.mjs +462 -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 +28 -7
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +541 -62
- package/src/core/config.mjs +310 -44
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +773 -53
- 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/failure-policy.mjs +201 -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 +1072 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +180 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +994 -4097
- 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 +80 -17
- 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 +3934 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +184 -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 +4240 -1682
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +317 -0
- package/ui/public/ask-panel.mjs +2129 -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 +311 -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 +1487 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1894 -104
- 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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// failure-policy.mjs — the ONE place that decides what a failure does to a run.
|
|
2
|
+
//
|
|
3
|
+
// Every site in the engine that can see a failure — the per-node retry loop, the
|
|
4
|
+
// flow-card dispatcher, the budget gate, run()/resume()'s setup and shell catch
|
|
5
|
+
// blocks — asks `resolveFailure()` for a VERDICT and then enacts it with its own
|
|
6
|
+
// mechanics (throwing the pause sentinel through the scheduler, picking a resume
|
|
7
|
+
// point, stamping a setup replay). No site makes the decision itself, so shifting
|
|
8
|
+
// a case from "terminal error" to "pause" (or back) is a one-cell edit in
|
|
9
|
+
// FAILURE_POLICY below plus its row in test/failure-policy.test.mjs.
|
|
10
|
+
//
|
|
11
|
+
// Inputs (all plain values — this module is pure and imports nothing):
|
|
12
|
+
// site where the failure surfaced (SITES)
|
|
13
|
+
// cls classifyError()'s class, or null for an unclassified error; the
|
|
14
|
+
// budget gate passes its cost code
|
|
15
|
+
// auto --yes / headless (true) or interactive (false)
|
|
16
|
+
// attempt 1-based attempt number, for bounded retries
|
|
17
|
+
// answer the recovery prompt's answer once the user gave one ('retry'|'giveup')
|
|
18
|
+
//
|
|
19
|
+
// Verdicts:
|
|
20
|
+
// { outcome: 'retry' } try again (the site backs off)
|
|
21
|
+
// { outcome: 'prompt', options: [...] } ask the user (interactive only)
|
|
22
|
+
// { outcome: 'pause', reason: ReasonCode } park the run, resumable
|
|
23
|
+
// { outcome: 'error' } end the run as a terminal error
|
|
24
|
+
//
|
|
25
|
+
// Control-flow signals — a PauseError, an AbortError, a pause already requested,
|
|
26
|
+
// the user's Stop — are NOT failures and never reach this table; every site guards
|
|
27
|
+
// for them first. Stop is user-only and always ends the run as 'stopped'.
|
|
28
|
+
//
|
|
29
|
+
// A verdict is issued ONCE. When a site enacts 'error' it marks the error
|
|
30
|
+
// terminal (markTerminal) so every enclosing catch — the flow dispatcher, the
|
|
31
|
+
// shell — enacts that same verdict instead of re-deciding at its own site.
|
|
32
|
+
|
|
33
|
+
/** Where a failure can surface. */
|
|
34
|
+
export const SITES = Object.freeze(['node', 'flow', 'budget', 'setup', 'launch', 'shell', 'resume']);
|
|
35
|
+
|
|
36
|
+
/** Machine-readable pause reasons. The human text rides `pauseDetail`. */
|
|
37
|
+
export const REASON = Object.freeze({
|
|
38
|
+
USAGE_LIMIT: 'usage_limit', // a session/usage cap that clears after a multi-hour reset
|
|
39
|
+
RECOVERABLE: 'recoverable', // a classified (auth/quota/rate_limit/network) error the run could not outwait
|
|
40
|
+
ERROR: 'error', // a failure that would otherwise have ended the run
|
|
41
|
+
COST_PIPELINE: 'cost_pipeline', // the per-pipeline cost cap
|
|
42
|
+
COST_TOTAL: 'cost_total', // the total (weekly/monthly) cost cap
|
|
43
|
+
});
|
|
44
|
+
export const REASON_CODES = Object.freeze(Object.values(REASON));
|
|
45
|
+
|
|
46
|
+
/** Max auto-mode retries for a recoverable error before the row's `then` verdict. */
|
|
47
|
+
export const RECOVERY_MAX_AUTO_ATTEMPTS = (() => {
|
|
48
|
+
const n = Number(process.env.WORCA_RECOVERY_MAX_ATTEMPTS);
|
|
49
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3;
|
|
50
|
+
})();
|
|
51
|
+
|
|
52
|
+
// ── verdict constructors ──────────────────────────────────────────────────────
|
|
53
|
+
const pause = (reason) => Object.freeze({ outcome: 'pause', reason });
|
|
54
|
+
const error = () => Object.freeze({ outcome: 'error' });
|
|
55
|
+
/** Bounded retries (auto mode): `max` retries, then the `then` verdict. */
|
|
56
|
+
const retry = (max, then) => Object.freeze({ outcome: 'retry', max, then });
|
|
57
|
+
/** Interactive prompt: Retry re-runs in place; the give-up option enacts `giveUp`. */
|
|
58
|
+
const prompt = (giveUp) => Object.freeze({ outcome: 'prompt', giveUp });
|
|
59
|
+
|
|
60
|
+
/** One matrix cell: the verdict per run mode. */
|
|
61
|
+
const cell = (auto, interactive) => Object.freeze({ auto, interactive });
|
|
62
|
+
/** The same verdict in both modes. */
|
|
63
|
+
const both = (v) => cell(v, v);
|
|
64
|
+
|
|
65
|
+
// ── THE MATRIX ────────────────────────────────────────────────────────────────
|
|
66
|
+
// Rows are keyed by site, then by error class; '*' is the row for any class the
|
|
67
|
+
// site has no specific row for. Edit a cell to shift a case.
|
|
68
|
+
//
|
|
69
|
+
// A note on the two contested rows (PR #415 vs. the policy PR #412 shipped):
|
|
70
|
+
// node/'*' an UNCLASSIFIED error (often a genuine bug) pauses instead of ending
|
|
71
|
+
// the run; resume retries the node in place. Flip to error() to
|
|
72
|
+
// restore "a bug ends the run".
|
|
73
|
+
// node/auth… an interactive recovery prompt's give-up option PAUSES (no Abort
|
|
74
|
+
// verdict). Flip prompt(error()) to offer Abort as a terminal error.
|
|
75
|
+
export const FAILURE_POLICY = Object.freeze({
|
|
76
|
+
node: Object.freeze({
|
|
77
|
+
usage_limit: both(pause(REASON.USAGE_LIMIT)),
|
|
78
|
+
// auth/quota are user-fixable but never time-fixable — a 1s/2s/4s backoff
|
|
79
|
+
// cannot re-login or top up a balance — so auto mode pauses on the first hit.
|
|
80
|
+
// A self-parked auto run pauses as RECOVERABLE (the class is kept: "resume when
|
|
81
|
+
// it clears"); a user who gives up on the prompt pauses as ERROR (a verdict).
|
|
82
|
+
auth: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
|
|
83
|
+
quota: cell(pause(REASON.RECOVERABLE), prompt(pause(REASON.ERROR))),
|
|
84
|
+
rate_limit: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
|
|
85
|
+
network: cell(retry(RECOVERY_MAX_AUTO_ATTEMPTS, pause(REASON.RECOVERABLE)), prompt(pause(REASON.ERROR))),
|
|
86
|
+
'*': both(pause(REASON.ERROR)),
|
|
87
|
+
}),
|
|
88
|
+
// A flow card, the questions loop, _afterExecution, a composite shell mode, an
|
|
89
|
+
// allocation failure — engine-side throws around an execution.
|
|
90
|
+
flow: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
|
|
91
|
+
// The step-boundary budget gate (not an error: a cap was reached).
|
|
92
|
+
budget: Object.freeze({
|
|
93
|
+
cost_pipeline: both(pause(REASON.COST_PIPELINE)),
|
|
94
|
+
cost_total: both(pause(REASON.COST_TOTAL)),
|
|
95
|
+
}),
|
|
96
|
+
// run()'s setup — checkout, graph build, skills gate — failed with the pipeline
|
|
97
|
+
// row already created. A pause here stamps `setupIncomplete`; resume replays it.
|
|
98
|
+
setup: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
|
|
99
|
+
// Before the pipeline row exists (topology, preflight, tool detection) there is
|
|
100
|
+
// nothing to resume into: a launch error is the only enactable verdict.
|
|
101
|
+
launch: Object.freeze({ '*': both(error()) }),
|
|
102
|
+
// Anything that escaped the engine after setup (a scheduler throw, a persist
|
|
103
|
+
// failure, a bookkeeping bug).
|
|
104
|
+
shell: Object.freeze({ '*': both(pause(REASON.ERROR)) }),
|
|
105
|
+
// resume() could not REHYDRATE the paused run — the checkout is gone, run.json
|
|
106
|
+
// is corrupt, a guardrail set or agent prompt no longer loads. The point on disk
|
|
107
|
+
// is already the best the run can offer: parking it again would re-persist the
|
|
108
|
+
// same point (and re-notify the task source) on every attempt, forever. A
|
|
109
|
+
// structurally unrecoverable resume ends the run.
|
|
110
|
+
resume: Object.freeze({ '*': both(error()) }),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** The recovery prompt's options, derived from the row: what Retry does is fixed;
|
|
114
|
+
* the give-up option's id is the wire `decision` value and names its verdict. */
|
|
115
|
+
export function promptOptions(giveUp) {
|
|
116
|
+
const giveUpId = giveUp.outcome === 'pause' ? 'pause' : 'abort';
|
|
117
|
+
return Object.freeze([
|
|
118
|
+
Object.freeze({ id: 'retry', label: 'Retry' }),
|
|
119
|
+
giveUpId === 'pause'
|
|
120
|
+
? Object.freeze({ id: 'pause', label: 'Pause the run (nothing is discarded — resume later)' })
|
|
121
|
+
: Object.freeze({ id: 'abort', label: 'Abort the run' }),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The give-up option of a prompt's options (the CLI/UI/chat render its label and
|
|
126
|
+
* send its id back as the decision). Falls back to the pause option for a prompt
|
|
127
|
+
* payload that predates options. */
|
|
128
|
+
export function giveUpOption(options) {
|
|
129
|
+
const found = Array.isArray(options) ? options.find((o) => o && o.id !== 'retry') : null;
|
|
130
|
+
return found || promptOptions({ outcome: 'pause' })[1];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A recovery answer's `decision` wire value → the policy answer. 'abort' is the
|
|
134
|
+
* pre-policy wire value (older UI tabs, chat /abort) and means give up too. */
|
|
135
|
+
export function answerFromDecision(decision) {
|
|
136
|
+
return decision === 'retry' ? 'retry' : 'giveup';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Decide what a failure does. Pure.
|
|
141
|
+
* @param {{site:string, cls?:string|null, auto?:boolean, attempt?:number, answer?:'retry'|'giveup'}} f
|
|
142
|
+
* @returns {{outcome:'retry'|'prompt'|'pause'|'error', reason?:string, options?:readonly object[]}}
|
|
143
|
+
*/
|
|
144
|
+
export function resolveFailure({ site, cls = null, auto = false, attempt = 1, answer } = {}) {
|
|
145
|
+
const rows = FAILURE_POLICY[site];
|
|
146
|
+
if (!rows) throw new Error(`failure-policy: unknown site '${site}'`);
|
|
147
|
+
const row = (cls != null && rows[cls]) || rows['*'];
|
|
148
|
+
if (!row) throw new Error(`failure-policy: no row for ${site}/${cls}`);
|
|
149
|
+
let v = auto ? row.auto : row.interactive;
|
|
150
|
+
if (v.outcome === 'retry') {
|
|
151
|
+
v = attempt > v.max ? v.then : { outcome: 'retry' };
|
|
152
|
+
}
|
|
153
|
+
if (v.outcome === 'prompt') {
|
|
154
|
+
if (answer === undefined) return { outcome: 'prompt', options: promptOptions(v.giveUp) };
|
|
155
|
+
v = answer === 'retry' ? { outcome: 'retry' } : v.giveUp;
|
|
156
|
+
}
|
|
157
|
+
return v.outcome === 'pause' ? { outcome: 'pause', reason: v.reason } : { outcome: v.outcome };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── terminal-verdict stamp ────────────────────────────────────────────────────
|
|
161
|
+
const TERMINAL = Symbol.for('worca.failure.terminal');
|
|
162
|
+
/** Stamp an error whose verdict is 'error' so enclosing sites enact, not re-decide. */
|
|
163
|
+
export function markTerminal(err) {
|
|
164
|
+
if (err && typeof err === 'object') { try { err[TERMINAL] = true; } catch { /* frozen */ } }
|
|
165
|
+
return err;
|
|
166
|
+
}
|
|
167
|
+
export function isTerminal(err) {
|
|
168
|
+
return !!(err && typeof err === 'object' && err[TERMINAL] === true);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── consequences of a pause, keyed on its reason ──────────────────────────────
|
|
172
|
+
// What each surface does with a parked run is a function of the reason code, not
|
|
173
|
+
// of ad hoc string checks. `null` is a manual pause (the user pressed Pause).
|
|
174
|
+
const CONSEQUENCES = Object.freeze({
|
|
175
|
+
manual: { reportsToSource: false, stagesResults: false, severity: 'info', notifyPref: 'paused', exitInteractive: 0, label: null },
|
|
176
|
+
[REASON.USAGE_LIMIT]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'session/usage limit reached' },
|
|
177
|
+
[REASON.RECOVERABLE]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'recoverable error — resume to retry' },
|
|
178
|
+
[REASON.COST_PIPELINE]:{ reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'pipeline cost limit reached' },
|
|
179
|
+
[REASON.COST_TOTAL]: { reportsToSource: true, stagesResults: false, severity: 'warning', notifyPref: 'paused', exitInteractive: 0, label: 'total cost limit reached' },
|
|
180
|
+
[REASON.ERROR]: { reportsToSource: true, stagesResults: true, severity: 'error', notifyPref: 'error', exitInteractive: 1, label: 'a step failed' },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/** The consequences row for a pause reason (unknown/legacy free-text reasons read
|
|
184
|
+
* as a forced pause with a warning severity — every reasoned pause is forced). */
|
|
185
|
+
export function pauseConsequences(reason) {
|
|
186
|
+
if (reason == null || reason === '') return CONSEQUENCES.manual;
|
|
187
|
+
return CONSEQUENCES[reason] || CONSEQUENCES[REASON.USAGE_LIMIT];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** CLI exit code for a paused run. Under --yes every pause is the run parking
|
|
191
|
+
* ITSELF with nobody left to resume: 3, so a wrapper can tell a resumable pause
|
|
192
|
+
* from a hard error (1) and a usage error (2). Interactive: 0 when the user asked
|
|
193
|
+
* for it or a cap/limit holds the run; 1 when an error forced it. */
|
|
194
|
+
export function pauseExitCode(reason, auto) {
|
|
195
|
+
return auto ? 3 : pauseConsequences(reason).exitInteractive;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Human label for a reason code, or null for a manual pause / unknown code. */
|
|
199
|
+
export function describePauseReason(reason) {
|
|
200
|
+
return pauseConsequences(reason).label;
|
|
201
|
+
}
|
package/src/core/git-info.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
|
|
10
10
|
/** Default runner: spawn `cmd args` in `cwd`, resolve { ok, stdout, stderr, code }. */
|
|
11
|
-
function defaultRun(cmd, args, { cwd } = {}) {
|
|
11
|
+
function defaultRun(cmd, args, { cwd, timeout = 0 } = {}) {
|
|
12
12
|
return new Promise((resolve) => {
|
|
13
13
|
let child;
|
|
14
14
|
try {
|
|
@@ -18,13 +18,24 @@ function defaultRun(cmd, args, { cwd } = {}) {
|
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
let stdout = '', stderr = '';
|
|
21
|
+
let settled = false;
|
|
22
|
+
const done = (val) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve(val); };
|
|
23
|
+
// `-M -l0` (unlimited rename detection) can run for minutes on a huge diff, and
|
|
24
|
+
// the diff helpers sit on the Stop/error terminal path (review of PR #376) —
|
|
25
|
+
// a bound keeps a stop from hanging on git. 0 = no bound (the default).
|
|
26
|
+
const timer = timeout > 0
|
|
27
|
+
? setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } done({ ok: false, stdout, stderr: `${cmd} timed out after ${timeout} ms`, code: -1 }); }, timeout)
|
|
28
|
+
: null;
|
|
21
29
|
child.stdout?.on('data', (b) => (stdout += b.toString()));
|
|
22
30
|
child.stderr?.on('data', (b) => (stderr += b.toString()));
|
|
23
|
-
child.on('error', (err) =>
|
|
24
|
-
child.on('close', (code) =>
|
|
31
|
+
child.on('error', (err) => done({ ok: false, stdout, stderr: stderr || err.message, code: -1 }));
|
|
32
|
+
child.on('close', (code) => done({ ok: code === 0, stdout, stderr, code: code ?? -1 }));
|
|
25
33
|
});
|
|
26
34
|
}
|
|
27
35
|
|
|
36
|
+
/** Bound for the diff helpers below (persisted-diff generation on every terminal path). */
|
|
37
|
+
export const DIFF_TIMEOUT_MS = 120_000;
|
|
38
|
+
|
|
28
39
|
let _run = defaultRun;
|
|
29
40
|
let _ghCache = null;
|
|
30
41
|
|
|
@@ -49,12 +60,16 @@ export async function diffShortstat(projectDir, source, feature) {
|
|
|
49
60
|
* `pathspecs` (optional) are appended AFTER the bare '--' so callers can restrict
|
|
50
61
|
* or, more usefully, EXCLUDE paths (`:(exclude)<path>` — exclude-only pathspecs are
|
|
51
62
|
* valid git). Passing nothing yields the byte-identical argv of before (§8.8).
|
|
63
|
+
* `core.quotePath=false` matches diffPatch below, so results.json and the persisted
|
|
64
|
+
* patch name a non-ASCII file the same way instead of `"cl\303\251.pem"` vs `clé.pem`.
|
|
65
|
+
* `-l0` matches it for the same reason: `diff.renameLimit` would otherwise decide
|
|
66
|
+
* per command whether a rename is one `R` row or a `D` plus an `A`.
|
|
52
67
|
* @returns {Promise<Array<{status:string, path:string, from?:string}>>}
|
|
53
68
|
*/
|
|
54
69
|
export async function diffNameStatus(projectDir, base, head, pathspecs = []) {
|
|
55
70
|
if (!projectDir || !base) return [];
|
|
56
|
-
const args = ['diff', '--name-status', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
|
|
57
|
-
const r = await _run('git', args, { cwd: projectDir });
|
|
71
|
+
const args = ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-l0', base, ...(head ? [head] : []), '--', ...pathspecs];
|
|
72
|
+
const r = await _run('git', args, { cwd: projectDir, timeout: DIFF_TIMEOUT_MS });
|
|
58
73
|
if (!r.ok) return [];
|
|
59
74
|
const out = [];
|
|
60
75
|
for (const line of r.stdout.split('\n')) {
|
|
@@ -73,14 +88,15 @@ export async function diffNameStatus(projectDir, base, head, pathspecs = []) {
|
|
|
73
88
|
/**
|
|
74
89
|
* Parse `git diff --numstat -M` into a Map keyed by path. Binary files report
|
|
75
90
|
* `-`/`-` and are flagged `binary:true` with zero counts. `pathspecs` (optional)
|
|
76
|
-
* are appended AFTER the bare '--' — see diffNameStatus.
|
|
91
|
+
* are appended AFTER the bare '--' — see diffNameStatus, whose `core.quotePath=false`
|
|
92
|
+
* and `-l0` this shares so the Map keys match the name-status paths.
|
|
77
93
|
* @returns {Promise<Map<string,{added:number, removed:number, binary:boolean}>>}
|
|
78
94
|
*/
|
|
79
95
|
export async function diffNumstat(projectDir, base, head, pathspecs = []) {
|
|
80
96
|
const m = new Map();
|
|
81
97
|
if (!projectDir || !base) return m;
|
|
82
|
-
const args = ['diff', '--numstat', '-M', base, ...(head ? [head] : []), '--', ...pathspecs];
|
|
83
|
-
const r = await _run('git', args, { cwd: projectDir });
|
|
98
|
+
const args = ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-l0', base, ...(head ? [head] : []), '--', ...pathspecs];
|
|
99
|
+
const r = await _run('git', args, { cwd: projectDir, timeout: DIFF_TIMEOUT_MS });
|
|
84
100
|
if (!r.ok) return m;
|
|
85
101
|
for (const line of r.stdout.split('\n')) {
|
|
86
102
|
if (!line.trim()) continue;
|
|
@@ -95,12 +111,34 @@ export async function diffNumstat(projectDir, base, head, pathspecs = []) {
|
|
|
95
111
|
/**
|
|
96
112
|
* Full unified diff (`git diff -M base [head]`). Empty string on failure.
|
|
97
113
|
* `pathspecs` (optional) are appended AFTER the bare '--' — see diffNameStatus.
|
|
114
|
+
* `core.quotePath=false` keeps non-ASCII paths literal instead of C-quoted, so
|
|
115
|
+
* every `diff --git a/X b/X` parser downstream sees the real path. The prefixes
|
|
116
|
+
* themselves are a SETTING, not a constant — `diff.noprefix`,
|
|
117
|
+
* `diff.mnemonicPrefix` (`c/` … `w/`) and `diff.srcPrefix`/`diff.dstPrefix` come
|
|
118
|
+
* from the user's own ~/.gitconfig and apply to every worktree — and
|
|
119
|
+
* `diff.external`/`GIT_EXTERNAL_DIFF` replaces the patch wholesale, emitting no
|
|
120
|
+
* `diff --git` line at all. Pin all four so the header shape those parsers
|
|
121
|
+
* (ask/tools.mjs, ui/public/diff-view.mjs) rely on is ours, not the user's.
|
|
122
|
+
* `color.diff`/`color.ui = always` is the same class of setting — it wraps every
|
|
123
|
+
* header in SGR escapes, which no parser here strips — so `--no-color` is pinned too.
|
|
124
|
+
* `diff.submodule = diff` is the same class one level down: git spawns an INNER
|
|
125
|
+
* `git diff` inside the submodule and propagates none of these pins into it, so an
|
|
126
|
+
* external diff tool (config or GIT_EXTERNAL_DIFF) makes the inner patch header-less
|
|
127
|
+
* and it rides inside the section before it. `--submodule=short` keeps a submodule
|
|
128
|
+
* as one `Subproject commit` hunk under its own path — the name the two row parsers
|
|
129
|
+
* above already use.
|
|
130
|
+
* `diff.renameLimit` decides whether git PAIRS a rename+edit at all: below it a
|
|
131
|
+
* rename out of a credential file arrives as delete + add, and the add's `+` lines
|
|
132
|
+
* are the old file's content under the new, harmless name. `-l0` pins the unlimited
|
|
133
|
+
* detection the old-path filter in ask/tools.mjs depends on.
|
|
98
134
|
* @returns {Promise<string>}
|
|
99
135
|
*/
|
|
100
136
|
export async function diffPatch(projectDir, base, head, pathspecs = []) {
|
|
101
137
|
if (!projectDir || !base) return '';
|
|
102
|
-
const args = ['diff', '-M',
|
|
103
|
-
|
|
138
|
+
const args = ['-c', 'core.quotePath=false', 'diff', '-M', '-l0', '--no-color', '--no-ext-diff', '--submodule=short',
|
|
139
|
+
'--src-prefix=a/', '--dst-prefix=b/',
|
|
140
|
+
base, ...(head ? [head] : []), '--', ...pathspecs];
|
|
141
|
+
const r = await _run('git', args, { cwd: projectDir, timeout: DIFF_TIMEOUT_MS });
|
|
104
142
|
return r.ok ? r.stdout : '';
|
|
105
143
|
}
|
|
106
144
|
|
|
@@ -195,6 +233,7 @@ export async function findPrForBranch({ projectDir, head } = {}) {
|
|
|
195
233
|
|
|
196
234
|
// Test seam: swap the command runner + clear the gh memo. Mirrors server.mjs#_testing.
|
|
197
235
|
export const _testing = {
|
|
236
|
+
defaultRun,
|
|
198
237
|
setRunner(fn) { _run = typeof fn === 'function' ? fn : defaultRun; _ghCache = null; },
|
|
199
238
|
reset() { _run = defaultRun; _ghCache = null; },
|
|
200
239
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/core/graph/builtin-workflows.mjs
|
|
2
|
+
// The SHIPPING builtin workflow: wf_default as a version-2 template. This is the
|
|
3
|
+
// constant the seeder writes and the V17 migration reconciles against — not a
|
|
4
|
+
// test fixture. `fixtures.mjs` re-exports it as FIXTURE_DEFAULT so the engine
|
|
5
|
+
// tests and the shipping default can never drift apart.
|
|
6
|
+
//
|
|
7
|
+
// Pure data + one pure helper — no IO, no imports, no dependency on the registry.
|
|
8
|
+
|
|
9
|
+
/** Recursively freeze a value and everything reachable from it, returning the
|
|
10
|
+
* same reference. A shallow `Object.freeze` is NOT enough here: it passes
|
|
11
|
+
* `Object.isFrozen(template)` while `template.nodes[0].x = 999` mutates the
|
|
12
|
+
* shipping constant silently, which is exactly what a frozen constant exists to
|
|
13
|
+
* prevent. Primitives and null pass straight through. */
|
|
14
|
+
export function deepFreeze(value) {
|
|
15
|
+
if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
16
|
+
Object.freeze(value);
|
|
17
|
+
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** wf_default as a version-2 graph. w5 (refine self-loop) and w9 (review -> fix)
|
|
22
|
+
* are the loop wires; w10 lands the clean review on the End node. */
|
|
23
|
+
export const GRAPH_DEFAULT_WORKFLOW = deepFreeze({
|
|
24
|
+
id: 'wf_default',
|
|
25
|
+
name: 'Default',
|
|
26
|
+
version: 2,
|
|
27
|
+
domain: 'coding',
|
|
28
|
+
createdAt: '1970-01-01T00:00:00.000Z',
|
|
29
|
+
updatedAt: '1970-01-01T00:00:00.000Z',
|
|
30
|
+
nodes: [
|
|
31
|
+
{ id: 'n_task', kind: 'task', x: 40, y: 200, config: {} },
|
|
32
|
+
{ id: 'n_clarify', kind: 'agent', key: 'clarify', x: 320, y: 200, config: {} },
|
|
33
|
+
{ id: 'n_plan', kind: 'agent', key: 'planner', x: 600, y: 200, config: {} },
|
|
34
|
+
{ id: 'n_refine', kind: 'agent', key: 'refiner', x: 880, y: 200, config: {} },
|
|
35
|
+
{ id: 'n_impl', kind: 'agent', key: 'implementer', x: 1160, y: 200, config: {} },
|
|
36
|
+
{ id: 'n_review', kind: 'agent', key: 'reviewer', x: 1440, y: 200, config: {} },
|
|
37
|
+
{ id: 'n_end', kind: 'end', x: 1720, y: 200, config: {} },
|
|
38
|
+
],
|
|
39
|
+
wires: [
|
|
40
|
+
{ id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
|
|
41
|
+
{ id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
|
|
42
|
+
{ id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
|
|
43
|
+
{ id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
|
|
44
|
+
{ id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
|
|
45
|
+
{ id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
|
|
46
|
+
{ id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
|
|
47
|
+
{ id: 'w8', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
|
|
48
|
+
{ id: 'w9', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
|
|
49
|
+
{ id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
|
|
50
|
+
],
|
|
51
|
+
});
|