@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,285 @@
|
|
|
1
|
+
// src/core/ask/worktrees.mjs
|
|
2
|
+
// Per-thread detached git worktrees of the Ask Worca chat
|
|
3
|
+
// (ask-worca-worktrees-design.md §3-§5): registry rows in ask_worktrees,
|
|
4
|
+
// checkouts under <worcaHome>/ask/<threadId>/wt/<wtId>. Git mechanics come from
|
|
5
|
+
// ../worktree.mjs (DB-free primitives); this module owns rows, paths, caps and
|
|
6
|
+
// the sweep. Synchronous DB via getDb()/prepare() — the store.mjs conventions;
|
|
7
|
+
// ids are shape-checked before they reach any path (the store.mjs doctrine:
|
|
8
|
+
// these paths feed recursive removes).
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
11
|
+
import { realpath } from 'node:fs/promises';
|
|
12
|
+
import { join, sep } from 'node:path';
|
|
13
|
+
import { getDb, prepare } from '../db.mjs';
|
|
14
|
+
import { listProjects } from '../projects.mjs';
|
|
15
|
+
import { readStoreMeta, findPipelineRowById } from '../artifacts.mjs';
|
|
16
|
+
import { branchExists } from '../git-info.mjs';
|
|
17
|
+
import { createDetachedWorktree, removeWorktree, worktreeHead, isValidSourceRef } from '../worktree.mjs';
|
|
18
|
+
import { ASK_ID_RE, askRoot } from './store.mjs';
|
|
19
|
+
import { ASK_LIMITS } from './limits.mjs';
|
|
20
|
+
|
|
21
|
+
export const WT_ID_RE = /^wt_[0-9a-f]{8}$/;
|
|
22
|
+
// The shared ASK_ID_RE (store.mjs) is the loose /^[a-z]+_[0-9a-f]{8}$/; a thread
|
|
23
|
+
// id that becomes a filesystem path is re-checked here against the strict
|
|
24
|
+
// ask_-anchored form (matches askWorktreeAllowRules), so a `wt_…`/other-prefix id
|
|
25
|
+
// can never reach a path.
|
|
26
|
+
const ASK_THREAD_RE = /^ask_[0-9a-f]{8}$/;
|
|
27
|
+
|
|
28
|
+
export class AskWorktreeError extends Error {
|
|
29
|
+
constructor(message) { super(message); this.name = 'AskWorktreeError'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const newWtId = () => `wt_${randomBytes(4).toString('hex')}`;
|
|
33
|
+
const now = () => new Date().toISOString();
|
|
34
|
+
const parse = (v, fallback) => { if (v == null) return fallback; try { return JSON.parse(v); } catch { return fallback; } };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `<askRoot>/<threadId>/wt` — refuses a thread id the store never minted, and
|
|
38
|
+
* REALPATHS the dir once it exists. openAskWorktree stores a realpath'd
|
|
39
|
+
* worktree_dir (git emits realpaths; the base is realpath'd), so without this
|
|
40
|
+
* every later `worktreeDirFor`/lookup would MISS on a symlinked home (macOS
|
|
41
|
+
* `/var` -> `/private/var`, any home on a symlinked mount).
|
|
42
|
+
*/
|
|
43
|
+
export function worktreesDir(threadId) {
|
|
44
|
+
if (typeof threadId !== 'string' || !ASK_THREAD_RE.test(threadId)) {
|
|
45
|
+
throw new Error('worktreesDir: refusing a thread id the store never minted');
|
|
46
|
+
}
|
|
47
|
+
const dir = join(askRoot(), threadId, 'wt');
|
|
48
|
+
try { return realpathSync(dir); } catch { return dir; } // realpath'd once it exists
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `<askRoot>/<threadId>/wt/<wtId>` — both ids shape-checked. */
|
|
52
|
+
export function worktreeDirFor(threadId, wtId) {
|
|
53
|
+
if (typeof wtId !== 'string' || !WT_ID_RE.test(wtId)) {
|
|
54
|
+
throw new Error('worktreeDirFor: refusing a worktree id the store never minted');
|
|
55
|
+
}
|
|
56
|
+
return join(worktreesDir(threadId), wtId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rowToWorktree(r) {
|
|
60
|
+
return {
|
|
61
|
+
worktreeId: r.id, threadId: r.thread_id, projectKey: r.project_key,
|
|
62
|
+
projectDir: r.project_dir, ref: r.ref, commit: r.resolved_commit,
|
|
63
|
+
runId: r.run_id ?? null, path: r.worktree_dir,
|
|
64
|
+
createdAt: r.created_at, updatedAt: r.updated_at,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listAskWorktrees(threadId) {
|
|
69
|
+
getDb();
|
|
70
|
+
return prepare('SELECT * FROM ask_worktrees WHERE thread_id = ? ORDER BY created_at, id')
|
|
71
|
+
.all(threadId).map(rowToWorktree);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getAskWorktree(threadId, wtId) {
|
|
75
|
+
getDb();
|
|
76
|
+
const r = prepare('SELECT * FROM ask_worktrees WHERE thread_id = ? AND id = ?').get(threadId, wtId);
|
|
77
|
+
return r ? rowToWorktree(r) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** (projectKey, ref) directly, or runId → the record's feature branch;
|
|
81
|
+
* workspace records need projectKey to pick the member (§5 steps 1-3). */
|
|
82
|
+
async function resolveTarget({ projectKey, ref, runId }) {
|
|
83
|
+
if (runId) {
|
|
84
|
+
const row = findPipelineRowById(runId);
|
|
85
|
+
if (!row) throw new AskWorktreeError(`run not found: ${runId}`);
|
|
86
|
+
const isWs = row.target === 'workspace' || !!row.workspace_key;
|
|
87
|
+
if (isWs) {
|
|
88
|
+
const meta = parse(row.workspace_meta, {}) || {};
|
|
89
|
+
const members = Array.isArray(meta.projects) ? meta.projects : [];
|
|
90
|
+
if (!projectKey) {
|
|
91
|
+
throw new AskWorktreeError(`run ${runId} is a workspace run — pass projectKey to pick the member (one of: ${members.map((m) => m.projectKey).join(', ') || 'none'})`);
|
|
92
|
+
}
|
|
93
|
+
const member = members.find((m) => m.projectKey === projectKey);
|
|
94
|
+
const b = (meta.branches || {})[projectKey];
|
|
95
|
+
if (!member || !b || !b.feature) throw new AskWorktreeError(`run ${runId} has no member ${projectKey} with a feature branch`);
|
|
96
|
+
if (!(await branchExists(member.projectDir, b.feature))) {
|
|
97
|
+
throw new AskWorktreeError(`branch ${b.feature} no longer exists — use get_run_diff for the recorded diff`);
|
|
98
|
+
}
|
|
99
|
+
return { projectKey, projectDir: member.projectDir, ref: b.feature, runId };
|
|
100
|
+
}
|
|
101
|
+
const branch = parse(row.branch, {}) || {};
|
|
102
|
+
if (!branch.feature) throw new AskWorktreeError(`run ${runId} has no feature branch`);
|
|
103
|
+
const meta = readStoreMeta(row.project_key);
|
|
104
|
+
const projectDir = meta && meta.path;
|
|
105
|
+
if (!projectDir) throw new AskWorktreeError(`run ${runId}: project directory unknown`);
|
|
106
|
+
if (!(await branchExists(projectDir, branch.feature))) {
|
|
107
|
+
throw new AskWorktreeError(`branch ${branch.feature} no longer exists — use get_run_diff for the recorded diff`);
|
|
108
|
+
}
|
|
109
|
+
return { projectKey: row.project_key, projectDir, ref: branch.feature, runId };
|
|
110
|
+
}
|
|
111
|
+
if (!projectKey || !ref) throw new AskWorktreeError('open_worktree: give (projectKey and ref) or runId');
|
|
112
|
+
const projects = await listProjects();
|
|
113
|
+
const p = projects.find((x) => x.key === projectKey);
|
|
114
|
+
if (!p) throw new AskWorktreeError(`unknown projectKey: ${projectKey} — see list_projects`);
|
|
115
|
+
return { projectKey, projectDir: p.path, ref, runId: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function openAskWorktree({ threadId, projectKey, ref, runId, signal } = {}) {
|
|
119
|
+
getDb();
|
|
120
|
+
if (typeof threadId !== 'string' || !ASK_ID_RE.test(threadId)
|
|
121
|
+
|| !prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) {
|
|
122
|
+
throw new AskWorktreeError('unknown thread');
|
|
123
|
+
}
|
|
124
|
+
const perThread = prepare('SELECT count(*) AS n FROM ask_worktrees WHERE thread_id = ?').get(threadId).n;
|
|
125
|
+
if (perThread >= ASK_LIMITS.worktreesPerThread) {
|
|
126
|
+
throw new AskWorktreeError(`worktree cap reached (${ASK_LIMITS.worktreesPerThread} per chat) — remove one with remove_worktree, or reuse one from list_worktrees`);
|
|
127
|
+
}
|
|
128
|
+
const globalCount = prepare('SELECT count(*) AS n FROM ask_worktrees').get().n;
|
|
129
|
+
if (globalCount >= ASK_LIMITS.worktreesGlobal) {
|
|
130
|
+
throw new AskWorktreeError(`global worktree cap reached (${ASK_LIMITS.worktreesGlobal}) — remove unused worktrees first`);
|
|
131
|
+
}
|
|
132
|
+
const t = await resolveTarget({
|
|
133
|
+
projectKey: projectKey || undefined, ref: ref || undefined, runId: runId || undefined,
|
|
134
|
+
});
|
|
135
|
+
if (!existsSync(join(t.projectDir, '.git'))) {
|
|
136
|
+
throw new AskWorktreeError(`project ${t.projectKey} has no git repository at ${t.projectDir}`);
|
|
137
|
+
}
|
|
138
|
+
if (!(await isValidSourceRef(t.projectDir, t.ref))) {
|
|
139
|
+
throw new AskWorktreeError(`ref does not resolve: ${JSON.stringify(t.ref)}`);
|
|
140
|
+
}
|
|
141
|
+
const wtId = newWtId();
|
|
142
|
+
mkdirSync(worktreesDir(threadId), { recursive: true });
|
|
143
|
+
// Re-read AFTER mkdir: worktreesDir() realpaths an existing dir, so the stored
|
|
144
|
+
// worktree_dir matches BOTH what `git worktree list` reports AND every later
|
|
145
|
+
// worktreesDir()/worktreeDirFor() lookup. Reading `base` before mkdir would get
|
|
146
|
+
// the un-realpath'd form and every subsequent lookup would miss on a symlinked
|
|
147
|
+
// home (macOS /var -> /private/var). The createWorktree precedent.
|
|
148
|
+
const baseReal = worktreesDir(threadId);
|
|
149
|
+
const dir = join(baseReal, wtId);
|
|
150
|
+
if (!dir.startsWith(baseReal + sep)) throw new AskWorktreeError('worktree path escapes base'); // belt-and-braces
|
|
151
|
+
const { commit } = await createDetachedWorktree({ projectDir: t.projectDir, worktreeDir: dir, ref: t.ref, signal });
|
|
152
|
+
const ts = now();
|
|
153
|
+
// The thread may have been DELETED while `git worktree add` ran (the DELETE
|
|
154
|
+
// route stops the turn, but an in-flight open_worktree in the MCP child still
|
|
155
|
+
// completes): registering the row would fail or orphan, and the checkout
|
|
156
|
+
// would sit in the user's repo until the next sweep. Roll it back instead.
|
|
157
|
+
try {
|
|
158
|
+
if (!prepare('SELECT 1 FROM ask_threads WHERE id = ?').get(threadId)) throw new AskWorktreeError('unknown thread');
|
|
159
|
+
prepare(`INSERT INTO ask_worktrees (id, thread_id, project_key, project_dir, ref, resolved_commit, run_id, worktree_dir, created_at, updated_at)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
161
|
+
.run(wtId, threadId, t.projectKey, t.projectDir, t.ref, commit ?? '', t.runId, dir, ts, ts);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
try { await removeWorktree({ projectDir: t.projectDir, worktreeDir: dir, branch: null, force: true }); } catch { /* best-effort */ }
|
|
164
|
+
throw err instanceof AskWorktreeError ? err : new AskWorktreeError(`worktree could not be registered: ${err && err.message ? err.message : err}`);
|
|
165
|
+
}
|
|
166
|
+
return getAskWorktree(threadId, wtId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function removeAskWorktree({ threadId, wtId } = {}) {
|
|
170
|
+
const wt = typeof wtId === 'string' && WT_ID_RE.test(wtId) ? getAskWorktree(threadId, wtId) : null;
|
|
171
|
+
if (!wt) throw new AskWorktreeError('worktree not found');
|
|
172
|
+
const res = await removeWorktree({ projectDir: wt.projectDir, worktreeDir: wt.path, branch: null, force: true });
|
|
173
|
+
prepare('DELETE FROM ask_worktrees WHERE thread_id = ? AND id = ?').run(threadId, wtId);
|
|
174
|
+
return { ok: true, steps: res.steps };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Thread-delete cascade step (§5): git-proper removal of every row BEFORE the
|
|
178
|
+
* SQL cascade + the thread-dir rmSync backstop. Never throws — a dead source
|
|
179
|
+
* repo degrades to removeWorktree's rm-rf + prune best effort. */
|
|
180
|
+
export async function removeThreadWorktrees(threadId) {
|
|
181
|
+
if (typeof threadId !== 'string' || !ASK_ID_RE.test(threadId)) return { removed: 0 };
|
|
182
|
+
let removed = 0;
|
|
183
|
+
for (const wt of listAskWorktrees(threadId)) {
|
|
184
|
+
try {
|
|
185
|
+
await removeAskWorktree({ threadId, wtId: wt.worktreeId });
|
|
186
|
+
removed += 1;
|
|
187
|
+
} catch { /* row raced away or repo gone — the rmSync backstop covers the dir */ }
|
|
188
|
+
}
|
|
189
|
+
return { removed };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** After a successful checkout/switch: re-read HEAD and stamp the row so
|
|
193
|
+
* list_worktrees and the UI always show where the checkout actually points. */
|
|
194
|
+
export async function noteWorktreeNavigation(threadId, wtId, { ref } = {}) {
|
|
195
|
+
const wt = getAskWorktree(threadId, wtId);
|
|
196
|
+
if (!wt) return null;
|
|
197
|
+
const head = await worktreeHead(wt.path);
|
|
198
|
+
prepare('UPDATE ask_worktrees SET ref = ?, resolved_commit = ?, updated_at = ? WHERE thread_id = ? AND id = ?')
|
|
199
|
+
.run(ref ?? wt.ref, head ?? wt.commit, now(), threadId, wtId);
|
|
200
|
+
return getAskWorktree(threadId, wtId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Recover the source repo of an orphan dir from its `.git` gitdir pointer
|
|
204
|
+
* (`gitdir: <repo>/.git/worktrees/<name>`), or null. */
|
|
205
|
+
function repoDirOfOrphan(dir) {
|
|
206
|
+
try {
|
|
207
|
+
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(join(dir, '.git'), 'utf8'));
|
|
208
|
+
if (!m) return null;
|
|
209
|
+
const gitdir = m[1].trim();
|
|
210
|
+
const marker = `${sep}.git${sep}worktrees${sep}`;
|
|
211
|
+
const i = gitdir.lastIndexOf(marker);
|
|
212
|
+
return i > 0 ? gitdir.slice(0, i) : null;
|
|
213
|
+
} catch { return null; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Boot/doctor sweep (§5): reconcile rows vs dirs BOTH ways. Three-state
|
|
218
|
+
* doctrine — a DB failure aborts the sweep with nothing removed (failed > 0),
|
|
219
|
+
* never "no rows ⇒ reclaim everything".
|
|
220
|
+
* @param {{log?: (level:string, msg:string) => void}} [opts]
|
|
221
|
+
*/
|
|
222
|
+
export async function sweepAskWorktrees({ log = () => {} } = {}) {
|
|
223
|
+
const summary = { removedDirs: 0, prunedRows: 0, failed: 0 };
|
|
224
|
+
let rows;
|
|
225
|
+
try {
|
|
226
|
+
getDb();
|
|
227
|
+
rows = prepare('SELECT * FROM ask_worktrees').all().map(rowToWorktree);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
summary.failed += 1;
|
|
230
|
+
log('warn', `ask-worktrees: row scan failed — nothing swept (${err.message})`);
|
|
231
|
+
return summary;
|
|
232
|
+
}
|
|
233
|
+
const live = new Set();
|
|
234
|
+
for (const wt of rows) {
|
|
235
|
+
if (existsSync(wt.path)) {
|
|
236
|
+
live.add(await realpath(wt.path).catch(() => wt.path));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
// dir already gone: this is prune + registration cleanup only
|
|
241
|
+
await removeWorktree({ projectDir: wt.projectDir, worktreeDir: wt.path, branch: null, force: true });
|
|
242
|
+
prepare('DELETE FROM ask_worktrees WHERE thread_id = ? AND id = ?').run(wt.threadId, wt.worktreeId);
|
|
243
|
+
summary.prunedRows += 1;
|
|
244
|
+
log('info', `ask-worktrees: dropped stale row ${wt.worktreeId} (dir missing)`);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
summary.failed += 1;
|
|
247
|
+
log('warn', `ask-worktrees: row ${wt.worktreeId} skipped (${err.message})`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
let root;
|
|
251
|
+
try { root = askRoot(); } catch { return summary; }
|
|
252
|
+
let threads = [];
|
|
253
|
+
try {
|
|
254
|
+
threads = readdirSync(root, { withFileTypes: true })
|
|
255
|
+
.filter((d) => d.isDirectory() && ASK_ID_RE.test(d.name)).map((d) => d.name);
|
|
256
|
+
} catch { return summary; } // no ask root yet — nothing to do
|
|
257
|
+
for (const tid of threads) {
|
|
258
|
+
const base = join(root, tid, 'wt');
|
|
259
|
+
let entries = [];
|
|
260
|
+
try {
|
|
261
|
+
entries = readdirSync(base, { withFileTypes: true })
|
|
262
|
+
.filter((d) => d.isDirectory() && WT_ID_RE.test(d.name)).map((d) => d.name);
|
|
263
|
+
} catch { continue; } // thread has no wt/ dir
|
|
264
|
+
for (const wtId of entries) {
|
|
265
|
+
const dir = join(base, wtId);
|
|
266
|
+
const real = await realpath(dir).catch(() => dir);
|
|
267
|
+
if (live.has(real)) continue;
|
|
268
|
+
const repoDir = repoDirOfOrphan(dir);
|
|
269
|
+
try {
|
|
270
|
+
await removeWorktree({ projectDir: repoDir ?? dir, worktreeDir: dir, branch: null, force: true });
|
|
271
|
+
if (!existsSync(dir)) {
|
|
272
|
+
summary.removedDirs += 1;
|
|
273
|
+
log('info', `ask-worktrees: removed orphan dir ${dir}`);
|
|
274
|
+
} else {
|
|
275
|
+
summary.failed += 1;
|
|
276
|
+
log('warn', `ask-worktrees: orphan ${dir} could not be removed`);
|
|
277
|
+
}
|
|
278
|
+
} catch (err) {
|
|
279
|
+
summary.failed += 1;
|
|
280
|
+
log('warn', `ask-worktrees: orphan ${dir} skipped (${err.message})`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return summary;
|
|
285
|
+
}
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// this module never imports Express or the orchestrator.
|
|
12
12
|
|
|
13
13
|
import { parseCommand } from './parser.mjs';
|
|
14
|
+
import { BOOKEND_EXECUTION_IDS } from '../../shared/graph/constants.mjs';
|
|
14
15
|
import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
|
|
15
16
|
import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
|
|
17
|
+
import { giveUpOption, describePauseReason, pauseConsequences } from '../failure-policy.mjs';
|
|
16
18
|
|
|
17
19
|
const md = (value) => ({ kind: 'markdown', value });
|
|
18
20
|
const reply = (text, severity = 'info') => ({ title: null, body: [md(text)], severity });
|
|
@@ -43,7 +45,7 @@ const HELP_TEXT = [
|
|
|
43
45
|
'`/status [*ref]` — run detail · `/cost [*ref]` — run cost',
|
|
44
46
|
'`/pause [*ref]` · `/stop [*ref]` · `/resume [*ref]`',
|
|
45
47
|
'`/approve [*ref]` — continue past a gate · `/retry [*ref]` — another cycle',
|
|
46
|
-
'`/abort [*ref]` —
|
|
48
|
+
'`/abort [*ref]` — give up on a recovery prompt (pauses the run; nothing is discarded)',
|
|
47
49
|
'`/answer [*ref] <n|text> [| …]` — answer clarify questions (option number, or text for free-text)',
|
|
48
50
|
'`/projects` · `/use <name>` — scope commands to one project',
|
|
49
51
|
'`/mute 30m|2h|1d` · `/unmute` — silence notifications for this chat',
|
|
@@ -94,6 +96,12 @@ function runLine(r) {
|
|
|
94
96
|
return `${statusEmoji(r.status)} \`${runRef(r.runId || r.id)}\` ${r.status} — ${title}`;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/** Final segment of a host-native path, on either separator ("/x/proj",
|
|
100
|
+
* "C:\\x\\proj", trailing separator tolerated). Exported for tests. */
|
|
101
|
+
export function lastPathSegment(p) {
|
|
102
|
+
return String(p || '').split(/[\\/]/).filter(Boolean).pop() || '';
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
/**
|
|
98
106
|
* @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
|
|
99
107
|
* actions: listRuns(), runState(runId), pendingQuestion(runId),
|
|
@@ -107,7 +115,9 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
107
115
|
const all = actions.listRuns().filter((r) => (r.kind || 'run') === 'run' || r.kind === 'workspace-run');
|
|
108
116
|
const scope = projectOf(chatKey);
|
|
109
117
|
if (!scope) return all;
|
|
110
|
-
|
|
118
|
+
// Last path segment on EITHER separator: projectDir is host-native, so on
|
|
119
|
+
// Windows it is `C:\\…\\proj` and a "/"-only split never matched the scope.
|
|
120
|
+
return all.filter((r) => lastPathSegment(r.projectDir) === scope
|
|
111
121
|
|| (r.projectNames || []).includes(scope));
|
|
112
122
|
};
|
|
113
123
|
|
|
@@ -165,15 +175,24 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
165
175
|
const r = t.row;
|
|
166
176
|
return reply([runLine({ ...r, runId: r.id }),
|
|
167
177
|
...(fmtUsd(r.totalCostUsd) ? [` **Cost:** ${fmtUsd(r.totalCostUsd)}`] : []),
|
|
168
|
-
...(r.pauseReason ? [` **Pause reason:** ${r.pauseReason}`] : []),
|
|
178
|
+
...(r.pauseReason ? [` **Pause reason:** ${describePauseReason(r.pauseReason) || r.pauseReason}`] : []),
|
|
179
|
+
...(r.pauseDetail ? [` **${pauseConsequences(r.pauseReason).severity === 'error' ? 'Error' : 'Cause'}:** ${r.pauseDetail}`] : []),
|
|
169
180
|
].join('\n'));
|
|
170
181
|
}
|
|
171
182
|
const r = t.run;
|
|
172
183
|
const lines = [runLine(r)];
|
|
173
184
|
const state = actions.runState(r.runId);
|
|
174
185
|
if (state) {
|
|
175
|
-
|
|
176
|
-
|
|
186
|
+
// `x:` is NOT a bookend filter — every v2 executionId starts with it —
|
|
187
|
+
// so the two BOOKEND rows are named explicitly.
|
|
188
|
+
const ledger = (state.steps || []).filter((s) => !BOOKEND_EXECUTION_IDS.includes(String(s.key || '')));
|
|
189
|
+
const doneSteps = ledger.filter((s) => s.status === 'done').length;
|
|
190
|
+
const nodes = state.stepper?.graph?.nodes || [];
|
|
191
|
+
const active = (state.active || [])
|
|
192
|
+
.map((a) => nodes.find((n) => n.id === a.nodeId)?.label || a.nodeId);
|
|
193
|
+
const activeLabel = active.length === 0 ? '—'
|
|
194
|
+
: (active.length === 1 ? active[0] : `${active.length} agents running`);
|
|
195
|
+
lines.push(` **Executions:** ${doneSteps}/${ledger.length} done · **Active:** ${activeLabel}`);
|
|
177
196
|
const cost = fmtUsd(state.totalCostUsd);
|
|
178
197
|
if (cost) lines.push(` **Cost:** ${cost}`);
|
|
179
198
|
}
|
|
@@ -305,14 +324,16 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
305
324
|
if (verb === 'abort') return reply(`Gates have no abort — \`/approve ${ref}\`, \`/retry ${ref}\`, or \`/stop ${ref}\`.`, 'warning');
|
|
306
325
|
payload = { decision: verb === 'approve' ? 'continue' : 'another' };
|
|
307
326
|
} else if (pq.kind === 'recovery') {
|
|
308
|
-
|
|
327
|
+
// /abort is the give-up choice; what it does (pause or abort) is the row's
|
|
328
|
+
// option (failure-policy.mjs) — the option id is the wire decision.
|
|
329
|
+
payload = { decision: verb === 'abort' ? giveUpOption(pq.recovery?.options).id : 'retry' };
|
|
309
330
|
} else {
|
|
310
331
|
return reply(`\`${ref}\` is waiting on ${pq.kind} — use \`/answer ${ref} <n>\`.`, 'warning');
|
|
311
332
|
}
|
|
312
333
|
await actions.answer(t.run.runId, pq.id, payload);
|
|
313
334
|
const what = pq.kind === 'gate'
|
|
314
335
|
? (payload.decision === 'continue' ? 'approved — continuing' : 'sent back for another cycle')
|
|
315
|
-
: (payload.decision === 'retry' ? 'retrying' : 'aborting');
|
|
336
|
+
: (payload.decision === 'retry' ? 'retrying' : payload.decision === 'abort' ? 'aborting the run' : 'pausing the run');
|
|
316
337
|
return reply(`✅ \`${ref}\` ${what}.`, 'success');
|
|
317
338
|
}
|
|
318
339
|
|
|
@@ -14,6 +14,7 @@ import { readPluginConfig } from '../plugin-config.mjs';
|
|
|
14
14
|
import { parseIdList } from './allowlist.mjs';
|
|
15
15
|
import { createRateLimiter } from './rate-limiter.mjs';
|
|
16
16
|
import { renderDone, renderError, renderQuestion } from './renderers.mjs';
|
|
17
|
+
import { pauseConsequences } from '../failure-policy.mjs';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* @param {{channelHost: object, getPrefs: () => {notify:object, channels:object},
|
|
@@ -89,7 +90,11 @@ export function createNotifier({ channelHost, getPrefs, chatContext, logger = ()
|
|
|
89
90
|
const status = payload?.status || 'done';
|
|
90
91
|
if (status === 'error') return; // the richer 'error' event already went out
|
|
91
92
|
const prefs = getPrefsSafe().notify;
|
|
92
|
-
|
|
93
|
+
// Which preference gates a pause follows its reason (failure-policy.mjs):
|
|
94
|
+
// an error-pause IS the failure notification (no 'error' event precedes
|
|
95
|
+
// it), so notify.error gates it, not notify.paused.
|
|
96
|
+
const gate = status === 'paused' ? prefs[pauseConsequences(payload?.reason).notifyPref] : prefs.done;
|
|
97
|
+
if (gate === false) return;
|
|
93
98
|
deliver(renderDone(meta(), payload || {}));
|
|
94
99
|
}));
|
|
95
100
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// ordinals and embeds the exact reply commands (/approve, /retry, /answer n…)
|
|
10
10
|
// using the run-id wildcard-suffix convention the command router resolves.
|
|
11
11
|
|
|
12
|
+
import { pauseConsequences, describePauseReason, giveUpOption } from '../failure-policy.mjs';
|
|
13
|
+
|
|
12
14
|
const md = (value) => ({ kind: 'markdown', value });
|
|
13
15
|
|
|
14
16
|
export function fmtMs(ms) {
|
|
@@ -41,10 +43,6 @@ function head(icon, meta) {
|
|
|
41
43
|
return parts;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
const PAUSE_REASONS = {
|
|
45
|
-
cost_pipeline: 'pipeline cost limit reached',
|
|
46
|
-
cost_total: 'total cost limit reached',
|
|
47
|
-
};
|
|
48
46
|
|
|
49
47
|
/**
|
|
50
48
|
* done event: status done|stopped|paused (+reason for limit pauses).
|
|
@@ -53,11 +51,20 @@ const PAUSE_REASONS = {
|
|
|
53
51
|
export function renderDone(meta, payload = {}) {
|
|
54
52
|
const status = payload.status || 'done';
|
|
55
53
|
if (status === 'paused') {
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// The icon, severity and wording follow the pause's reason (failure-policy.mjs):
|
|
55
|
+
// an error-pause IS the failure notification (no 'error' event precedes it).
|
|
56
|
+
const { severity } = pauseConsequences(payload.reason);
|
|
57
|
+
const isError = severity === 'error';
|
|
58
|
+
const reason = payload.reason ? (describePauseReason(payload.reason) || payload.reason) : null;
|
|
59
|
+
const parts = head(isError ? '\u{1F534}' : '⏸', meta);
|
|
58
60
|
parts.push(` **Status:** paused${reason ? ` — ${reason}` : ''}`);
|
|
61
|
+
if (payload.detail && payload.reason) {
|
|
62
|
+
// Already bounded (PAUSE_DETAIL_MAX, middle-clipped so the runner's trailing
|
|
63
|
+
// cause survives) — a head clip here would throw exactly that tail away.
|
|
64
|
+
parts.push(` **${isError ? 'Error' : 'Cause'}:** ${String(payload.detail)}`);
|
|
65
|
+
}
|
|
59
66
|
parts.push(` Resume from the worca-cc UI, or reply: /resume ${runRef(meta.runId)}`);
|
|
60
|
-
return mdMsg(parts.join('\n'), 'warning');
|
|
67
|
+
return mdMsg(parts.join('\n'), isError ? 'error' : 'warning');
|
|
61
68
|
}
|
|
62
69
|
if (status === 'stopped') {
|
|
63
70
|
const parts = head('⏹', meta);
|
|
@@ -109,7 +116,7 @@ export function renderQuestion(meta, payload = {}) {
|
|
|
109
116
|
}
|
|
110
117
|
parts.push(kind === 'gate'
|
|
111
118
|
? ` Reply: /approve ${ref} to continue · /retry ${ref} for another cycle`
|
|
112
|
-
: ` Reply: /approve ${ref} to retry · /abort ${ref} to abort`);
|
|
119
|
+
: ` Reply: /approve ${ref} to retry · /abort ${ref} to ${giveUpOption(payload.recovery?.options).id === 'abort' ? 'abort the run' : 'pause the run'}`);
|
|
113
120
|
return mdMsg(parts.join('\n'), 'warning');
|
|
114
121
|
}
|
|
115
122
|
|