@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
|
@@ -39,6 +39,7 @@ const { isSafeSlug, invalidSlugRefusal } = require('../task-scope.js');
|
|
|
39
39
|
const { appendEvents, readFold } = require('./event-store.js');
|
|
40
40
|
const { deriveStatuses, readGitFacts, behindNote, subjectNames, readMerges, readBranchTips, readHistory } = require('./status.js');
|
|
41
41
|
const { FollowupUsageError, oneLine, channelBy } = require('./followup.js');
|
|
42
|
+
const { parseSemVer, compareSemVer } = require('../../helper/semver.js');
|
|
42
43
|
|
|
43
44
|
/** The release-tag pattern of IC-020, the one `git-state` filters by. */
|
|
44
45
|
const RELEASE_TAG = /^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/;
|
|
@@ -125,13 +126,25 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
125
126
|
return refusal('release', 'untracked-feature', `${untracked.join(', ')} ${untracked.length === 1 ? 'is' : 'are'} not tracked, so ${untracked.length === 1 ? 'it' : 'they'} cannot be added to or excluded from a release; run doflow-run lifecycle --action init --slug <slug> first. Nothing was written.`);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
// DEC-030: containment in the tag being recorded never finishes a feature before its record exists.
|
|
129
|
-
|
|
129
|
+
// DEC-030: containment in the tag being recorded never finishes a feature before its record exists. Of the
|
|
130
|
+
// other tags only an unrecorded one that is stable and earlier than `tag` (lower version, or a strict ancestor of it)
|
|
131
|
+
// may take a feature out of this release: a pre-release (v1.0.0-rc.1 before v1.0.0) or a later tag
|
|
132
|
+
// (v2.0.0 when recording v1.0.0) does not. A tag with a record is left in: status reads it by its record.
|
|
133
|
+
const tagCommit = commitOf(root, `refs/tags/${tag}`);
|
|
134
|
+
const recordedTags = new Set(fold.releases.map((r ) => r.tag));
|
|
135
|
+
const shadows = (name ) => {
|
|
136
|
+
const parsed = parseSemVer(name.slice(1));
|
|
137
|
+
if (!parsed || parsed.prerelease.length > 0) return false;
|
|
138
|
+
if (compareSemVer(name.slice(1), tag.slice(1)) < 0) return true;
|
|
139
|
+
// A strict ancestor only: a higher-versioned tag on the very same commit is not earlier.
|
|
140
|
+
const commit = tagCommit && commitOf(root, `refs/tags/${name}`);
|
|
141
|
+
return Boolean(commit) && commit !== tagCommit && isAncestor(root, commit, tagCommit);
|
|
142
|
+
};
|
|
143
|
+
const otherTags = { ...facts, release_tags: facts.release_tags.filter((name ) => name !== tag && (recordedTags.has(name) || shadows(name))) };
|
|
130
144
|
const derived = deriveStatuses({ root, fold, facts: otherTags });
|
|
131
145
|
if (derived.releaseMode === 'unknown' || !derived.integrationSha) return refusal('release', 'no-integration-ref', `${derived.reason || `${facts.integration_ref} does not resolve to a commit`}. Nothing was written.`);
|
|
132
146
|
|
|
133
147
|
// X: the tag's commit once it exists, else the commit the status view pinned the integration ref to (DEC-044).
|
|
134
|
-
const tagCommit = commitOf(root, `refs/tags/${tag}`);
|
|
135
148
|
const bound = tagCommit || derived.integrationSha;
|
|
136
149
|
const previousTag = previousTagOf(root, { tag, facts, bound });
|
|
137
150
|
// A hotfix merged only into the tag's own history has no evidence on the integration chain: derive against the tag as well.
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
const nodeFs = require('node:fs');
|
|
20
20
|
const { isDeepStrictEqual } = require('node:util');
|
|
21
|
-
const { loadSettings } = require('../settings/resolve.js');
|
|
21
|
+
const { loadSettings, registryOn } = require('../settings/resolve.js');
|
|
22
22
|
const { foldInto, finalize, EVENT_TYPES } = require('./fold.js');
|
|
23
23
|
|
|
24
24
|
const HOUR_MS = 3600000;
|
|
@@ -53,14 +53,7 @@ function retentionWindow({ env = process.env, projectRoot, fsImpl = nodeFs }
|
|
|
53
53
|
const parsed = parseWindow(env);
|
|
54
54
|
if (parsed.state === 'invalid') return parsed;
|
|
55
55
|
try {
|
|
56
|
-
|
|
57
|
-
if (fsImpl !== nodeFs) {
|
|
58
|
-
const { loadSettingsRegistry } = require('../settings/registry.js');
|
|
59
|
-
const loaded = loadSettingsRegistry({ fsImpl });
|
|
60
|
-
if (!loaded.ok) throw new Error(loaded.detail);
|
|
61
|
-
settingsRegistry = loaded.registry;
|
|
62
|
-
}
|
|
63
|
-
const view = loadSettings({ projectRoot, env, fsImpl, ...(settingsRegistry ? { registry: settingsRegistry } : {}) });
|
|
56
|
+
const view = loadSettings({ projectRoot, env, fsImpl, ...registryOn(fsImpl) });
|
|
64
57
|
const retention = view.get('storage.retentionHours', 'project');
|
|
65
58
|
if (retention.state === 'invalid') return { state: 'invalid', raw: invalidRaw(retention, env) };
|
|
66
59
|
if (retention.explicit) {
|
|
@@ -136,10 +129,12 @@ function unitsOf(events , shouldStop ) {
|
|
|
136
129
|
}
|
|
137
130
|
|
|
138
131
|
/**
|
|
139
|
-
* Every string value and every object key
|
|
140
|
-
* does not know (a newer DoFlow wrote them). The fold ignores them, so retention
|
|
141
|
-
*
|
|
142
|
-
*
|
|
132
|
+
* Every string value in the envelope, and every object key inside it, of the events whose type this
|
|
133
|
+
* version's fold does not know (a newer DoFlow wrote them). The fold ignores them, so retention
|
|
134
|
+
* cannot tell what they change; a unit that one of them names, by item id, event id or event file
|
|
135
|
+
* name, is kept whole rather than deleted from under it. The envelope's own field names are its
|
|
136
|
+
* shape, not references. Exact matches only: keeping too little is a lost record, keeping too much
|
|
137
|
+
* only a file that stays.
|
|
143
138
|
* @returns {Set<string>|null} null when `shouldStop` fired
|
|
144
139
|
*/
|
|
145
140
|
function unknownTypeStrings(events , shouldStop ) {
|
|
@@ -148,7 +143,7 @@ function unknownTypeStrings(events , shouldStop )
|
|
|
148
143
|
for (const event of events) {
|
|
149
144
|
if (shouldStop?.()) return null;
|
|
150
145
|
if (known.has(event.type)) continue;
|
|
151
|
-
const pending =
|
|
146
|
+
const pending = Object.values(event);
|
|
152
147
|
while (pending.length > 0) {
|
|
153
148
|
if (shouldStop?.()) return null;
|
|
154
149
|
const value = pending.pop();
|
|
@@ -260,7 +255,9 @@ function selectExpired(events , { now, windowMs, shouldStop } = {})
|
|
|
260
255
|
};
|
|
261
256
|
const baseSelected = safeSubset(candidates);
|
|
262
257
|
if (!baseSelected) return incomplete();
|
|
263
|
-
const
|
|
258
|
+
const namedByUnknown = (unit ) => [...unit.items].some((key ) => mentioned.has(key.slice(key.indexOf(':') + 1)))
|
|
259
|
+
|| unit.events.some((event ) => mentioned.has(event.id) || mentioned.has(`${event.id}.json`));
|
|
260
|
+
const unprotected = baseSelected.filter((unit ) => !namedByUnknown(unit));
|
|
264
261
|
// Dropping units can leave a set the refold no longer accepts; check it again, inside the subset.
|
|
265
262
|
const selected = unprotected.length === baseSelected.length ? baseSelected : safeSubset(unprotected);
|
|
266
263
|
if (!selected) return incomplete();
|
|
@@ -342,9 +342,9 @@ function retainLocked(root , options = {}) {
|
|
|
342
342
|
if (!release) return outcome(0, [], true);
|
|
343
343
|
try {
|
|
344
344
|
const swept = sweepStaleTmp(fsImpl, root, now, staleTmpMs, { maxFiles, shouldStop });
|
|
345
|
-
if (swept.partial) return outcome(
|
|
345
|
+
if (swept.partial) return outcome(swept.removed, [], true);
|
|
346
346
|
const result = retain(fsImpl, root, window, now, { maxFiles: Math.max(0, maxFiles - swept.removed), shouldStop });
|
|
347
|
-
return outcome(result.removedCount, result.warning, result.partial, result.selected, result.code);
|
|
347
|
+
return outcome(swept.removed + result.removedCount, result.warning, result.partial, result.selected, result.code);
|
|
348
348
|
} finally {
|
|
349
349
|
release();
|
|
350
350
|
}
|
|
@@ -358,6 +358,16 @@ function needsPrune(fsImpl , root , window , now ) {
|
|
|
358
358
|
return selectExpired(readEvents(root, { fsImpl }).events, { now, windowMs: window.ms }).files.length > 0;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Whether the old `agent-docs/lifecycle` copy still waits to be copied. Read-only (no lock, no
|
|
363
|
+
* write, `DOFLOW_UPKEEP` not consulted), for `doctor`; an unreadable folder counts as not pending.
|
|
364
|
+
*/
|
|
365
|
+
function migrationPending(root , { fsImpl = nodeFs } = {}) {
|
|
366
|
+
try {
|
|
367
|
+
return !hasMarker(fsImpl, root) && !storeHasEvents(fsImpl, root) && legacyEventFiles(fsImpl, root).length > 0;
|
|
368
|
+
} catch { return false; }
|
|
369
|
+
}
|
|
370
|
+
|
|
361
371
|
/**
|
|
362
372
|
* Prepares the project store for one verb invocation.
|
|
363
373
|
* @param {string} root projectRoot() result
|
|
@@ -439,4 +449,4 @@ function prepareStore(root , { env = process.env, now = new Date(), fsImp
|
|
|
439
449
|
}
|
|
440
450
|
}
|
|
441
451
|
|
|
442
|
-
module.exports = { prepareStore, retainLocked, removedLine, STALE_TMP_MS, LEGACY_LIFECYCLE_REL, LEGACY_EVENTS_REL, MARKER_REL };
|
|
452
|
+
module.exports = { prepareStore, migrationPending, retainLocked, removedLine, STALE_TMP_MS, LEGACY_LIFECYCLE_REL, LEGACY_EVENTS_REL, MARKER_REL };
|
package/src/runtime/mask.js
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* Rules run in the table's order. Normalisation (rules 10 to 13) is a separate step applied to
|
|
13
13
|
* failure messages only, after masking.
|
|
14
14
|
*
|
|
15
|
-
* The module has no I/O and no state; the home directory is read from `HOME`
|
|
16
|
-
* passes one.
|
|
15
|
+
* The module has no I/O and no state; the home directory is read from `HOME` (`USERPROFILE` when
|
|
16
|
+
* `HOME` is absent or empty, as on Windows) unless the caller passes one.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
const MASKED = '<masked>';
|
|
@@ -152,7 +152,7 @@ function escapeRegExp(text ) { return text.replace(/[.*+?^${}()|[
|
|
|
152
152
|
|
|
153
153
|
/** The home directory to abbreviate: an absolute path other than the file system root. */
|
|
154
154
|
function homeOf(options ) {
|
|
155
|
-
const raw = options && 'home' in options ? options.home : process.env.HOME;
|
|
155
|
+
const raw = options && 'home' in options ? options.home : (process.env.HOME || process.env.USERPROFILE);
|
|
156
156
|
if (typeof raw !== 'string' || !(raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw))) return null;
|
|
157
157
|
const home = raw.replace(/[\\/]+$/, '');
|
|
158
158
|
return home.length > 1 ? home : null;
|
|
@@ -230,14 +230,15 @@ const bySortKey = (a , b ) => (a < b ? -1 : a > b ? 1 : 0);
|
|
|
230
230
|
* Injected readers (all optional; a missing one contributes nothing):
|
|
231
231
|
* - `ledger.queryEvidence({taskId})` -> evidence items (active ones count)
|
|
232
232
|
* - `requests.list(taskId)` -> research requests
|
|
233
|
-
* - `briefRecords` -> IC-018 records already parsed, each `{ runTaskId, stage, tasks, expected }`
|
|
233
|
+
* - `briefRecords` -> IC-018 records already parsed, each `{ runTaskId, stage, createdAt, tasks, expected }`
|
|
234
234
|
* - `readReport(path)` -> report text, or null when the file is missing
|
|
235
235
|
* - `reach.anyHarnessHas(server)` -> true | false | null (unknown); never decides an outcome
|
|
236
236
|
* - `reach.harnessWith(server)` -> the display name of a harness that has the server, or null
|
|
237
237
|
* - `harnessName(id)` -> a harness id's display name (for a brief record's `harness`)
|
|
238
|
-
* - `addCommand(server)` -> the text after "add it:" in the unavailable notice
|
|
238
|
+
* - `addCommand(server, harnessId)` -> the text after "add it:" in the unavailable notice; `harnessId` is the harness
|
|
239
|
+
* of the brief that found the server unreachable, or null when no brief names one
|
|
239
240
|
* - `since` -> ISO time of the previous stage's completion (or the run start); rule 3 counts only
|
|
240
|
-
* evidence observed after it
|
|
241
|
+
* evidence observed after it, and only brief records created after it
|
|
241
242
|
*
|
|
242
243
|
* @returns {{ record: object, warnings: object[], notices: object[], evidenceToAdd: object[] }}
|
|
243
244
|
*/
|
|
@@ -266,8 +267,11 @@ function resolveConsultations({
|
|
|
266
267
|
// ingested (design A6).
|
|
267
268
|
const toolsUsed = [];
|
|
268
269
|
const fromReports = new Map ();
|
|
270
|
+
// A stage visited twice leaves briefs from the earlier visit: when `since` is known only a brief created
|
|
271
|
+
// after it counts, and a record with no creation time (written before it was stamped) cannot be placed.
|
|
269
272
|
const records = (Array.isArray(briefRecords) ? briefRecords : [])
|
|
270
|
-
.filter((record ) => record && stageId && taskId && record.stage === stageId && record.runTaskId === taskId
|
|
273
|
+
.filter((record ) => record && stageId && taskId && record.stage === stageId && record.runTaskId === taskId
|
|
274
|
+
&& (since === null || (typeof record.createdAt === 'string' && record.createdAt > since)));
|
|
271
275
|
const reports = [];
|
|
272
276
|
for (const record of records) {
|
|
273
277
|
for (const task of record.tasks || []) {
|
|
@@ -347,10 +351,12 @@ function resolveConsultations({
|
|
|
347
351
|
// brief writes `unreachable` only where every config the harness merges was read (IC-017).
|
|
348
352
|
const unreachable = new Map ();
|
|
349
353
|
const reachableOn = new Map ();
|
|
354
|
+
const unreachableOn = new Map ();
|
|
350
355
|
for (const record of records) {
|
|
351
356
|
for (const expected of record.expected || []) {
|
|
352
357
|
if (!expected || typeof expected.server !== 'string') continue;
|
|
353
358
|
if (expected.reach === 'unreachable' && !unreachable.has(expected.server)) unreachable.set(expected.server, record.tasks?.[0]?.id);
|
|
359
|
+
if (expected.reach === 'unreachable' && !unreachableOn.has(expected.server)) unreachableOn.set(expected.server, record.harness ?? null);
|
|
354
360
|
if (expected.reach === 'reachable' && !reachableOn.has(expected.server)) reachableOn.set(expected.server, record.harness ?? null);
|
|
355
361
|
}
|
|
356
362
|
}
|
|
@@ -415,7 +421,7 @@ function resolveConsultations({
|
|
|
415
421
|
text: `[WARN] mcp: ${server} reported unavailable but ${has} has it configured`,
|
|
416
422
|
});
|
|
417
423
|
}
|
|
418
|
-
const add = !has && typeof addCommand === 'function' ? addCommand(server) : null;
|
|
424
|
+
const add = !has && typeof addCommand === 'function' ? addCommand(server, unreachableOn.get(server) ?? null) : null;
|
|
419
425
|
notices.push({
|
|
420
426
|
kind: 'unavailable', server,
|
|
421
427
|
text: `[INFO] mcp: ${server} unavailable to ${skill}${add ? `; add it: ${add}` : ''}`,
|
|
@@ -95,7 +95,9 @@ class ResearchRequestStore {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
stageFor(taskId , stageId ) {
|
|
98
|
-
|
|
98
|
+
// The run is read the way every run read is: no link followed, no FIFO or device opened (FU-d88kp0).
|
|
99
|
+
const { readRunRecord } = require('./implementation-gate.js');
|
|
100
|
+
const run = readRunRecord(this.fsImpl, this.runFile(taskId), path.join(this.projectRoot, '.doflow', 'state', 'orchestration'));
|
|
99
101
|
if (!run || run.taskClass !== 'feature' || run.state !== 'RUNNING') {
|
|
100
102
|
throw new Error(`Task '${taskId}' has no active feature stage`);
|
|
101
103
|
}
|
|
@@ -25,15 +25,10 @@ const TELEMETRY_COUNT_KEYS = Object.freeze([
|
|
|
25
25
|
'plausible', 'unverified_blockers', 'bug_classes',
|
|
26
26
|
]);
|
|
27
27
|
|
|
28
|
-
// Inherited location variables would point the check at another repository (C-8).
|
|
29
|
-
const GIT_LOCATION_KEYS = Object.freeze([
|
|
30
|
-
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_COMMON_DIR',
|
|
31
|
-
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
28
|
function defaultGit(args , cwd , env ) {
|
|
35
29
|
const gitEnv = { ...env };
|
|
36
|
-
|
|
30
|
+
// Inherited location variables would point the check at another repository (C-8); the list is verification's.
|
|
31
|
+
for (const key of require('../dispatch/verify-run.js').REPO_ENV) delete gitEnv[key];
|
|
37
32
|
return spawnSync('git', ['-C', cwd, ...args], { cwd, env: gitEnv, stdio: 'ignore' });
|
|
38
33
|
}
|
|
39
34
|
|
|
@@ -112,6 +107,7 @@ function handleReviewCommand({
|
|
|
112
107
|
}
|
|
113
108
|
return handleReviewStart({ slug: hasValue(slug) ? slug : null, base: hasValue(base) ? base : null, preset: hasValue(preset) ? preset : null, json, cwd, env, resolve, adviceClient, out });
|
|
114
109
|
}
|
|
110
|
+
if (Array.isArray(recordPath)) return usage('--path may be given only once', json, out);
|
|
115
111
|
if (hasValue(base)) return usage('--base is valid only with --action start', json, out);
|
|
116
112
|
if (hasValue(preset)) return usage('--preset is valid only with --action start', json, out);
|
|
117
113
|
if (hasValue(slug) && hasValue(recordPath)) return usage('use either --slug or --path, not both', json, out);
|
|
@@ -109,15 +109,16 @@ function readBounded(file , maxBytes )
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function parseRecordLines(file )
|
|
112
|
+
function parseRecordLines(file ) {
|
|
113
113
|
const read = readBounded(file, MAX_RECORD_BYTES);
|
|
114
114
|
if ('error' in read) return { error: read.error };
|
|
115
|
+
const text = 'text' in read ? read.text : '';
|
|
115
116
|
const records = [];
|
|
116
|
-
for (const [index, line] of
|
|
117
|
+
for (const [index, line] of text.split('\n').entries()) {
|
|
117
118
|
if (!line.trim()) continue;
|
|
118
119
|
try { records.push(JSON.parse(line)); } catch (error ) { return { error: `line ${index + 1}: ${error.message}` }; }
|
|
119
120
|
}
|
|
120
|
-
return { records };
|
|
121
|
+
return { records, text };
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
|
|
@@ -243,20 +244,23 @@ function loadGatePolicy(cwd )
|
|
|
243
244
|
* is on, so the class seat floor binds; maint-thin), the gate policy (the shipped gates.json narrowed by the
|
|
244
245
|
* project's `.doflow/gates.override.json`, read as roadmap gate-b reads it, 119 IC-005; null when there is no task class, so
|
|
245
246
|
* no floor), the protocols registry and the enforcement inputs (`loadEnforcementInputs`). A base, override or registry the loaders reject is a refusal carrying the loader's
|
|
246
|
-
* reason (and the override file). Shared with the `protocol` verb.
|
|
247
|
+
* reason (and the override file). Shared with the `protocol` verb. `replaced` names the settings a prepared run
|
|
248
|
+
* (`config validate --prepared-run`) writes to the project layer: an invalid project candidate of such a key is
|
|
249
|
+
* replaced by it, so it does not refuse here.
|
|
247
250
|
*/
|
|
248
|
-
function loadProtocolInputs(taskClass , cwd , env = process.env)
|
|
251
|
+
function loadProtocolInputs(taskClass , cwd , env = process.env, replaced = new Set())
|
|
249
252
|
{
|
|
250
253
|
const settingsView = {};
|
|
251
254
|
try {
|
|
252
255
|
const settings = require('../settings/resolve.js').loadSettings({ projectRoot: path.resolve(cwd), env });
|
|
253
256
|
for (const key of PROTOCOL_SETTINGS) {
|
|
254
257
|
const resolution = settings.get(key);
|
|
255
|
-
// C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead
|
|
256
|
-
// A preset key refuses an invalid user candidate too.
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// C-6: an invalid project or local review.* candidate would silently fall back to the default: refuse instead,
|
|
259
|
+
// except a project candidate that a prepared run replaces. A preset key refuses an invalid user candidate too.
|
|
260
|
+
const judged = replaced.has(key) ? { ...resolution, candidates: (resolution.candidates ?? []).filter((c ) => c.layer !== 'project') } : resolution;
|
|
261
|
+
const stands = key.endsWith('.preset') ? invalidPresetCandidate(judged)
|
|
262
|
+
: key.startsWith('review.') ? invalidProjectCandidate(judged) : undefined;
|
|
263
|
+
if (stands) return { error: 'settings-unavailable', message: `${key} in ${stands.source}: ${stands.reason}` };
|
|
260
264
|
settingsView[key] = { value: resolution.value, layer: resolution.origin.layer };
|
|
261
265
|
}
|
|
262
266
|
} catch (error ) {
|
|
@@ -429,12 +433,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
|
|
|
429
433
|
result.applicable = result.notApplicable === null;
|
|
430
434
|
|
|
431
435
|
let nextCommand = null;
|
|
436
|
+
let seen = null;
|
|
432
437
|
if (result.applicable) {
|
|
433
438
|
const state = readHeadState(cwd, env);
|
|
434
439
|
result.head = state.head;
|
|
435
440
|
result.clean = state.clean;
|
|
436
441
|
const parsed = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
|
|
437
442
|
if ('error' in parsed) return refuse('records-unreadable', parsed.error);
|
|
443
|
+
seen = { head: state.head, clean: state.clean, text: parsed.text };
|
|
438
444
|
result.reasons = reviewPrecondition({ records: parsed.records, head: state.head, clean: state.clean, slug: verifyId });
|
|
439
445
|
if (result.reasons.length) {
|
|
440
446
|
result.ok = false;
|
|
@@ -507,6 +513,14 @@ async function handleReviewStart({ slug, base, preset, json = false, cwd, env, r
|
|
|
507
513
|
const decided = resolveProtocol({ ...facts, advicePick: null });
|
|
508
514
|
if (!decided.ok) return refuseResolution(decided.reason);
|
|
509
515
|
const advice = await askAdvice({ decision: { depth: decided.protocol.depth, scope }, risk: result.risk, taskClass, slug: resolvedSlug, files: paths ? paths.length : null, cwd, env, adviceClient });
|
|
516
|
+
// The advice await can take a while: what the precondition and the risk were read from must still stand.
|
|
517
|
+
if (seen && advice.status !== 'off') {
|
|
518
|
+
const now = readHeadState(cwd, env);
|
|
519
|
+
const records = parseRecordLines(verificationRunRecordFile(verificationRunsRoot(cwd), verifyId ));
|
|
520
|
+
if (now.head !== seen.head || now.clean !== seen.clean || 'error' in records || records.text !== seen.text) {
|
|
521
|
+
return refuse('state-moved', 'HEAD, the working tree or the verification records changed while the advice was asked; run review start again');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
510
524
|
const resolved = resolveProtocol({ ...facts, advicePick: advice.pick });
|
|
511
525
|
if (!resolved.ok) return refuseResolution(resolved.reason);
|
|
512
526
|
const { protocol } = resolved;
|
|
@@ -15,6 +15,7 @@ const path = require('node:path');
|
|
|
15
15
|
const crypto = require('node:crypto');
|
|
16
16
|
const { execFileSync: defaultExecFileSync } = require('node:child_process');
|
|
17
17
|
const { listCheckouts } = require('../checkouts.js');
|
|
18
|
+
const { readRegularFileText } = require('../task-scope.js');
|
|
18
19
|
const { withGitMemo } = require('../git-memo.js');
|
|
19
20
|
const {
|
|
20
21
|
parseSeed,
|
|
@@ -661,11 +662,17 @@ const tripwireFile = (ctx ) => path.join(ctx.dir, 'tripwire', 'event
|
|
|
661
662
|
function readTripwire(ctx ) {
|
|
662
663
|
const file = tripwireFile(ctx);
|
|
663
664
|
try {
|
|
664
|
-
|
|
665
|
+
try { ctx.fsImpl.lstatSync(file); } catch (error ) {
|
|
666
|
+
if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
// A bounded read: a link, a FIFO or a file over the record cap is never opened as the record.
|
|
670
|
+
const text = readRegularFileText(file, ctx.fsImpl);
|
|
671
|
+
if (text === null) throw new Error('not a regular file within the record size cap');
|
|
672
|
+
const value = JSON.parse(text);
|
|
665
673
|
if (!value || value.schema !== 1 || !Array.isArray(value.reverts) || !Array.isArray(value.unverified)) throw new Error('not a schema 1 tripwire record');
|
|
666
674
|
return value;
|
|
667
675
|
} catch (error ) {
|
|
668
|
-
if (error.code === 'ENOENT') return { schema: 1, reverts: [], unverified: [] };
|
|
669
676
|
throw new Unreadable(['tripwire'], error.message);
|
|
670
677
|
}
|
|
671
678
|
}
|
|
@@ -808,7 +815,9 @@ function orchestrationFor(ctx , row , worktree )
|
|
|
808
815
|
// artifactRootOf finds it (the feature checkout, else the main one).
|
|
809
816
|
const planExitCheck = () => require('../plan-content.js').planStageExit({ roots: [...new Set([worktree, runRoot, ctx.root])], slug: row.slug });
|
|
810
817
|
const orch = new WorkflowOrchestrator({ repoRoot: ctx.repoRoot, projectRoot: runRoot, sourceRoot: worktree,
|
|
811
|
-
stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck
|
|
818
|
+
stateDir: path.join(runRoot, '.doflow', 'state', 'orchestration'), featureSlug: row.slug, fsImpl: ctx.fsImpl, env: ctx.env, cwd: worktree, planExitCheck,
|
|
819
|
+
// FR-006, as the orchestrate verb wires it: the ledger is found from any checkout of the row.
|
|
820
|
+
taskAttemptCheck: () => require('../task-attempts.js').checkCompletion({ slug: row.slug, cwd: worktree, fsImpl: ctx.fsImpl }) });
|
|
812
821
|
let run ;
|
|
813
822
|
try { run = orch.readRun(row.slug); } catch (error ) { throw new Unreadable([source], error.message); }
|
|
814
823
|
if (run && (!Array.isArray(run.program) || !Number.isInteger(run.cursor) || typeof run.state !== 'string' || !Array.isArray(run.history))) {
|
|
@@ -1716,4 +1725,6 @@ module.exports = {
|
|
|
1716
1725
|
// The lease primitive, exported for its interleaving test (C8).
|
|
1717
1726
|
acquireLease,
|
|
1718
1727
|
releaseLease, resolveMainRoot, noPsAlive,
|
|
1728
|
+
// The orchestrator the loop builds for a row, exported for its wiring test (FU-e4yr45).
|
|
1729
|
+
orchestrationFor,
|
|
1719
1730
|
};
|
|
@@ -864,7 +864,8 @@ function render(
|
|
|
864
864
|
for (const row of rows) {
|
|
865
865
|
const info = getInfo(row.id);
|
|
866
866
|
const needsStr = row.needs && row.needs.length > 0 ? row.needs.join(', ') : '-';
|
|
867
|
-
|
|
867
|
+
// A not-started row of a class the loop does not run waits for a person, not for time: `manual`.
|
|
868
|
+
const statusStr = info.status === 'not-started' && !LOOP_CLASSES.includes(row.class) ? 'manual' : info.status;
|
|
868
869
|
|
|
869
870
|
let evidenceStr = '-';
|
|
870
871
|
if (info.evidence) {
|
|
@@ -35,6 +35,8 @@ const BOM = '\uFEFF';
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
|
|
39
|
+
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
function isObject(value ) {
|
|
@@ -265,7 +267,7 @@ function readLayerFile(file , { registry, fsImpl = nodeFs, includeDocumen
|
|
|
265
267
|
values.set(key, { raw, value: error ? null : raw, error });
|
|
266
268
|
}
|
|
267
269
|
return { status: 'ok', code: null, offset: null, reason: null, values, notes,
|
|
268
|
-
...(includeDocument ? { document: parsed
|
|
270
|
+
...(includeDocument ? { document: parsed , source: text } : {}) };
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
module.exports = { MAX_BYTES, jsonFailure, validateValue, readLayerFile };
|
|
@@ -188,6 +188,7 @@ function validatePrepared(doc , ctx ) {
|
|
|
188
188
|
if (Object.hasOwn(doc, 'presets') && !isObject(doc.presets)) errors.push(entry('presets', null, 'type', 'presets must be an object with review and debate preset ids'));
|
|
189
189
|
let loaded = null; // the start verb's inputs, loaded once and only when a preset is checked
|
|
190
190
|
const inputs = () => (loaded ??= ctx.inputs(taskClass));
|
|
191
|
+
let settingsFault = null; // a live settings fault the prepared values do not replace: one entry per preset
|
|
191
192
|
for (const kind of ['review', 'debate']) {
|
|
192
193
|
if (!Object.hasOwn(presets, kind)) continue;
|
|
193
194
|
const order = presetIds(protocols, kind); // the effective core order: the only ids that have a rank
|
|
@@ -201,6 +202,10 @@ function validatePrepared(doc , ctx ) {
|
|
|
201
202
|
// The effective preset setting (C-3): the user/project/local layers `review --action start` reads, with this
|
|
202
203
|
// file's own entry as the project layer, applied by the resolver's rule (settingsPick) to the loosest preset.
|
|
203
204
|
const settingKey = `${kind}.preset`;
|
|
205
|
+
if (settingsFault === null) {
|
|
206
|
+
try { inputs(); } catch (error ) { if (!error.settings) throw error; settingsFault = error.message; }
|
|
207
|
+
}
|
|
208
|
+
if (settingsFault !== null) { errors.push(entry(key, null, 'settings-unavailable', settingsFault)); continue; }
|
|
204
209
|
const view = { ...inputs().settingsView };
|
|
205
210
|
const current = view[settingKey];
|
|
206
211
|
if (typeof settings[settingKey] === 'string' && (!current || ['default', 'user', 'project'].includes(current.layer))) {
|
|
@@ -239,6 +244,11 @@ function report(file , errors , json , notes =
|
|
|
239
244
|
return finishRuntime(errors.length === 0 ? 0 : 1);
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
/** The settings keys the prepared run writes to the project layer: applied before the live settings are judged. */
|
|
248
|
+
function replaced(doc ) {
|
|
249
|
+
return new Set(isObject(doc.settings) ? Object.keys(doc.settings).filter((key) => doc.settings[key] !== null) : []);
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, fsImpl = nodeFs } ) {
|
|
243
253
|
let text ;
|
|
244
254
|
try { text = fsImpl.readFileSync(path.resolve(file), 'utf8'); } catch { return usageError('config', `cannot read the prepared run ${file}`, json); }
|
|
@@ -255,8 +265,8 @@ function handlePreparedRun({ file, json, registry, repoRoot, projectRoot, env, f
|
|
|
255
265
|
registry, protocols: protocols.registry, classes: taskClasses(repoRoot, fsImpl), env, projectRoot: root, repoRoot, fsImpl, notes,
|
|
256
266
|
// what `review --action start` loads: the protocol settings view and gates.json narrowed by the project's override
|
|
257
267
|
inputs: (taskClass ) => {
|
|
258
|
-
const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env);
|
|
259
|
-
if ('error' in inputs) throw Object.assign(new Error(inputs.message), { usage: true });
|
|
268
|
+
const inputs = require('../review/start.js').loadProtocolInputs(taskClass, root, env, replaced(doc));
|
|
269
|
+
if ('error' in inputs) throw Object.assign(new Error(inputs.message), inputs.error === 'settings-unavailable' ? { settings: true } : { usage: true });
|
|
260
270
|
return inputs;
|
|
261
271
|
},
|
|
262
272
|
};
|
|
@@ -214,6 +214,18 @@ function loadSettings({ projectRoot = null, global = false, env = process.env, r
|
|
|
214
214
|
return view;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The registry to pass `loadSettings` with `fsImpl`: none for the real file system (the shipped one),
|
|
219
|
+
* else the registry read through that file system. Throws when it is unusable (callers fail closed).
|
|
220
|
+
*/
|
|
221
|
+
function registryOn(fsImpl ) {
|
|
222
|
+
if (fsImpl === nodeFs) return {};
|
|
223
|
+
const { loadSettingsRegistry } = require('./registry.js');
|
|
224
|
+
const loaded = loadSettingsRegistry({ fsImpl });
|
|
225
|
+
if (!loaded.ok) throw new Error(loaded.detail);
|
|
226
|
+
return { registry: loaded.registry };
|
|
227
|
+
}
|
|
228
|
+
|
|
217
229
|
function buildView(registry , home , root , global , env , fsImpl , protocols , only ) {
|
|
218
230
|
const { REGISTRY_FILE } = require('./registry.js');
|
|
219
231
|
const { REF_META } = require('./refs.js');
|
|
@@ -710,4 +722,4 @@ function buildView(registry , home , root , global
|
|
|
710
722
|
return view;
|
|
711
723
|
}
|
|
712
724
|
|
|
713
|
-
module.exports = { loadSettings, clearSettingsCache };
|
|
725
|
+
module.exports = { loadSettings, registryOn, clearSettingsCache };
|
|
@@ -189,10 +189,74 @@ function writeLayer({ file, set = {}, unset = [], scrubKeys = [], force = false,
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Where a write to `file` lands. A symlinked settings file (a dotfile manager's link) is written
|
|
194
|
+
* through to its target so the link survives; a project or local file whose link leads outside the
|
|
195
|
+
* project, or leads nowhere, is refused rather than read through or copied.
|
|
196
|
+
*/
|
|
197
|
+
function writeTarget(file , fsImpl ) {
|
|
198
|
+
let stat ;
|
|
199
|
+
try { stat = fsImpl.lstatSync(file); } catch { return { ok: true, target: file }; }
|
|
200
|
+
if (!stat.isSymbolicLink()) return { ok: true, target: file };
|
|
201
|
+
let real ;
|
|
202
|
+
try { real = fsImpl.realpathSync(file); } catch {
|
|
203
|
+
return { ok: false, result: invalid(file, 'is a symbolic link that does not resolve to a file; nothing was written', 'scope') };
|
|
204
|
+
}
|
|
205
|
+
const directory = path.dirname(file);
|
|
206
|
+
if (path.basename(directory) === '.doflow') {
|
|
207
|
+
try {
|
|
208
|
+
const rel = path.relative(fsImpl.realpathSync(path.dirname(directory)), real);
|
|
209
|
+
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error('outside');
|
|
210
|
+
} catch {
|
|
211
|
+
return { ok: false, result: invalid(file, 'is a symbolic link to a path outside the project; nothing was written', 'scope') };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { ok: true, target: real };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The most significant digits a JSON number keeps exactly when it is read as a double and written back. */
|
|
218
|
+
const EXACT_DIGITS = 15;
|
|
219
|
+
const NUMBER_AT = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The first top-level key of `text` (a JSON document) holding a number that `JSON.parse` followed by
|
|
223
|
+
* `JSON.stringify` would change in value: more than 15 digits and not a safe integer.
|
|
224
|
+
*/
|
|
225
|
+
function inexactNumberKey(text ) {
|
|
226
|
+
let depth = 0;
|
|
227
|
+
let current = null;
|
|
228
|
+
for (let i = 0; i < text.length;) {
|
|
229
|
+
const ch = text[i];
|
|
230
|
+
if (ch === '"') {
|
|
231
|
+
let end = i + 1;
|
|
232
|
+
while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
|
|
233
|
+
if (depth === 1) {
|
|
234
|
+
let after = end + 1;
|
|
235
|
+
while (after < text.length && /\s/.test(text[after])) after++;
|
|
236
|
+
if (text[after] === ':') { try { current = JSON.parse(text.slice(i, end + 1)); } catch { current = null; } }
|
|
237
|
+
}
|
|
238
|
+
i = end + 1;
|
|
239
|
+
} else if (ch === '{' || ch === '[') { depth++; i++; }
|
|
240
|
+
else if (ch === '}' || ch === ']') { depth--; i++; }
|
|
241
|
+
else if (ch === '-' || (ch >= '0' && ch <= '9')) {
|
|
242
|
+
NUMBER_AT.lastIndex = i;
|
|
243
|
+
const match = NUMBER_AT.exec(text);
|
|
244
|
+
if (!match) { i++; continue; }
|
|
245
|
+
const token = match[0];
|
|
246
|
+
const digits = token.replace(/[eE].*$/, '').replace(/[-.]/g, '').replace(/^0+/, '').replace(/0+$/, '');
|
|
247
|
+
if (digits.length > EXACT_DIGITS && !(/^-?\d+$/.test(token) && Number.isSafeInteger(Number(token))) && current !== null) return current;
|
|
248
|
+
i += token.length;
|
|
249
|
+
} else i++;
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
192
254
|
const INVALID = Symbol('invalid layer');
|
|
193
255
|
|
|
194
256
|
/** The read, merge and rename of `writeLayer`, run while it holds the layer's lock. */
|
|
195
257
|
function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect, backup, fsImpl } ) {
|
|
258
|
+
const landing = writeTarget(file, fsImpl);
|
|
259
|
+
if (!landing.ok) return landing.result;
|
|
196
260
|
let before = null;
|
|
197
261
|
let existing = {};
|
|
198
262
|
let layer ;
|
|
@@ -231,6 +295,13 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
|
|
|
231
295
|
for (const [key, value] of Object.entries(set)) values[key] = value;
|
|
232
296
|
for (const key of unsetKeys) delete values[key];
|
|
233
297
|
|
|
298
|
+
if (layer?.status === 'ok' && typeof layer.source === 'string') {
|
|
299
|
+
const kept = inexactNumberKey(layer.source);
|
|
300
|
+
if (kept !== null && Object.hasOwn(values, kept) && !Object.hasOwn(set, kept)) {
|
|
301
|
+
return invalid(file, `key '${kept}' holds a number with more digits than JSON can rewrite exactly; nothing was written; edit the file by hand`, 'number-precision');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
234
305
|
const after = layerDocument(registry, values);
|
|
235
306
|
const bytes = serializeLayer(after);
|
|
236
307
|
const tooLarge = oversize(file, bytes);
|
|
@@ -244,7 +315,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
|
|
|
244
315
|
const directory = path.dirname(file);
|
|
245
316
|
let temporary = null;
|
|
246
317
|
try {
|
|
247
|
-
temporary = path.join(
|
|
318
|
+
temporary = path.join(path.dirname(landing.target), `.${path.basename(landing.target)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`);
|
|
248
319
|
fsImpl.writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
|
|
249
320
|
fsImpl.chmodSync(temporary, 0o600);
|
|
250
321
|
if (path.basename(directory) === '.doflow') {
|
|
@@ -253,7 +324,7 @@ function replaceLayer({ file, registry, set, unsetKeys, scrubKeys, force, expect
|
|
|
253
324
|
return invalid(file, '.doflow symlink points outside the project', 'scope');
|
|
254
325
|
}
|
|
255
326
|
}
|
|
256
|
-
fsImpl.renameSync(temporary,
|
|
327
|
+
fsImpl.renameSync(temporary, landing.target);
|
|
257
328
|
temporary = null;
|
|
258
329
|
clearSettingsCache();
|
|
259
330
|
return { ok: true, file, before, after };
|
|
@@ -13,7 +13,7 @@ const RULES = new Map([
|
|
|
13
13
|
['date-partition', ['maxAgeDays']],
|
|
14
14
|
['recovery', ['keepNewest']],
|
|
15
15
|
['lifecycle-settled', ['defaultHours']],
|
|
16
|
-
['report-orphan', ['orphanGraceHours'
|
|
16
|
+
['report-orphan', ['orphanGraceHours']],
|
|
17
17
|
['age', ['maxAgeDays']],
|
|
18
18
|
['existing', []],
|
|
19
19
|
['control', []],
|
|
@@ -132,7 +132,7 @@ function formatBound(store ) {
|
|
|
132
132
|
case 'date-partition': return `prune partitions older than ${bound.maxAgeDays} days`;
|
|
133
133
|
case 'recovery': return `keep newest ${bound.keepNewest}, plus the snapshot named by the ledger`;
|
|
134
134
|
case 'lifecycle-settled': return `retain settled events for ${bound.defaultHours} hours`;
|
|
135
|
-
case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders
|
|
135
|
+
case 'report-orphan': return `prune orphan bodies after ${bound.orphanGraceHours} hours; foreign folders are kept`;
|
|
136
136
|
case 'age': return `prune inactive directories after ${bound.maxAgeDays} days`;
|
|
137
137
|
case 'existing':
|
|
138
138
|
case 'control': return bound.text;
|