@worca/app 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -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,6 +11,7 @@
|
|
|
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';
|
|
16
17
|
|
|
@@ -94,6 +95,12 @@ function runLine(r) {
|
|
|
94
95
|
return `${statusEmoji(r.status)} \`${runRef(r.runId || r.id)}\` ${r.status} — ${title}`;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/** Final segment of a host-native path, on either separator ("/x/proj",
|
|
99
|
+
* "C:\\x\\proj", trailing separator tolerated). Exported for tests. */
|
|
100
|
+
export function lastPathSegment(p) {
|
|
101
|
+
return String(p || '').split(/[\\/]/).filter(Boolean).pop() || '';
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
/**
|
|
98
105
|
* @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
|
|
99
106
|
* actions: listRuns(), runState(runId), pendingQuestion(runId),
|
|
@@ -107,7 +114,9 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
107
114
|
const all = actions.listRuns().filter((r) => (r.kind || 'run') === 'run' || r.kind === 'workspace-run');
|
|
108
115
|
const scope = projectOf(chatKey);
|
|
109
116
|
if (!scope) return all;
|
|
110
|
-
|
|
117
|
+
// Last path segment on EITHER separator: projectDir is host-native, so on
|
|
118
|
+
// Windows it is `C:\\…\\proj` and a "/"-only split never matched the scope.
|
|
119
|
+
return all.filter((r) => lastPathSegment(r.projectDir) === scope
|
|
111
120
|
|| (r.projectNames || []).includes(scope));
|
|
112
121
|
};
|
|
113
122
|
|
|
@@ -172,8 +181,16 @@ export function createCommandRouter({ actions, chatContext, logger = () => {} })
|
|
|
172
181
|
const lines = [runLine(r)];
|
|
173
182
|
const state = actions.runState(r.runId);
|
|
174
183
|
if (state) {
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
// `x:` is NOT a bookend filter — every v2 executionId starts with it —
|
|
185
|
+
// so the two BOOKEND rows are named explicitly.
|
|
186
|
+
const ledger = (state.steps || []).filter((s) => !BOOKEND_EXECUTION_IDS.includes(String(s.key || '')));
|
|
187
|
+
const doneSteps = ledger.filter((s) => s.status === 'done').length;
|
|
188
|
+
const nodes = state.stepper?.graph?.nodes || [];
|
|
189
|
+
const active = (state.active || [])
|
|
190
|
+
.map((a) => nodes.find((n) => n.id === a.nodeId)?.label || a.nodeId);
|
|
191
|
+
const activeLabel = active.length === 0 ? '—'
|
|
192
|
+
: (active.length === 1 ? active[0] : `${active.length} agents running`);
|
|
193
|
+
lines.push(` **Executions:** ${doneSteps}/${ledger.length} done · **Active:** ${activeLabel}`);
|
|
177
194
|
const cost = fmtUsd(state.totalCostUsd);
|
|
178
195
|
if (cost) lines.push(` **Cost:** ${cost}`);
|
|
179
196
|
}
|