@khoavu882/doflow 2.0.1 → 2.0.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/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/session-end.sh +21 -1
- package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
- package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
- package/core/registry/contribution-points.json +1 -1
- package/core/registry/intake.json +3 -3
- package/core/registry/lifecycle.json +256 -5
- package/core/registry/packages/doflow/doflow-package.json +2 -2
- package/core/registry/settings.json +12 -12
- package/core/registry/storage.json +1 -2
- package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
- package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
- package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
- package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
- package/core/shared/scripts/doflow/bin/doflow-run +18 -0
- package/core/shared/skills/do-design/SKILL.md +6 -2
- package/core/shared/skills/do-design/references/data_model_example.md +167 -0
- package/core/shared/templates/doflow/data-model-template.md +136 -102
- package/package.json +1 -1
- package/src/adapters/antigravity/index.js +54 -12
- package/src/adapters/codex/agents.js +15 -1
- package/src/adapters/codex/config.js +5 -2
- package/src/adapters/copilot/index.js +11 -4
- package/src/adapters/copy-tree.js +7 -0
- package/src/adapters/created-file.js +41 -0
- package/src/adapters/mcp-entries.js +11 -1
- package/src/adapters/opencode/index.js +32 -5
- package/src/adapters/pi/index.js +15 -4
- package/src/adapters/pi/mcp.js +44 -18
- package/src/cli/commands/remove.js +3 -1
- package/src/cli/commands/update.js +7 -3
- package/src/cli/index.js +24 -12
- package/src/cli/runtime-commands.js +1 -1
- package/src/cli/shared.js +5 -3
- package/src/helper/marker-merge.js +11 -7
- package/src/install/backup-set.js +7 -3
- package/src/install/backup.js +13 -7
- package/src/install/context.js +2 -1
- package/src/install/manifest.js +35 -11
- package/src/install/paths.js +38 -0
- package/src/install/targets.js +11 -2
- package/src/lifecycle/index.js +80 -3
- package/src/lifecycle/retired-harnesses.js +16 -1
- package/src/lifecycle/view.js +3 -2
- package/src/registry/index.js +21 -4
- package/src/release/docs-blocks.js +5 -1
- package/src/runtime/advice/doctor.js +2 -2
- package/src/runtime/brief-tools.js +2 -1
- package/src/runtime/capability-router.js +26 -3
- package/src/runtime/context/pointer.js +17 -5
- package/src/runtime/context-pack.js +48 -8
- package/src/runtime/dispatch/integrate.js +27 -8
- package/src/runtime/dispatch/loop.js +7 -0
- package/src/runtime/dispatch/verify-run.js +9 -3
- package/src/runtime/feature-pointer.js +10 -1
- package/src/runtime/health.js +9 -4
- package/src/runtime/implementation-gate.js +10 -2
- package/src/runtime/intake/engine.js +11 -9
- package/src/runtime/knowledge/code-retrieval.js +11 -5
- package/src/runtime/knowledge/semble-provider.js +12 -2
- package/src/runtime/lifecycle/cli.js +7 -5
- package/src/runtime/lifecycle/overview.js +32 -2
- package/src/runtime/lifecycle/release.js +16 -3
- package/src/runtime/lifecycle/retention.js +12 -15
- package/src/runtime/lifecycle/store-upkeep.js +13 -3
- package/src/runtime/mask.js +3 -3
- package/src/runtime/mcp-consultations.js +11 -5
- package/src/runtime/research-request.js +3 -1
- package/src/runtime/review/command.js +3 -7
- package/src/runtime/review/start.js +24 -10
- package/src/runtime/roadmap/loop.js +14 -3
- package/src/runtime/roadmap/rows.js +2 -1
- package/src/runtime/settings/files.js +3 -1
- package/src/runtime/settings/prepared-run.js +12 -2
- package/src/runtime/settings/resolve.js +13 -1
- package/src/runtime/settings/write.js +73 -2
- package/src/runtime/storage/registry.js +2 -2
- package/src/runtime/storage/roots.js +4 -18
- package/src/runtime/storage/rules.js +22 -46
- package/src/runtime/storage/work-status.js +14 -4
- package/src/runtime/task-scope.js +17 -2
- package/src/runtime/task-state.js +11 -4
- package/src/runtime/trace/analyze.js +4 -2
- package/src/runtime/trace/indicators.js +25 -20
- package/src/runtime/trace/spans.js +4 -5
- package/src/runtime/verb-view.js +6 -5
- package/src/runtime/worker/command.js +4 -2
- package/src/runtime/worker/readiness.js +8 -4
- package/src/runtime/worker/supervisor.js +18 -6
- package/src/runtime/workflow-orchestrator.js +6 -2
|
@@ -31,16 +31,9 @@ function createPruneSettingsResolver({ roots, fsImpl = fs, onView } = {})
|
|
|
31
31
|
if (!loadedView) {
|
|
32
32
|
loadedView = true;
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
if (fsImpl !== fs) {
|
|
36
|
-
const { loadSettingsRegistry } = require('../settings/registry.js');
|
|
37
|
-
const loaded = loadSettingsRegistry({ fsImpl });
|
|
38
|
-
if (!loaded.ok) throw new Error(loaded.detail);
|
|
39
|
-
settingsRegistry = loaded.registry;
|
|
40
|
-
}
|
|
41
|
-
const { loadSettings } = require('../settings/resolve.js');
|
|
34
|
+
const { loadSettings, registryOn } = require('../settings/resolve.js');
|
|
42
35
|
const projectRoot = roots['project-doflow'] ? path.dirname(roots['project-doflow']) : null;
|
|
43
|
-
view = loadSettings({ projectRoot, env: resolverEnv, fsImpl, ...(
|
|
36
|
+
view = loadSettings({ projectRoot, env: resolverEnv, fsImpl, ...registryOn(fsImpl) });
|
|
44
37
|
} catch {
|
|
45
38
|
// A settings registry fault fails closed for every root, as it did for each old call.
|
|
46
39
|
view = null;
|
|
@@ -69,15 +62,8 @@ function readPruneSetting({ roots, rootId, fsImpl = fs } = {})
|
|
|
69
62
|
const resolverEnv = {};
|
|
70
63
|
if (roots['config-home']) resolverEnv.XDG_CONFIG_HOME = path.dirname(roots['config-home']);
|
|
71
64
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const { loadSettingsRegistry } = require('../settings/registry.js');
|
|
75
|
-
const loaded = loadSettingsRegistry({ fsImpl });
|
|
76
|
-
if (!loaded.ok) throw new Error(loaded.detail);
|
|
77
|
-
settingsRegistry = loaded.registry;
|
|
78
|
-
}
|
|
79
|
-
const { loadSettings } = require('../settings/resolve.js');
|
|
80
|
-
const view = loadSettings({ projectRoot, global, env: resolverEnv, fsImpl, ...(settingsRegistry ? { registry: settingsRegistry } : {}) });
|
|
65
|
+
const { loadSettings, registryOn } = require('../settings/resolve.js');
|
|
66
|
+
const view = loadSettings({ projectRoot, global, env: resolverEnv, fsImpl, ...registryOn(fsImpl) });
|
|
81
67
|
const resolved = view.get('storage.prune', global ? 'global' : 'project');
|
|
82
68
|
return {
|
|
83
69
|
setting: resolved.value === 'on' ? 'on' : 'off',
|
|
@@ -232,16 +232,15 @@ function lifecyclePathsSafe(project , fsImpl ) {
|
|
|
232
232
|
}
|
|
233
233
|
return true;
|
|
234
234
|
}
|
|
235
|
-
function definitelyMissing(fsImpl , target ) {
|
|
236
|
-
try { fsImpl.lstatSync(target); return false; }
|
|
237
|
-
catch (error ) { return error.code === 'ENOENT' || error.code === 'ENOTDIR'; }
|
|
238
|
-
}
|
|
239
235
|
function reportCandidates(store , entries , context ) {
|
|
240
236
|
const { fsImpl, storeDir, roots, now, settings, shouldStop } = context;
|
|
241
237
|
const project = projectRoot(roots);
|
|
242
238
|
const key = project && projectKey(project, fsImpl), selected = [];
|
|
243
239
|
for (const entry of entries) {
|
|
244
240
|
if (shouldStop?.()) throw budgetError();
|
|
241
|
+
// A folder under another project's key stays: ENOENT or ENOTDIR on its recorded root never
|
|
242
|
+
// authorises deleting it, since an unmounted or renamed root looks the same as a gone one.
|
|
243
|
+
if (entry.name !== key) continue;
|
|
245
244
|
const folder = path.join(storeDir, entry.name);
|
|
246
245
|
let children, bodyNames = [];
|
|
247
246
|
try {
|
|
@@ -251,50 +250,27 @@ function reportCandidates(store , entries , context ) {
|
|
|
251
250
|
if (BODY.test(name) && safeStat(fsImpl, path.join(folder, name))?.isFile()) bodyNames.push(name);
|
|
252
251
|
}
|
|
253
252
|
} catch (error ) { if (error.partial) throw error; continue; }
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
for (const id of ids) {
|
|
267
|
-
if (shouldStop?.()) throw budgetError();
|
|
268
|
-
if (typeof id === 'string' && /^FU-[0-9a-hjkmnp-tv-z]{6}$/.test(id)) eventIds.add(id);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
for (const body of bodyNames) {
|
|
272
|
-
if (shouldStop?.()) throw budgetError();
|
|
273
|
-
const stat = safeStat(fsImpl, path.join(folder, body));
|
|
274
|
-
if (stat && !eventIds.has(body.slice(0, -4)) && now - stat.mtimeMs > settings.orphanGraceHours * HOUR)
|
|
275
|
-
selected.push({ name: `${entry.name}/${body}`, reason: 'orphan-body', bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
278
|
-
const marker = path.join(folder, '.root');
|
|
279
|
-
if (!safeStat(fsImpl, marker)?.isFile() || !bodyNames.length) continue;
|
|
280
|
-
let validFolder = true;
|
|
281
|
-
for (const name of children) {
|
|
253
|
+
const lifecycle = path.join(project, '.doflow/state/lifecycle');
|
|
254
|
+
if (!safeStat(fsImpl, path.join(lifecycle, 'migrated.json'))?.isFile() || !lifecyclePathsSafe(project, fsImpl)) continue;
|
|
255
|
+
let snapshot;
|
|
256
|
+
try { snapshot = readEvents(project, { fsImpl, lockFallback: false, shouldStop }); }
|
|
257
|
+
catch { continue; }
|
|
258
|
+
if (snapshot.incomplete) throw budgetError();
|
|
259
|
+
if (snapshot.unreadable.length) continue;
|
|
260
|
+
const eventIds = new Set();
|
|
261
|
+
for (const event of snapshot.events) {
|
|
262
|
+
if (shouldStop?.()) throw budgetError();
|
|
263
|
+
const ids = [event.data.id, ...(Array.isArray(event.data.ids) ? event.data.ids : [])];
|
|
264
|
+
for (const id of ids) {
|
|
282
265
|
if (shouldStop?.()) throw budgetError();
|
|
283
|
-
if (
|
|
266
|
+
if (typeof id === 'string' && /^FU-[0-9a-hjkmnp-tv-z]{6}$/.test(id)) eventIds.add(id);
|
|
284
267
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (shouldStop?.()) throw budgetError();
|
|
292
|
-
const stat = safeStat(fsImpl, path.join(folder, body));
|
|
293
|
-
if (!stat) { newest = Infinity; break; }
|
|
294
|
-
newest = Math.max(newest, stat.mtimeMs);
|
|
295
|
-
}
|
|
296
|
-
if (now - newest > store.bound.foreignMaxAgeDays * DAY)
|
|
297
|
-
selected.push({ name: entry.name, reason: 'foreign-root-gone', bytes: entry.stat.size, mtimeMs: newest });
|
|
268
|
+
}
|
|
269
|
+
for (const body of bodyNames) {
|
|
270
|
+
if (shouldStop?.()) throw budgetError();
|
|
271
|
+
const stat = safeStat(fsImpl, path.join(folder, body));
|
|
272
|
+
if (stat && !eventIds.has(body.slice(0, -4)) && now - stat.mtimeMs > settings.orphanGraceHours * HOUR)
|
|
273
|
+
selected.push({ name: `${entry.name}/${body}`, reason: 'orphan-body', bytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
298
274
|
}
|
|
299
275
|
}
|
|
300
276
|
return selected;
|
|
@@ -81,6 +81,17 @@ function isRunNamespaceDir(name ) {
|
|
|
81
81
|
return safeSegment(name) && !name.endsWith('.lock');
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether a flat run is superseded by a namespaced copy: it names that copy's namespace as its own
|
|
86
|
+
* feature (`featureSlug`, else `slug`) and the copy has the same file name. A flat run that names
|
|
87
|
+
* no feature, or one whose namespace holds no copy, is a run of its own and is never dropped. The
|
|
88
|
+
* one ownership rule for every reader that walks `orchestration/` (health, trace, work status).
|
|
89
|
+
*/
|
|
90
|
+
function isSupersededFlatRun(name , value , copies ) {
|
|
91
|
+
const own = stringField(value, 'featureSlug') || stringField(value, 'slug');
|
|
92
|
+
return own !== null && isRunNamespaceDir(own) && copies.some((copy) => copy.namespace === own && copy.name === name);
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
/**
|
|
85
96
|
* Every run id the orchestration store holds, flat or under a `<slug>/` namespace (115 C-1: a task id
|
|
86
97
|
* other than its feature's slug lives in `orchestration/<slug>/`). `cache.runLocations` maps each id
|
|
@@ -136,9 +147,8 @@ function readRunAt(cache , runId , where ) {
|
|
|
136
147
|
const file = path.join(stateRoot(cache.projectRoot), 'orchestration', ...(where ? [where] : []), `${runId}.json`);
|
|
137
148
|
let result ;
|
|
138
149
|
try {
|
|
139
|
-
// A
|
|
140
|
-
|
|
141
|
-
const text = where && typeof cache.fsImpl.lstatSync === 'function' && typeof cache.fsImpl.openSync === 'function'
|
|
150
|
+
// A link, a FIFO or a device there reads as unreadable (the record is kept) and never blocks.
|
|
151
|
+
const text = typeof cache.fsImpl.lstatSync === 'function' && typeof cache.fsImpl.openSync === 'function'
|
|
142
152
|
? require('../task-scope.js').readRegularFileText(file, cache.fsImpl) : cache.fsImpl.readFileSync(file, 'utf8');
|
|
143
153
|
if (text === null) throw new Error('not a regular file');
|
|
144
154
|
const value = JSON.parse(text);
|
|
@@ -542,4 +552,4 @@ function workStatus({
|
|
|
542
552
|
return answer('finished', run ? 'run-finished' : 'no-run', resolvedRun.id, feature, since);
|
|
543
553
|
}
|
|
544
554
|
|
|
545
|
-
module.exports = { workStatus, recordExtension, createWorkCache, refreshedWorkCache, RUN_OPEN_STATES };
|
|
555
|
+
module.exports = { workStatus, recordExtension, createWorkCache, refreshedWorkCache, RUN_OPEN_STATES, isRunNamespaceDir, isSupersededFlatRun };
|
|
@@ -176,9 +176,23 @@ function readRegularFileText(file , fsImpl = fs) {
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/** Whether `slug` is a register feature (it has `decisions/register.json`), for a record file under
|
|
180
|
+
* a checkout's `.doflow/state`. Such a feature never reads another feature's identity-less flat
|
|
181
|
+
* record (045 isolation). A file whose checkout cannot be told, or a feature that does not resolve,
|
|
182
|
+
* has no register. */
|
|
183
|
+
function featureHasRegister(file , slug ) {
|
|
184
|
+
const marker = `${path.sep}.doflow${path.sep}state${path.sep}`;
|
|
185
|
+
const at = file.lastIndexOf(marker);
|
|
186
|
+
if (at <= 0) return false;
|
|
187
|
+
try {
|
|
188
|
+
const found = resolveFeature({ projectRoot: file.slice(0, at), slug });
|
|
189
|
+
return !found.error && Boolean(found.featureDir) && fs.existsSync(path.join(found.featureDir, 'decisions', 'register.json'));
|
|
190
|
+
} catch { return false; }
|
|
191
|
+
}
|
|
192
|
+
|
|
179
193
|
/**
|
|
180
194
|
* Whether a flat record may be read as `slug`'s (D-3): it names that feature (`featureSlug` on a
|
|
181
|
-
* run, `slug` on a readiness record) or names none. A file that does not parse names nobody, so a
|
|
195
|
+
* run, `slug` on a readiness record), or names none and `slug` has no decision register. A file that does not parse names nobody, so a
|
|
182
196
|
* namespaced read never falls back to it. The probe never blocks and follows no link: a flat file
|
|
183
197
|
* that is not a regular file of at most `MAX_RECORD_FILE_BYTES` is not read; `unsafeIsOurs` is then
|
|
184
198
|
* the answer, so a caller whose reader refuses such a file (the run store's `readRunRecord`) is
|
|
@@ -198,7 +212,8 @@ function flatRecordIsOurs(file , slug , fsImpl = fs, u
|
|
|
198
212
|
data = JSON.parse(text);
|
|
199
213
|
} catch { return false; }
|
|
200
214
|
const named = [data?.featureSlug, data?.slug].find((value) => typeof value === 'string' && value !== '');
|
|
201
|
-
|
|
215
|
+
if (named === undefined) return !slug || !featureHasRegister(file, slug);
|
|
216
|
+
return named === slug;
|
|
202
217
|
}
|
|
203
218
|
|
|
204
219
|
/**
|
|
@@ -376,10 +376,14 @@ function acquireLock(fsImpl , file , options = {})
|
|
|
376
376
|
+ strayEntries(fsImpl, lockDir));
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
/**
|
|
379
|
+
/** The non-directory entries in the lock (no DoFlow runtime makes one; each blocks the lock until removed). */
|
|
380
|
+
function strayNames(fsImpl , lockDir ) {
|
|
381
|
+
try { return fsImpl.readdirSync(lockDir, { withFileTypes: true }).filter((d ) => !d.isDirectory()).map((d ) => d.name); } catch { return []; }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `strayNames` as the sentence the lock timeout carries. */
|
|
380
385
|
function strayEntries(fsImpl , lockDir ) {
|
|
381
|
-
|
|
382
|
-
try { stray = fsImpl.readdirSync(lockDir, { withFileTypes: true }).filter((d ) => !d.isDirectory()).map((d ) => d.name); } catch { return ''; }
|
|
386
|
+
const stray = strayNames(fsImpl, lockDir);
|
|
383
387
|
if (stray.length === 0) return '';
|
|
384
388
|
return ` It holds ${stray.map((name) => `'${name}'`).join(', ')}, which no DoFlow runtime makes and which keeps it locked: `
|
|
385
389
|
+ `remove ${stray.length > 1 ? 'them' : 'it'} once no DoFlow process is running.`;
|
|
@@ -389,7 +393,8 @@ function strayEntries(fsImpl , lockDir ) {
|
|
|
389
393
|
* Takes the cross-process lock for one state file with a bounded wait.
|
|
390
394
|
* Makes 1 try plus `retries` retries (default 3) separated by `waitMs` (default 5); each try is one
|
|
391
395
|
* tryAcquireLock, which waits at most 40 ms, and only on a process that reached the lock at the same moment.
|
|
392
|
-
* Throws an Error('store-locked') after one try plus retries
|
|
396
|
+
* Throws an Error('store-locked') after one try plus retries, carrying `lockPath` (the lock folder) and
|
|
397
|
+
* `strayEntries` (the names in it that no DoFlow runtime makes) as properties.
|
|
393
398
|
* @param {Object} fsImpl
|
|
394
399
|
* @param {string} file
|
|
395
400
|
* @param {Object} [options]
|
|
@@ -420,6 +425,8 @@ function boundedAcquire(
|
|
|
420
425
|
}
|
|
421
426
|
const error = new Error('store-locked');
|
|
422
427
|
(error ).code = 'store-locked';
|
|
428
|
+
(error ).lockPath = `${file}.lock`;
|
|
429
|
+
(error ).strayEntries = strayNames(fsImpl, `${file}.lock`);
|
|
423
430
|
throw error;
|
|
424
431
|
}
|
|
425
432
|
|
|
@@ -115,8 +115,10 @@ function latencyHotspots(scopes , th , latency ) {
|
|
|
115
115
|
const groups = new Map ();
|
|
116
116
|
for (const s of sc.spans) {
|
|
117
117
|
if (!Number.isInteger(s.durationMs) || !ranItself(s)) continue;
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
// Every derived `test` span is named 'test'; the command's class tells unrelated shell commands apart.
|
|
119
|
+
const name = s.kind === 'test' && typeof s.attrs?.test_class === 'string' ? s.attrs.test_class : (s.name ?? null);
|
|
120
|
+
const key = `${s.kind}\n${name ?? ''}`;
|
|
121
|
+
if (!groups.has(key)) groups.set(key, { kind: s.kind, name, spans: [] });
|
|
120
122
|
groups.get(key).spans.push(s);
|
|
121
123
|
}
|
|
122
124
|
for (const g of [...groups.values()].sort((a, b) => cmp(a.kind, b.kind) || cmp(a.name ?? '', b.name ?? ''))) {
|
|
@@ -40,27 +40,33 @@ function parseAt(value ) {
|
|
|
40
40
|
return Number.isNaN(ms) ? null : ms;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** Every
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
/** Every run the `list` action shows (flat and namespaced, this checkout and the other ones, each
|
|
44
|
+
* logical run once), plus the ones that could not be read, named with a fixed reason. A file that
|
|
45
|
+
* cannot be read is counted and named rather than skipped: a report that quietly drops what it could
|
|
46
|
+
* not read overstates its own coverage, which is the defect `malformedLines` already guards against
|
|
47
|
+
* for runs. The reason is never the error's own text, which can carry a path or file bytes. */
|
|
48
|
+
function readRecords(root ) {
|
|
49
|
+
const { orchestrationRuns, runEntriesAt, selectRunEntries, runStoreDir } = require('../implementation-gate.js');
|
|
50
|
+
const listed = orchestrationRuns({ stateRoot: root });
|
|
49
51
|
const unreadable = [];
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
unreadable.push({ file: name, reason:
|
|
52
|
+
let entries ;
|
|
53
|
+
if (listed.ok) entries = selectRunEntries(listed.entries);
|
|
54
|
+
else {
|
|
55
|
+
entries = selectRunEntries(runEntriesAt(root, 'current'));
|
|
56
|
+
unreadable.push({ file: '(other checkouts)', reason: 'could not be listed' });
|
|
57
|
+
}
|
|
58
|
+
const records = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const name = entry.origin === 'other' ? entry.file : path.relative(runStoreDir(entry.root), entry.file);
|
|
61
|
+
if (entry.error) {
|
|
62
|
+
unreadable.push({ file: name, reason: entry.error === 'io' ? 'not a regular file' : 'not valid JSON' });
|
|
63
|
+
} else if (!entry.record || typeof entry.record !== 'object' || !entry.record.taskId) {
|
|
64
|
+
unreadable.push({ file: name, reason: 'not an orchestration record' });
|
|
65
|
+
} else {
|
|
66
|
+
records.push(entry.record);
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
|
-
return { records, unreadable, exists:
|
|
69
|
+
return { records, unreadable, exists: entries.length > 0 || fs.existsSync(orchestrationDir(root)) };
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
/** A stage row is "not work done" when the orchestrator backfilled it or imported it rather than
|
|
@@ -196,8 +202,7 @@ function printClass(group ) {
|
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
function handleIndicatorsCommand({ json = false, projectRoot = process.cwd() } = {}) {
|
|
199
|
-
const
|
|
200
|
-
const read = readRecords(dir);
|
|
205
|
+
const read = readRecords(projectRoot);
|
|
201
206
|
const view = buildIndicators(read);
|
|
202
207
|
|
|
203
208
|
if (json) {
|
|
@@ -9,6 +9,7 @@ const { resolveRunsLocation, normalizeRunRecord } = require('./ledger.js');
|
|
|
9
9
|
const { SAFE_TOKEN } = require('./telemetry.js');
|
|
10
10
|
const { finish } = require('./render.js');
|
|
11
11
|
const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
12
|
+
const { isRunNamespaceDir, isSupersededFlatRun } = require('../storage/work-status.js');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `trace spans` (088 IC-011, FR-005, DD-5): spans projected offline from the records that already
|
|
@@ -63,7 +64,6 @@ const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
|
63
64
|
|
|
64
65
|
const PARTITION = /^\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
65
66
|
const STORE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
|
66
|
-
const NAMESPACE_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
67
67
|
const HEX16 = /^[0-9a-f]{16}$/;
|
|
68
68
|
const HEX32 = /^[0-9a-f]{32}$/;
|
|
69
69
|
const WORKER_RUN_ID = /^w-[0-9a-f]{12}$/;
|
|
@@ -201,15 +201,14 @@ function readStores(fsImpl , stateDir , rel , gaps , kin
|
|
|
201
201
|
if (!namespaced) return out.map(({ file, value }) => ({ file, value }));
|
|
202
202
|
let entries = [];
|
|
203
203
|
try { entries = fsImpl.readdirSync(base, { withFileTypes: true }); } catch { entries = []; }
|
|
204
|
-
const namespaces = entries.filter((entry ) => entry.isDirectory() &&
|
|
204
|
+
const namespaces = entries.filter((entry ) => entry.isDirectory() && isRunNamespaceDir(entry.name))
|
|
205
205
|
.map((entry ) => entry.name).sort();
|
|
206
206
|
for (const namespace of namespaces) {
|
|
207
207
|
const dir = path.join(base, namespace);
|
|
208
208
|
for (const name of listDir(fsImpl, dir, STORE_FILE)) read(`${rel}/${namespace}/${name}`, name, dir, namespace);
|
|
209
209
|
}
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
return out.filter((entry) => entry.namespace !== '' || !copied(entry)).map(({ file, value }) => ({ file, value }));
|
|
210
|
+
const copies = out.filter((other) => other.namespace !== '');
|
|
211
|
+
return out.filter((entry) => entry.namespace !== '' || !isSupersededFlatRun(entry.name, entry.value, copies)).map(({ file, value }) => ({ file, value }));
|
|
213
212
|
}
|
|
214
213
|
|
|
215
214
|
const objects = (v ) => (v && typeof v === 'object' && !Array.isArray(v)
|
package/src/runtime/verb-view.js
CHANGED
|
@@ -87,7 +87,8 @@ function projectValue(value , node ) {
|
|
|
87
87
|
let part;
|
|
88
88
|
if (child.array) {
|
|
89
89
|
part = Array.isArray(value[key])
|
|
90
|
-
? value[key].map((element ) => (child.node.all
|
|
90
|
+
? value[key].map((element ) => (child.node.all || element === null || typeof element !== 'object' || Array.isArray(element)
|
|
91
|
+
? element : (projectValue(element, child.node) ?? {})))
|
|
91
92
|
: undefined;
|
|
92
93
|
} else {
|
|
93
94
|
part = projectValue(value[key], child.node);
|
|
@@ -115,7 +116,7 @@ const DIGITS = /^[0-9]+$/;
|
|
|
115
116
|
|
|
116
117
|
function encodeCursor(key ) {
|
|
117
118
|
if (!Number.isSafeInteger(key.atMs) || key.atMs < 0 || String(key.atMs).length > 15) {
|
|
118
|
-
throw new
|
|
119
|
+
throw new ViewUsageError(`cursor time must be 0 to 15 digits of milliseconds (got ${key.atMs})`);
|
|
119
120
|
}
|
|
120
121
|
return `${key.atMs}:${key.event}`;
|
|
121
122
|
}
|
|
@@ -197,8 +198,8 @@ function commandLine(verb , action , carried
|
|
|
197
198
|
* are those the spec's `carry` names, in that order, then `--cursor`, `--limit`, `--view` (a `page` line names
|
|
198
199
|
* `--view full` only when the caller's `view` is `full`). Returns no line when
|
|
199
200
|
* the kind has nothing to point at (`page` without a nextCursor, `page-full` with nothing shown). In a JSON answer
|
|
200
|
-
* (`json: true`)
|
|
201
|
-
*
|
|
201
|
+
* (`json: true`) every line ends with `--json`, so the command as written prints the same JSON shape; a text
|
|
202
|
+
* answer's lines carry no `--json`.
|
|
202
203
|
*/
|
|
203
204
|
function viewLines(kind , ctx
|
|
204
205
|
|
|
@@ -222,7 +223,7 @@ function viewLines(kind , ctx
|
|
|
222
223
|
} else {
|
|
223
224
|
args.view = 'full';
|
|
224
225
|
}
|
|
225
|
-
const asJson = ctx.json === true
|
|
226
|
+
const asJson = ctx.json === true ? ' --json' : '';
|
|
226
227
|
return [`${LABELS[kind]}: ${commandLine(ctx.spec.verb, ctx.spec.action, args)}${asJson}`];
|
|
227
228
|
}
|
|
228
229
|
|
|
@@ -285,7 +285,7 @@ function evaluate({ reg, worker, harness, needs, coverage, env, cwd, root, nowMs
|
|
|
285
285
|
nowMs,
|
|
286
286
|
);
|
|
287
287
|
const requirements = readiness.requirementsFor({ harness, worker }, facts, needs, coverage, edit);
|
|
288
|
-
return { requirements, version: found.version, ...readiness.eligibility(requirements) };
|
|
288
|
+
return { requirements, version: found.version, binaryPath: found.path, ...readiness.eligibility(requirements) };
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
/** IC-011 steps 0 to 12, then the supervisor spawn and the start poll. */
|
|
@@ -365,7 +365,9 @@ async function runAction(o ) {
|
|
|
365
365
|
edit: editRecord, childOf, request: childOf ? o.request : null,
|
|
366
366
|
...launchRecord(worker, identity, prompt.bytes.toString('utf8'), edit, binding),
|
|
367
367
|
...(binding ? { binding: { model: binding.model, flag: binding.flag } } : {}),
|
|
368
|
-
|
|
368
|
+
// The binary the probe found, as an absolute path: the supervisor spawns from the execution worktree,
|
|
369
|
+
// where a relative PATH entry would name another file than the one probed.
|
|
370
|
+
descriptor: evaluated.binaryPath && worker?.cli ? { ...worker, cli: { ...worker.cli, binary: evaluated.binaryPath } } : worker,
|
|
369
371
|
prompt: { path: prompt.path, sha256: prompt.sha256, bytes: prompt.bytes.length },
|
|
370
372
|
needs: evaluated.requirements.map((item ) => ({ requirement: item.requirement, at: item.at ?? null })),
|
|
371
373
|
};
|
|
@@ -78,9 +78,13 @@ function workerDescriptors({ descriptors, registry, view } = {})
|
|
|
78
78
|
return ids.filter((id) => Object.hasOwn(descriptors, id)).map((id) => ({ harness: id, worker: descriptors[id] }));
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The first executable `binary` on `env.PATH`, or null. Starts no process. A relative PATH entry is
|
|
83
|
+
* read against `cwd` (the caller's directory) when one is given, so the path found is absolute and
|
|
84
|
+
* names the same file whichever directory a later process starts in.
|
|
85
|
+
*/
|
|
86
|
+
function findOnPath(binary , env , platform = process.platform, cwd ) {
|
|
87
|
+
const dirs = String(env?.PATH ?? '').split(path.delimiter).filter(Boolean).map((dir) => (cwd ? path.resolve(cwd, dir) : dir));
|
|
84
88
|
const suffixes = platform === 'win32'
|
|
85
89
|
? ['', ...String(env?.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)]
|
|
86
90
|
: [''];
|
|
@@ -116,7 +120,7 @@ function discoverOne({ harness, worker } , { env, cwd, now, platform } )
|
|
|
116
120
|
if (!supported(cli)) {
|
|
117
121
|
return { harness, binary: null, present: false, path: null, version: null, reason: 'cli-unknown', at: iso(now()) };
|
|
118
122
|
}
|
|
119
|
-
const found = findOnPath(cli.binary, env, platform);
|
|
123
|
+
const found = findOnPath(cli.binary, env, platform, cwd);
|
|
120
124
|
if (!found) return { harness, binary: cli.binary, present: false, path: null, version: null, reason: 'not-on-path', at: iso(now()) };
|
|
121
125
|
const execFileImpl = (bin , args , options ) => execFileSync(bin, args, { ...options, env, cwd });
|
|
122
126
|
const result = runProbe([found, ...cli.versionArgv], { execFileImpl, timeoutMs: PROBE_TIMEOUT_MS });
|
|
@@ -321,22 +321,33 @@ function boundedWait(promise , ms ) {
|
|
|
321
321
|
return Promise.race([promise, bound]).finally(() => clearTimeout(timer));
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
/**
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
/** What masking did to a retained final message, in the run record beside the stdout and stderr pointers. */
|
|
325
|
+
const ALTERED_WARNING = 'masking altered this final message: it is not suitable for byte-exact patch application; apply the worker\'s changes from its isolated worktree, not from this text';
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Reads only the bounded Codex output-last-message prefix; absent or unreadable files are optional.
|
|
329
|
+
* Returns the pointer the run record keeps (`path` relative to `stateRoot`, the masked `bytes`,
|
|
330
|
+
* whether masking `altered` the text and, when it did, the `warning`), or null when no file was read.
|
|
331
|
+
*/
|
|
332
|
+
function readLastMessage(parser , file , stateRoot ) {
|
|
333
|
+
if (typeof parser.lastMessage !== 'function') return null;
|
|
327
334
|
let fd ;
|
|
328
335
|
try {
|
|
329
336
|
fd = fs.openSync(file, 'r');
|
|
330
337
|
const bytes = Buffer.alloc(65536);
|
|
331
338
|
const length = fs.readSync(fd, bytes, 0, bytes.length, 0);
|
|
332
|
-
const
|
|
339
|
+
const raw = bytes.subarray(0, length).toString('utf8');
|
|
340
|
+
const masked = maskBody(raw).text;
|
|
333
341
|
parser.lastMessage(masked);
|
|
334
342
|
// NFR-004: the retained copy holds only the masked, bounded text, never the raw worker output.
|
|
335
343
|
fs.closeSync(fd);
|
|
336
344
|
fd = undefined;
|
|
337
|
-
try { fs.writeFileSync(file, masked, 'utf8'); } catch { fs.rmSync(file, { force: true }); }
|
|
345
|
+
try { fs.writeFileSync(file, masked, 'utf8'); } catch { fs.rmSync(file, { force: true }); return null; }
|
|
346
|
+
const altered = masked !== raw;
|
|
347
|
+
return { path: path.relative(stateRoot, file).split(path.sep).join('/'), bytes: Buffer.byteLength(masked), altered, ...(altered ? { warning: ALTERED_WARNING } : {}) };
|
|
338
348
|
} catch {
|
|
339
349
|
// Codex may not have produced this optional file; that does not change the run outcome.
|
|
350
|
+
return null;
|
|
340
351
|
} finally {
|
|
341
352
|
if (fd !== undefined) {
|
|
342
353
|
try { fs.closeSync(fd); } catch { /* unreadable/closed files are ignored */ }
|
|
@@ -509,7 +520,8 @@ async function superviseRun({
|
|
|
509
520
|
readOnly = { status: 'unverifiable', changes: [], head: { before: before.head, after: null }, refsChanged: null };
|
|
510
521
|
}
|
|
511
522
|
}
|
|
512
|
-
|
|
523
|
+
const lastMessage = lastMessageFile ? readLastMessage(parser, lastMessageFile, stateRoot) : null;
|
|
524
|
+
if (lastMessage) (output ).lastMessage = lastMessage;
|
|
513
525
|
const report = parser.end();
|
|
514
526
|
// 106 IC-002: a failure whose masked summary holds a declared quota signal is marked quota.
|
|
515
527
|
const quota = quotaOf(report, run.descriptor);
|
|
@@ -484,9 +484,13 @@ class WorkflowOrchestrator {
|
|
|
484
484
|
},
|
|
485
485
|
harnessName: displayName,
|
|
486
486
|
since,
|
|
487
|
-
addCommand: (server ) => {
|
|
487
|
+
addCommand: (server , harnessId = null) => {
|
|
488
488
|
const reach = reachOf();
|
|
489
|
-
const
|
|
489
|
+
const has = (item ) => ['selected', 'configured'].includes(item.servers[server]);
|
|
490
|
+
// The brief's own harness is where the server was missing; another harness having it says nothing.
|
|
491
|
+
const target = harnessId ? reach?.harnesses.find((item ) => item.id === harnessId) : null;
|
|
492
|
+
if (target) return has(target) ? null : reach.addCommand(target.id, target.scope, server);
|
|
493
|
+
const row = reach?.harnesses.find((item ) => !has(item));
|
|
490
494
|
return row ? reach.addCommand(row.id, row.scope, server) : null;
|
|
491
495
|
},
|
|
492
496
|
});
|