@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// The per-run memory MOUNT (agent-memory-design.md §4.1, amendment A1) and the
|
|
2
|
+
// hash-baselined SYNC-BACK (§5). Pure over injected paths: `root` is the store
|
|
3
|
+
// (memory-store.mjs' layout), `mount` is `<runCwd>/.claude/rules/worca` (memoryMountPath). No DB, no
|
|
4
|
+
// orchestrator state, no settings reads — the harness passes caps and paths.
|
|
5
|
+
import { mkdir, rm, cp, readFile, writeFile, stat, rename, unlink, readdir } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
GLOBAL_SCOPE, projectScope, scopeDir, listMemory, listMemoryDir, hashText,
|
|
9
|
+
repairMemoryFile, parseMemoryFile, writeMemory, removeMemory, snapshotScope, MemoryError,
|
|
10
|
+
} from './memory-store.mjs';
|
|
11
|
+
import { MEMORY_DEFRAG_WORKFLOW_ID } from './graph/builtin-workflows.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the mount lives INSIDE a run's cwd (native-rules revision, 2026-09-13): Claude Code
|
|
15
|
+
* discovers `<cwd>/.claude/rules/**` recursively, honours `paths:` frontmatter (comma-separated
|
|
16
|
+
* string included) and hands the files to Task sub-agents — probed on claude 2.1.270 from a git
|
|
17
|
+
* worktree cwd and from a non-git run root. `worca/` namespaces the mount away from a project's
|
|
18
|
+
* own committed rules. Forward slashes on purpose: the string is also the git pathspec of the
|
|
19
|
+
* §8.8 exclusion set (`:(exclude).claude/rules/worca`).
|
|
20
|
+
*/
|
|
21
|
+
export const MEMORY_RULES_REL = '.claude/rules/worca';
|
|
22
|
+
/** `<cwd>/.claude/rules/worca` with the platform separator. */
|
|
23
|
+
export function memoryMountPath(cwd) { return join(cwd, ...MEMORY_RULES_REL.split('/')); }
|
|
24
|
+
/**
|
|
25
|
+
* The §8.8 injected-path record of the mount. `kind: 'memory'` is EXCLUDED from every commit,
|
|
26
|
+
* intent-to-add staging and result diff (run-harness `_excludePathspecs`), REMOVED at teardown
|
|
27
|
+
* (`removeInjectedPaths`) and NEVER rescued (`rescueModifiedMounts` handles the skill/claudeMd
|
|
28
|
+
* kinds only — sync-back is this mount's rescue). `source: null`: there is no one source file.
|
|
29
|
+
*/
|
|
30
|
+
export const MEMORY_INJECTED_ENTRY = Object.freeze({ path: MEMORY_RULES_REL, kind: 'memory', source: null });
|
|
31
|
+
|
|
32
|
+
export const baselineKey = (rel, name) => `${rel}/${name}.md`;
|
|
33
|
+
const byKey = (a, b) => (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which store scopes a run mounts, and under which mount-relative dir.
|
|
37
|
+
* Single run: global/ + project/ (D6). Workspace run: global/ + projects/<key>/ per
|
|
38
|
+
* member, sorted by projectKey (the orchestrator's member order). `memoryScope`
|
|
39
|
+
* (P2's defrag option) narrows to exactly one dir; 'project' needs a single-project run.
|
|
40
|
+
*/
|
|
41
|
+
export function mountDirs({ members = [], isWorkspace = false, memoryScope = null } = {}) {
|
|
42
|
+
const out = [{ scope: GLOBAL_SCOPE, rel: 'global', label: 'Global' }];
|
|
43
|
+
if (isWorkspace) {
|
|
44
|
+
for (const m of [...members].sort(byKey)) {
|
|
45
|
+
out.push({ scope: projectScope(m.projectKey), rel: `projects/${m.projectKey}`, label: `Project ${m.projectName || m.projectKey}` });
|
|
46
|
+
}
|
|
47
|
+
} else if (members[0]) {
|
|
48
|
+
const m = members[0];
|
|
49
|
+
out.push({ scope: projectScope(m.projectKey), rel: 'project', label: `Project ${m.projectName || m.projectKey}` });
|
|
50
|
+
}
|
|
51
|
+
if (memoryScope === 'global') return out.filter((d) => d.rel === 'global');
|
|
52
|
+
if (memoryScope === 'project') {
|
|
53
|
+
if (isWorkspace) throw new Error('memoryScope "project" needs a single-project run');
|
|
54
|
+
return out.filter((d) => d.rel === 'project');
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const MEMORY_SCOPES = Object.freeze(['global', 'project']);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The run-option gate (agent-memory-design.md §7.3), shared by POST /api/run, the CLI, Ask's
|
|
63
|
+
* proposal validator and the harness constructor: null when the combination is legal, else the
|
|
64
|
+
* reason — an HTTP 400 / CLI usage error / proposal error, verbatim. `memoryScope` is only ever
|
|
65
|
+
* legal with the Memory defragment workflow, that workflow always needs it, and a defragment
|
|
66
|
+
* run is a single-project run (the mount is one scope dir: mountDirs).
|
|
67
|
+
*/
|
|
68
|
+
export function validateMemoryScope({ workflowId, memoryScope, isWorkspace = false } = {}) {
|
|
69
|
+
const has = memoryScope !== undefined && memoryScope !== null && memoryScope !== '';
|
|
70
|
+
const defrag = workflowId === MEMORY_DEFRAG_WORKFLOW_ID;
|
|
71
|
+
if (has && !MEMORY_SCOPES.includes(memoryScope)) return 'memoryScope must be "global" or "project"';
|
|
72
|
+
if (has && !defrag) return `memoryScope is only valid with the Memory defragment workflow (${MEMORY_DEFRAG_WORKFLOW_ID})`;
|
|
73
|
+
if (defrag && !has) return 'the Memory defragment workflow needs memoryScope ("global" or "project")';
|
|
74
|
+
if (has && isWorkspace) return 'a memory defragment run targets one project, not a workspace';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* (Re)create the mount from the store — the rm is scoped to the `worca/` subtree, so a project's
|
|
80
|
+
* own `.claude/rules/*.md` beside it is never touched: every dir exists even when empty (the
|
|
81
|
+
* pointer block tells the agent it is writable), stale content from a previous segment
|
|
82
|
+
* is removed first (the caller syncs back BEFORE remounting on resume), files are
|
|
83
|
+
* COPIED (never linked). Returns the baseline `{ '<rel>/<name>.md': sha1 }`.
|
|
84
|
+
* `gitIgnore: true` writes `<mount>/.gitignore` = `*` FIRST (before any file is copied, so a
|
|
85
|
+
* mount that fails half way never leaves files without their ignore — an ignore without files
|
|
86
|
+
* is harmless, bare files inside a checkout are not): the mount then sits inside a checkout
|
|
87
|
+
* invisibly — an agent's own `git add -A`, a staging pre-commit hook and the reviewer's
|
|
88
|
+
* `git status` all skip it. Not a memory file: `listMemoryDir` lists `*.md` only, so sync-back
|
|
89
|
+
* neither syncs it nor reads it as a deletion. The §8.8 `:(exclude)` pathspec stays as defence
|
|
90
|
+
* in depth (a hand-deleted sentinel, an older git).
|
|
91
|
+
*/
|
|
92
|
+
export async function mountMemory({ root, mount, dirs, onError, gitIgnore = false }) {
|
|
93
|
+
// (Windows: an indexer/AV may hold a handle for a moment.)
|
|
94
|
+
await rm(mount, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
95
|
+
await mkdir(mount, { recursive: true });
|
|
96
|
+
if (gitIgnore) await writeFile(join(mount, '.gitignore'), '*\n', 'utf8');
|
|
97
|
+
const baseline = {};
|
|
98
|
+
let files = 0;
|
|
99
|
+
for (const d of dirs) {
|
|
100
|
+
const dest = join(mount, d.rel);
|
|
101
|
+
await mkdir(dest, { recursive: true });
|
|
102
|
+
for (const e of await listMemory(root, d.scope, { onError })) {
|
|
103
|
+
await cp(join(scopeDir(root, d.scope), `${e.name}.md`), join(dest, `${e.name}.md`));
|
|
104
|
+
baseline[baselineKey(d.rel, e.name)] = e.hash;
|
|
105
|
+
files++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { mount, dirs, baseline, files };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The NON-destructive twin of mountMemory, for a mount a process may be READING while we refresh
|
|
113
|
+
* it (the Ask chat's --add-dir base, memory-deps.mjs refreshAskMemoryMount): every store file is
|
|
114
|
+
* written atomically by name (temp + rename), files no longer in the store are unlinked, dirs are
|
|
115
|
+
* created and never removed. No `.gitignore` sentinel — an Ask mount is not inside a checkout.
|
|
116
|
+
* No baseline — nothing syncs back from an Ask mount.
|
|
117
|
+
* Failure is PER FILE: a target that cannot be written keeps its previous copy (a stale rule still
|
|
118
|
+
* loads; a file that never landed is simply absent this turn), its temp is unlinked and its name is
|
|
119
|
+
* reported. Returns `{ files, failed }` where `files` is what the mount SHOULD hold (the store
|
|
120
|
+
* entries of the scope set) and `failed` the names that could not be refreshed this call.
|
|
121
|
+
*/
|
|
122
|
+
export async function refreshMount({ root, mount, dirs, onError }) {
|
|
123
|
+
let files = 0;
|
|
124
|
+
const failed = [];
|
|
125
|
+
for (const d of dirs) {
|
|
126
|
+
const dest = join(mount, d.rel);
|
|
127
|
+
await mkdir(dest, { recursive: true });
|
|
128
|
+
const entries = await listMemory(root, d.scope, { onError });
|
|
129
|
+
// `keep` is built from what we actually WROTE or deliberately kept — an entry that vanished
|
|
130
|
+
// between the listing and the read is not in it, so its stale mount copy is swept THIS call.
|
|
131
|
+
const keep = new Set();
|
|
132
|
+
for (const e of entries) {
|
|
133
|
+
const text = await readFile(join(scopeDir(root, d.scope), `${e.name}.md`), 'utf8').catch(() => null);
|
|
134
|
+
if (text === null) continue; // vanished between the listing and the read
|
|
135
|
+
files++;
|
|
136
|
+
const target = join(dest, `${e.name}.md`);
|
|
137
|
+
keep.add(`${e.name}.md`); // written below, or kept as the previous copy on failure
|
|
138
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
139
|
+
try {
|
|
140
|
+
await writeFile(tmp, text, 'utf8');
|
|
141
|
+
await rename(tmp, target);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
await unlink(tmp).catch(() => {});
|
|
144
|
+
failed.push(e.name);
|
|
145
|
+
onError?.(target, err);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Stale files go. A name of THIS shape (`x.md.tmp-<pid>-<ms>`) is another writer's in-flight rename,
|
|
149
|
+
// never a memory file (those end in `.md`) — a memory file named `a.tmp-b` lands as `a.tmp-b.md` and is swept.
|
|
150
|
+
for (const f of await readdir(dest).catch(() => [])) {
|
|
151
|
+
if (keep.has(f) || /\.md\.tmp-\d+-\d+$/.test(f)) continue;
|
|
152
|
+
await unlink(join(dest, f)).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { files, failed };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── one writer per store root per process ────────────────────────────────────
|
|
159
|
+
// A run's parallel executions (scheduler maxParallel, composite slices) and several
|
|
160
|
+
// live runs hosted by one server process all sync into the same store. A promise
|
|
161
|
+
// chain per root is enough for a single-user tool (no lock file); it keeps two
|
|
162
|
+
// syncs from diffing against the same baseline and writing the same file twice.
|
|
163
|
+
const storeQueues = new Map(); // root -> settled tail
|
|
164
|
+
export function withStoreLock(root, fn) {
|
|
165
|
+
const tail = storeQueues.get(root) || Promise.resolve();
|
|
166
|
+
const run = tail.then(fn);
|
|
167
|
+
const settled = run.catch(() => {});
|
|
168
|
+
storeQueues.set(root, settled);
|
|
169
|
+
settled.then(() => { if (storeQueues.get(root) === settled) storeQueues.delete(root); });
|
|
170
|
+
return run;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const REJECTED_INVALID = 'rejected:invalid';
|
|
174
|
+
/** Keep `isHash`: `storeHashOf` is defined in terms of it and is its only remaining caller. */
|
|
175
|
+
const isHash = (v) => typeof v === 'string' && !v.startsWith('rejected:');
|
|
176
|
+
/** The store hash a baseline value still vouches for: a plain hash, or the third segment of a
|
|
177
|
+
* `rejected:<mountHash>:<storeHash>` marker (the store held the file when its overwrite was
|
|
178
|
+
* refused, and that copy is what a later deletion may mirror — spec §5 step 5). null for
|
|
179
|
+
* `rejected:invalid` and for P1's two-part `rejected:<mountHash>` (the store never vouched). */
|
|
180
|
+
const storeHashOf = (v) => (isHash(v) ? v : ((typeof v === 'string' && v.split(':')[2]) || null));
|
|
181
|
+
|
|
182
|
+
/** Totals over a list of Change entries — one shape for results.json and the History detail. */
|
|
183
|
+
export function memoryTotals(changes) {
|
|
184
|
+
const totals = { added: 0, modified: 0, deleted: 0, rejected: 0 };
|
|
185
|
+
for (const c of Array.isArray(changes) ? changes : []) for (const k of Object.keys(totals)) totals[k] += Array.isArray(c[k]) ? c[k].length : 0;
|
|
186
|
+
return totals;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Sync the mount back into the store (§5 steps 1–6), serialised per store root. Per mount dir:
|
|
191
|
+
* - new/changed file: name-repair warning, scope-full / hard-cap / case-twin checks, ONE
|
|
192
|
+
* snapshot per scope per sync (taken lazily before the first accepted write), store write
|
|
193
|
+
* via writeMemory; the repaired text is written back into the mount so both sides carry
|
|
194
|
+
* one hash. Added vs modified is decided by the STORE, never by the baseline.
|
|
195
|
+
* - store changed since baseline AND mount changed: the run wins (the store's version is
|
|
196
|
+
* in the snapshot) — warned by name.
|
|
197
|
+
* - deleted in the mount: removed from the store only when the store still matches the
|
|
198
|
+
* baseline; otherwise kept + warned. A key whose last write was rejected is dropped with
|
|
199
|
+
* a warning (the store never took that text).
|
|
200
|
+
* - a file the store refuses (invalid name, cap, twin, scope full, or a store-side fs
|
|
201
|
+
* error) stays in the mount, is baselined as `rejected:<mountHash>[:<storeHash>]` (the store
|
|
202
|
+
* hash rides along when the store held the file, so a later mount deletion of that file
|
|
203
|
+
* still mirrors; reported ONCE per text, re-tried whenever the text — or the cap — changes)
|
|
204
|
+
* and never reaches the store.
|
|
205
|
+
* - a mount file the run EMPTIED is a DELETION request (amendment B19): the memory tool set
|
|
206
|
+
* has no unlink, and no legitimate memory file is empty (every writer renders a fence), so
|
|
207
|
+
* the key is handed to the deletion pass above. A NEW empty file is simply ignored; this
|
|
208
|
+
* arm runs BEFORE the scope-full pre-check, so a full scope never turns it into a rejection.
|
|
209
|
+
* - a mount file that cannot be READ is skipped this sync with its baseline untouched.
|
|
210
|
+
* Never throws for one file's sake; a listing error is reported through `onError`.
|
|
211
|
+
*/
|
|
212
|
+
export function syncBack(o) { return withStoreLock(o.root, () => syncBackUnlocked(o)); }
|
|
213
|
+
|
|
214
|
+
async function syncBackUnlocked({ root, mount, dirs, baseline, source, now, caps, onWarn, onError }) {
|
|
215
|
+
const next = { ...baseline };
|
|
216
|
+
const added = [], modified = [], deleted = [], rejected = [];
|
|
217
|
+
const warn = (t) => { if (typeof onWarn === 'function') onWarn(t); };
|
|
218
|
+
for (const d of dirs) {
|
|
219
|
+
const dir = join(mount, d.rel);
|
|
220
|
+
// A scope dir that is GONE is never an agent deleting every file in it (agents
|
|
221
|
+
// delete files, not the mount): a remount that failed after its rm, or a ledger
|
|
222
|
+
// older than the mount. Skip it with its baseline untouched — never mirror deletions.
|
|
223
|
+
const present = await stat(dir).then((s) => s.isDirectory(), () => false);
|
|
224
|
+
if (!present) { warn(`memory: mount dir ${d.rel} is missing — skipped this sync (nothing deleted)`); continue; }
|
|
225
|
+
const junk = [];
|
|
226
|
+
const current = await listMemoryDir(dir, { onError: (p, err) => { if (err?.code === 'ENAME') junk.push(p); else onError?.(p, err); } });
|
|
227
|
+
const seen = new Set();
|
|
228
|
+
for (const p of junk) {
|
|
229
|
+
const name = p.split(/[\\/]/).pop().replace(/\.md$/i, '');
|
|
230
|
+
const key = baselineKey(d.rel, name);
|
|
231
|
+
seen.add(key); // never reaches the deletion pass
|
|
232
|
+
if (baseline[key] !== REJECTED_INVALID) rejected.push({ scope: d.rel, name, reason: 'invalid name (letters, digits, ".", "_", "-" only; no leading or trailing dot; not a Windows device name)' });
|
|
233
|
+
next[key] = REJECTED_INVALID;
|
|
234
|
+
}
|
|
235
|
+
const store = new Map((await listMemory(root, d.scope, { onError })).map((e) => [e.name, e.hash]));
|
|
236
|
+
let snapped = false;
|
|
237
|
+
const snapshotOnce = async () => { if (!snapped) { snapped = true; await snapshotScope(root, d.scope, { source, now }); } };
|
|
238
|
+
for (const e of current) {
|
|
239
|
+
const key = baselineKey(d.rel, e.name);
|
|
240
|
+
seen.add(key);
|
|
241
|
+
if (next[key] === e.hash) continue; // unchanged since the last sync
|
|
242
|
+
const inStore = store.has(e.name);
|
|
243
|
+
// The marker keeps the STORE hash (when there is one) so the same text is not re-reported
|
|
244
|
+
// on the next sync (the marker must be stable) and a later mount deletion can still tell
|
|
245
|
+
// whether the store copy is the one this run mounted.
|
|
246
|
+
const storeHash = storeHashOf(baseline[key]);
|
|
247
|
+
const marker = `rejected:${e.hash}${storeHash ? `:${storeHash}` : ''}`;
|
|
248
|
+
const reject = (reason) => { if (baseline[key] !== marker) rejected.push({ scope: d.rel, name: e.name, reason }); next[key] = marker; };
|
|
249
|
+
let text;
|
|
250
|
+
try { text = await readFile(join(dir, `${e.name}.md`), 'utf8'); }
|
|
251
|
+
catch (err) {
|
|
252
|
+
if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') continue; // vanished between the listing and the read
|
|
253
|
+
warn(`memory: cannot read ${key}: ${err?.code || err?.message || err} — skipped this sync`); continue;
|
|
254
|
+
}
|
|
255
|
+
// B19: a mount file the agent EMPTIED is a deletion request — the memory tool set has
|
|
256
|
+
// no unlink (Write/Edit only), and no legitimate memory file is empty (every writer
|
|
257
|
+
// renders a fence). Hand the key to the deletion pass: it mirrors the removal only
|
|
258
|
+
// when the store copy is unchanged since the mount (spec §5 step 5), else keeps + warns.
|
|
259
|
+
// A NEW empty file is simply ignored (the deletion pass sees no baseline for it).
|
|
260
|
+
if (!text.trim()) { seen.delete(key); continue; }
|
|
261
|
+
// The scope-full pre-check comes AFTER the read and the B19 arm: a file the run EMPTIED is a
|
|
262
|
+
// deletion request, never a new file, so a full scope must not turn it into a rejection.
|
|
263
|
+
if (!inStore && store.size >= (caps?.maxFilesPerScope ?? Infinity)) { reject(`scope is full (${caps.maxFilesPerScope} files)`); continue; }
|
|
264
|
+
const declared = parseMemoryFile(text).meta.name;
|
|
265
|
+
if (declared && declared !== e.name) warn(`memory: ${key} declares name "${declared}" — repaired to the filename stem "${e.name}"`);
|
|
266
|
+
if (inStore && storeHash && store.get(e.name) !== storeHash) {
|
|
267
|
+
warn(`memory: ${key} changed in the store since this run mounted it — the run's version wins (the store's version is kept in .history)`);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await writeMemory(root, d.scope, e.name, text, { source, now, caps, snapshot: snapshotOnce });
|
|
271
|
+
const repaired = repairMemoryFile(text, { name: e.name, source, now, hookMaxChars: caps?.hookMaxChars });
|
|
272
|
+
if (repaired.changed) await writeFile(join(dir, `${e.name}.md`), repaired.text, 'utf8');
|
|
273
|
+
const h = hashText(repaired.text);
|
|
274
|
+
next[key] = h;
|
|
275
|
+
store.set(e.name, h);
|
|
276
|
+
(inStore ? modified : added).push({ scope: d.rel, name: e.name });
|
|
277
|
+
} catch (err) {
|
|
278
|
+
reject(err instanceof MemoryError ? err.message.replace(/^memory: /, '') : `could not write to the store (${err?.code || err?.name || 'error'})`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
for (const key of Object.keys(baseline)) {
|
|
282
|
+
if (!key.startsWith(`${d.rel}/`) || seen.has(key)) continue;
|
|
283
|
+
const name = key.slice(d.rel.length + 1).replace(/\.md$/, '');
|
|
284
|
+
if (!store.has(name)) { delete next[key]; continue; } // already gone from the store (or never accepted)
|
|
285
|
+
const storeHash = storeHashOf(baseline[key]);
|
|
286
|
+
if (storeHash === null) { // the store never vouched for this key (P1 marker / invalid name)
|
|
287
|
+
warn(`memory: ${key} was deleted by the run after a rejected write — the store's version is kept`);
|
|
288
|
+
delete next[key]; continue;
|
|
289
|
+
}
|
|
290
|
+
if (store.get(name) !== storeHash) {
|
|
291
|
+
warn(`memory: ${key} was deleted by the run but changed in the store since this run mounted it — kept`);
|
|
292
|
+
delete next[key]; continue;
|
|
293
|
+
}
|
|
294
|
+
await removeMemory(root, d.scope, name, { source, now, snapshot: snapshotOnce });
|
|
295
|
+
delete next[key];
|
|
296
|
+
deleted.push({ scope: d.rel, name });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { baseline: next, added, modified, deleted, rejected, total: added.length + modified.length + deleted.length };
|
|
300
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/core/metrics/ledger.mjs
|
|
2
|
+
// Per-run team-metrics status for the History header (§4.11): pending | recorded | skipped.
|
|
3
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { worcaHome } from '../projects.mjs';
|
|
7
|
+
|
|
8
|
+
const RUN_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
9
|
+
|
|
10
|
+
export function ledgerFile(runId) {
|
|
11
|
+
if (!RUN_ID_RE.test(String(runId))) throw new RangeError(`bad run id: ${runId}`);
|
|
12
|
+
return join(worcaHome(), 'metrics', 'ledger', `${runId}.json`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function writeRunLedger(runId, entry) {
|
|
16
|
+
const file = ledgerFile(runId);
|
|
17
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
18
|
+
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
19
|
+
writeFileSync(tmp, JSON.stringify({ ...entry, at: new Date().toISOString() }) + '\n', 'utf8');
|
|
20
|
+
renameSync(tmp, file);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @returns {{state:'recorded'|'pending'|'skipped'|'not-enabled', slug?:string, reason?:string, detail?:string}} */
|
|
24
|
+
export function readRunLedger(runId) {
|
|
25
|
+
try {
|
|
26
|
+
const v = JSON.parse(readFileSync(ledgerFile(runId), 'utf8'));
|
|
27
|
+
if (v && ['recorded', 'pending', 'skipped'].includes(v.state)) return v;
|
|
28
|
+
} catch { /* absent or unreadable */ }
|
|
29
|
+
return { state: 'not-enabled' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const LEDGER_RETENTION_MS = 180 * 24 * 60 * 60_000;
|
|
33
|
+
|
|
34
|
+
/** One file per run, forever, is a leak (§4.11 only needs the History header). Drop old entries. */
|
|
35
|
+
export function sweepRunLedger({ now = Date.now(), maxAgeMs = LEDGER_RETENTION_MS } = {}) {
|
|
36
|
+
const dir = join(worcaHome(), 'metrics', 'ledger');
|
|
37
|
+
let names;
|
|
38
|
+
try { names = readdirSync(dir); } catch { return 0; }
|
|
39
|
+
let dropped = 0;
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
if (!name.endsWith('.json')) continue;
|
|
42
|
+
try {
|
|
43
|
+
if (now - statSync(join(dir, name)).mtimeMs > maxAgeMs) { rmSync(join(dir, name), { force: true }); dropped += 1; }
|
|
44
|
+
} catch { /* raced with another sweep */ }
|
|
45
|
+
}
|
|
46
|
+
return dropped;
|
|
47
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// src/core/metrics/lock.mjs
|
|
2
|
+
// Cross-process advisory lock: O_EXCL create of a lock file holding {pid, token, at}. The CLI
|
|
3
|
+
// and the UI server may both finish runs and flush the same slug (§4.5).
|
|
4
|
+
// - The holder HEARTBEATS `at` while it holds the lock, so a long flush (5 × fetch+push) is
|
|
5
|
+
// never mistaken for a stale lock, and a reused pid cannot pin a dead lock forever.
|
|
6
|
+
// - Stale = holder pid is dead, or the heartbeat is older than `staleMs`.
|
|
7
|
+
// - Breaking a stale lock goes through a `.break` mutex and re-checks the observed content,
|
|
8
|
+
// so two waiters can never both delete-and-recreate (the second would delete a live lock).
|
|
9
|
+
// - Release removes the file only if it still carries our token.
|
|
10
|
+
//
|
|
11
|
+
// Two honest limits (decision 29):
|
|
12
|
+
// (a) `acquireLock` does `mkdir(dirname(file), { recursive: true })` unconditionally, and a
|
|
13
|
+
// caller may take this lock before any record exists in the directory it creates — so an
|
|
14
|
+
// enabled project can end up with an empty outbox directory. This is deliberate; do not
|
|
15
|
+
// "fix" it by skipping the mkdir or deferring it.
|
|
16
|
+
// (b) `breakStale`'s content re-check is NOT atomic: between reading the observed content and
|
|
17
|
+
// `rm`ing the file, a live holder can heartbeat and have its lock deleted out from under it.
|
|
18
|
+
// The `.break` mutex only removes breaker-vs-breaker races, not holder-vs-breaker; the
|
|
19
|
+
// window is sub-millisecond and only reachable after `staleMs` (2 min) of apparent silence
|
|
20
|
+
// from a live process. Do not attempt to close this window.
|
|
21
|
+
import { open, readFile, writeFile, rm, stat, mkdir } from 'node:fs/promises';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
import { randomBytes } from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export const LOCK_STALE_MS = 2 * 60_000;
|
|
26
|
+
export const LOCK_HEARTBEAT_MS = 20_000;
|
|
27
|
+
const BREAK_STALE_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
function pidAlive(pid) {
|
|
32
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @returns {Promise<{stale:boolean, raw:string|null}>} */
|
|
36
|
+
async function inspect(file, staleMs) {
|
|
37
|
+
let raw;
|
|
38
|
+
try { raw = await readFile(file, 'utf8'); } catch { return { stale: false, raw: null } }
|
|
39
|
+
try {
|
|
40
|
+
const { pid, at } = JSON.parse(raw);
|
|
41
|
+
if (Number.isInteger(pid) && pid !== process.pid && !pidAlive(pid)) return { stale: true, raw };
|
|
42
|
+
return { stale: Date.now() - Date.parse(at) > staleMs, raw };
|
|
43
|
+
} catch {
|
|
44
|
+
// Unparsable: possibly mid-write by its creator — judge by mtime only.
|
|
45
|
+
try { return { stale: Date.now() - (await stat(file)).mtimeMs > staleMs, raw }; } catch { return { stale: false, raw } }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {Promise<boolean>} true when this caller did the break (retry at once), false when someone else holds `.break` (poll). */
|
|
50
|
+
async function breakStale(file, observed) {
|
|
51
|
+
const brk = `${file}.break`;
|
|
52
|
+
let fh;
|
|
53
|
+
try { fh = await open(brk, 'wx'); } catch (e) {
|
|
54
|
+
if (e.code !== 'EEXIST') throw e;
|
|
55
|
+
const st = await stat(brk).catch(() => null);
|
|
56
|
+
if (st && Date.now() - st.mtimeMs > BREAK_STALE_MS) await rm(brk, { force: true });
|
|
57
|
+
return false; // someone else is breaking it; the caller sleeps and polls again
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
if ((await readFile(file, 'utf8').catch(() => null)) === observed) await rm(file, { force: true });
|
|
61
|
+
} finally {
|
|
62
|
+
await fh.close();
|
|
63
|
+
await rm(brk, { force: true });
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {Promise<() => Promise<void>>} release function (idempotent) */
|
|
69
|
+
export async function acquireLock(file, { staleMs = LOCK_STALE_MS, heartbeatMs = LOCK_HEARTBEAT_MS, timeoutMs = 120_000, pollMs = 50 } = {}) {
|
|
70
|
+
await mkdir(dirname(file), { recursive: true });
|
|
71
|
+
const deadline = Date.now() + timeoutMs;
|
|
72
|
+
const token = randomBytes(8).toString('hex');
|
|
73
|
+
const body = () => JSON.stringify({ pid: process.pid, token, at: new Date().toISOString() });
|
|
74
|
+
for (;;) {
|
|
75
|
+
try {
|
|
76
|
+
const fh = await open(file, 'wx');
|
|
77
|
+
await fh.writeFile(body());
|
|
78
|
+
await fh.close();
|
|
79
|
+
const ours = async () => {
|
|
80
|
+
try { return JSON.parse(await readFile(file, 'utf8')).token === token; } catch { return false; }
|
|
81
|
+
};
|
|
82
|
+
let released = false;
|
|
83
|
+
let beating = null;
|
|
84
|
+
// One heartbeat at a time, and release() awaits the in-flight one: otherwise a write that
|
|
85
|
+
// finishes after release()'s rm recreates the lock with a live pid and pins the slug for
|
|
86
|
+
// LOCK_STALE_MS (reproduced 11/400 with heartbeatMs:1).
|
|
87
|
+
const beat = setInterval(() => {
|
|
88
|
+
if (released || beating) return;
|
|
89
|
+
beating = ours()
|
|
90
|
+
.then((y) => (y && !released ? writeFile(file, body()) : null))
|
|
91
|
+
.catch(() => {})
|
|
92
|
+
.finally(() => { beating = null; });
|
|
93
|
+
}, heartbeatMs);
|
|
94
|
+
beat.unref?.();
|
|
95
|
+
return async () => {
|
|
96
|
+
if (released) return;
|
|
97
|
+
released = true;
|
|
98
|
+
clearInterval(beat);
|
|
99
|
+
if (beating) await beating;
|
|
100
|
+
if (await ours()) await rm(file, { force: true });
|
|
101
|
+
};
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err.code !== 'EEXIST') throw err;
|
|
104
|
+
const { stale, raw } = await inspect(file, staleMs);
|
|
105
|
+
if (stale && raw != null && await breakStale(file, raw)) continue;
|
|
106
|
+
if (Date.now() > deadline) {
|
|
107
|
+
throw Object.assign(new Error(`team metrics lock is busy: ${file}`), { code: 'LOCK_TIMEOUT' });
|
|
108
|
+
}
|
|
109
|
+
await sleep(pollMs + Math.floor(Math.random() * pollMs));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function withLock(file, fn, opts) {
|
|
115
|
+
const release = await acquireLock(file, opts);
|
|
116
|
+
try { return await fn(); } finally { await release(); }
|
|
117
|
+
}
|