devflow-kit 3.6.0 → 3.8.0
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/CHANGELOG.md +438 -1
- package/README.md +15 -11
- package/dist/agents/code.md +9 -15
- package/dist/agents/design.md +1 -1
- package/dist/agents/fix.md +11 -13
- package/dist/agents/git.md +1 -1
- package/dist/agents/harden.md +1 -1
- package/dist/agents/refactor.md +7 -10
- package/dist/agents/review.md +6 -4
- package/dist/agents/skim.md +2 -2
- package/dist/agents/triage.md +2 -2
- package/dist/cli/commands/capture.js +1 -1
- package/dist/cli/commands/curation-move-io.js +102 -0
- package/dist/cli/commands/curation-move-step.js +374 -0
- package/dist/cli/commands/{learning.js → curation.js} +302 -99
- package/dist/cli/commands/gate-level-prompts.js +426 -0
- package/dist/cli/commands/gates.js +299 -0
- package/dist/cli/commands/init-seed.js +16 -5
- package/dist/cli/commands/init.js +263 -54
- package/dist/cli/commands/memory.js +20 -2
- package/dist/cli/commands/prompt-io.js +37 -0
- package/dist/cli/commands/uninstall.js +81 -9
- package/dist/cli.js +4 -2
- package/dist/commands/bug-analysis.md +25 -36
- package/dist/commands/code-review.md +77 -73
- package/dist/commands/debug.md +5 -5
- package/dist/commands/dynamic-build.md +76 -83
- package/dist/commands/dynamic-plan.md +5 -5
- package/dist/commands/dynamic-profile.md +5 -5
- package/dist/commands/dynamic-tickets.md +5 -5
- package/dist/commands/explore.md +6 -6
- package/dist/commands/implement.md +68 -102
- package/dist/commands/plan.md +19 -29
- package/dist/commands/release.md +5 -5
- package/dist/commands/research.md +6 -6
- package/dist/commands/resolve.md +29 -31
- package/dist/commands/self-review.md +5 -13
- package/dist/core/assets.js +20 -20
- package/dist/core/curate-tuning-config.js +5 -6
- package/dist/core/curation-move-notice.js +84 -0
- package/dist/core/curation-move-skip.js +36 -0
- package/dist/core/curation-move.js +35 -0
- package/dist/core/curation-queue-cleanup.js +134 -0
- package/dist/core/curation-store.js +98 -0
- package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
- package/dist/core/echo-value.js +48 -0
- package/dist/core/evidence-policy.js +18 -6
- package/dist/core/feature-switch.js +95 -19
- package/dist/core/fs-atomic.js +1 -1
- package/dist/core/gate-level.js +221 -0
- package/dist/core/git.js +16 -14
- package/dist/core/ledger-root.js +82 -22
- package/dist/core/linked-path.js +4 -4
- package/dist/core/manifest-lock.js +238 -0
- package/dist/core/manifest.js +84 -24
- package/dist/core/migrations.js +1 -13
- package/dist/core/model-discovery.js +7 -1
- package/dist/core/observations.js +2 -2
- package/dist/core/plugins.js +15 -15
- package/dist/core/pool.js +26 -0
- package/dist/core/project-paths.js +103 -34
- package/dist/core/queue-drain.js +2 -2
- package/dist/core/tracked-areas.js +35 -0
- package/dist/core/tracker.js +6 -34
- package/dist/{learning-off → curation-off}/agents/code.md +9 -15
- package/dist/{learning-off → curation-off}/agents/fix.md +11 -13
- package/dist/{learning-off → curation-off}/agents/refactor.md +7 -10
- package/dist/{learning-off → curation-off}/agents/review.md +5 -3
- package/dist/{learning-off → curation-off}/agents/triage.md +1 -1
- package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
- package/dist/{learning-off → curation-off}/commands/code-review.md +74 -70
- package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
- package/dist/{learning-off → curation-off}/commands/dynamic-build.md +73 -80
- package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
- package/dist/{learning-off → curation-off}/commands/implement.md +65 -99
- package/dist/{learning-off → curation-off}/commands/plan.md +15 -25
- package/dist/{learning-off → curation-off}/commands/resolve.md +26 -28
- package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
- package/dist/hud/components/{learning-counts.js → curation-counts.js} +36 -17
- package/dist/hud/config.js +1 -1
- package/dist/hud/index.js +6 -6
- package/dist/hud/render.js +3 -3
- package/dist/skills/git/references/pr/validate-branch.md +8 -5
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +20 -14
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +20 -20
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +20 -20
- package/dist/targets/claude-code/{learning-install.js → curation-install.js} +99 -67
- package/dist/targets/claude-code/installer.js +15 -15
- package/dist/targets/claude-code/language-stamp.js +5 -5
- package/dist/targets/claude-code/post-install.js +2 -2
- package/dist/targets/claude-code/tracker-servers.js +1 -1
- package/package.json +3 -2
- package/src/assets/agents/_partials/_code.mds +16 -17
- package/src/assets/agents/code.mds +4 -7
- package/src/assets/agents/curate.md +12 -14
- package/src/assets/agents/debug.mds +6 -6
- package/src/assets/agents/design.mds +18 -18
- package/src/assets/agents/fix.mds +6 -5
- package/src/assets/agents/git.mds +1 -1
- package/src/assets/agents/harden.mds +7 -7
- package/src/assets/agents/knowledge.mds +23 -23
- package/src/assets/agents/refactor.mds +2 -2
- package/src/assets/agents/research.mds +15 -15
- package/src/assets/agents/review.mds +45 -43
- package/src/assets/agents/skim.mds +11 -11
- package/src/assets/agents/synthesize.md +2 -2
- package/src/assets/agents/triage.mds +22 -22
- package/src/assets/agents/validate.md +13 -26
- package/src/assets/commands/_partials/_decisions.mds +13 -13
- package/src/assets/commands/_partials/_engine.mds +8 -8
- package/src/assets/commands/_partials/_knowledge.mds +29 -15
- package/src/assets/commands/_partials/_preamble.mds +4 -4
- package/src/assets/commands/_partials/_settings.mds +3 -3
- package/src/assets/commands/_partials/_wave.mds +17 -24
- package/src/assets/commands/bug-analysis.mds +37 -48
- package/src/assets/commands/code-review.mds +90 -86
- package/src/assets/commands/debug.mds +14 -14
- package/src/assets/commands/dynamic-build.mds +78 -78
- package/src/assets/commands/dynamic-plan.mds +22 -22
- package/src/assets/commands/dynamic-profile.mds +2 -2
- package/src/assets/commands/dynamic-tickets.mds +8 -8
- package/src/assets/commands/explore.mds +20 -20
- package/src/assets/commands/implement.mds +92 -118
- package/src/assets/commands/plan.mds +41 -51
- package/src/assets/commands/release.mds +12 -12
- package/src/assets/commands/research.mds +19 -19
- package/src/assets/commands/resolve.mds +56 -50
- package/src/assets/commands/self-review.mds +20 -20
- package/src/assets/mds/git/_ops.mds +3 -1
- package/src/assets/mds/git/_ops_pr.mds +1 -1
- package/src/assets/mds/git/_ops_steps.mds +13 -7
- package/src/assets/mds/git/_pr.mds +7 -4
- package/src/assets/mds/tracker/_common.mds +23 -12
- package/src/assets/mds/tracker/_github.mds +2 -9
- package/src/assets/mds/tracker/_jira.mds +2 -15
- package/src/assets/mds/tracker/_linear.mds +2 -15
- package/src/assets/rules/quality.md +1 -0
- package/src/assets/scripts/ci-wait.cjs +22 -4
- package/src/assets/scripts/claude-md-audit.cjs +66 -51
- package/src/assets/scripts/hooks/background-memory-update +406 -97
- package/src/assets/scripts/hooks/capture-prompt +12 -12
- package/src/assets/scripts/hooks/capture-question +11 -11
- package/src/assets/scripts/hooks/capture-turn +12 -12
- package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
- package/src/assets/scripts/hooks/curation-move +293 -0
- package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
- package/src/assets/scripts/hooks/ensure-proxy +4 -4
- package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
- package/src/assets/scripts/hooks/git-marker +81 -0
- package/src/assets/scripts/hooks/json-helper.cjs +145 -51
- package/src/assets/scripts/hooks/json-parse +19 -4
- package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
- package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +242 -118
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
- package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +34 -22
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +259 -0
- package/src/assets/scripts/hooks/memory-permission +358 -0
- package/src/assets/scripts/hooks/memory-worker +1 -1
- package/src/assets/scripts/hooks/pre-compact-memory +6 -16
- package/src/assets/scripts/hooks/queue-append +228 -103
- package/src/assets/scripts/hooks/resolve-project-root +2 -2
- package/src/assets/scripts/hooks/session-start-context +145 -71
- package/src/assets/scripts/hooks/session-start-memory +27 -46
- package/src/assets/scripts/lib/project-config.cjs +31 -9
- package/src/assets/scripts/resolve-base.cjs +1043 -0
- package/src/assets/scripts/resolve-settings.cjs +238 -46
- package/src/assets/skills/agent-references/SKILL.md +9 -2
- package/src/assets/skills/agent-references/references/commands/code-review-levels.md +19 -0
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +27 -0
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
- package/src/assets/skills/agent-references/references/commands/plan-levels.md +40 -0
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +24 -0
- package/src/assets/skills/agent-references/references/synthesize/planning.md +9 -22
- package/src/assets/skills/agent-references/references/synthesize/review.md +11 -11
- package/src/assets/skills/apply-decisions/SKILL.md +1 -1
- package/src/assets/skills/docs-framework/SKILL.md +7 -7
- package/src/assets/skills/docs-framework/references/patterns.md +3 -4
- package/src/assets/skills/quality-gates/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +2 -2
- package/templates/lint-pack/.lint-pack.conf +86 -0
- package/templates/lint-pack/README.md +190 -0
- package/templates/lint-pack/lint-pack.sh +905 -0
- package/dist/core/learning-queue-cleanup.js +0 -27
- package/dist/core/learning-store.js +0 -61
- /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
|
@@ -0,0 +1,1582 @@
|
|
|
1
|
+
// src/assets/scripts/hooks/lib/curation-move.cjs
|
|
2
|
+
//
|
|
3
|
+
// The one-time move of a project's old ledger folder to its new name: moveLedger, the
|
|
4
|
+
// backup sweep (sweepLedgerBackups), the read-only backup count (backupPendingRows) that
|
|
5
|
+
// `devflow curation --status` uses, and the read-only list of the backups' queue files
|
|
6
|
+
// (backupQueueFiles) that the drain of a disable deletes. The one place that names both
|
|
7
|
+
// folders.
|
|
8
|
+
//
|
|
9
|
+
// DESIGN: plumbing only, like the store it builds on. No function here prints or calls
|
|
10
|
+
// process.exit. Work that can fail on its input or on a lock returns a Result, { ok:
|
|
11
|
+
// true, value } or { ok: false, error: { kind, message } }, and its caller prints it:
|
|
12
|
+
// json-helper.cjs for the move op, or the `devflow init` and `devflow curation` CLI
|
|
13
|
+
// through src/core/curation-move.ts. A throw means a broken invariant or an I/O
|
|
14
|
+
// failure, never an expected outcome.
|
|
15
|
+
//
|
|
16
|
+
// Loading: node built-ins and four sibling libs. This module requires the store for
|
|
17
|
+
// its primitives (readTextUnlinked, appendNoFollow, the quarantine, renderAll); the
|
|
18
|
+
// store never requires this module. The tracked-areas check is a leaf that requires
|
|
19
|
+
// neither of them. The store's write guard reads the move journal through
|
|
20
|
+
// project-paths.cjs and its own helpers, so a store write loads no move code.
|
|
21
|
+
//
|
|
22
|
+
// TS COUNTERPART: src/core/curation-move.ts transcribes the functions the CLI calls
|
|
23
|
+
// from their JSDoc here (D-LEARNING-STORE-SEAM) — change both together.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
LEGACY_NAMES,
|
|
33
|
+
CURATION_DIR_NAME,
|
|
34
|
+
CURATION_TUNING_FILE_NAME,
|
|
35
|
+
getCurationPendingTurnsPath,
|
|
36
|
+
getCurationPendingTurnsProcessingPath,
|
|
37
|
+
getCurationClaimOwnerPath,
|
|
38
|
+
getDecisionsLedgerPath,
|
|
39
|
+
getDecisionsLogPath,
|
|
40
|
+
getDecisionsArchivePath,
|
|
41
|
+
getDecisionsHistoryPath,
|
|
42
|
+
getDecisionsLockDir,
|
|
43
|
+
getDecisionsFilePath,
|
|
44
|
+
getPitfallsFilePath,
|
|
45
|
+
getDecisionsIndexPath,
|
|
46
|
+
curationMoveLockDir,
|
|
47
|
+
curationMoveJournalPath,
|
|
48
|
+
} = require('./project-paths.cjs');
|
|
49
|
+
const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
|
|
50
|
+
const {
|
|
51
|
+
CLAIM_STALE_SECS,
|
|
52
|
+
LOCK_STALE_MS,
|
|
53
|
+
NO_HARD_LINK_CODES,
|
|
54
|
+
QUEUE_LOCK_STALE_MS,
|
|
55
|
+
appendNoFollow,
|
|
56
|
+
quarantineRejected,
|
|
57
|
+
readJsonl,
|
|
58
|
+
readTextUnlinked,
|
|
59
|
+
removeIfPresent,
|
|
60
|
+
renderAll,
|
|
61
|
+
singleLine,
|
|
62
|
+
isPlainObject,
|
|
63
|
+
lstatOrNull,
|
|
64
|
+
physicalPath,
|
|
65
|
+
homeDirOrNull,
|
|
66
|
+
} = require('./curation-store.cjs');
|
|
67
|
+
const { checkTrackedAreas, inspectTrackedAreas } = require('./tracked-areas.cjs');
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// The one-time ledger move
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
//
|
|
73
|
+
// moveLedger renames a project's old ledger folder to the new name once, or, when
|
|
74
|
+
// the new folder already exists, merges the old one into it. The two folders are the
|
|
75
|
+
// two names the path helpers hold (LEGACY_NAMES.folder and CURATION_DIR_NAME): no
|
|
76
|
+
// caller names them, because the journal, the backup prefix, the tuning file and the
|
|
77
|
+
// locks all stand on that one pair. The move takes its locks without waiting, because a hook that waited on a crashed
|
|
78
|
+
// mover would stall every event; it never copies (a failed rename is an error, never
|
|
79
|
+
// a fallback to a copy); and it moves no ledger row across the two folders: a losing
|
|
80
|
+
// set goes to the backup whole.
|
|
81
|
+
//
|
|
82
|
+
// D-ONE-LEARNING-LOCK holds for every other writer. The move is the one exception
|
|
83
|
+
// to "one lock": it takes the move lock under the machine directory (keyed by the
|
|
84
|
+
// ledger root, never inside a repository) together with each folder's own store
|
|
85
|
+
// lock and queue lock, all with no wait, so a busy writer makes the move busy
|
|
86
|
+
// instead of the move waiting on it.
|
|
87
|
+
|
|
88
|
+
/** Age after which a move lock counts as abandoned (ms). */
|
|
89
|
+
const MOVE_LOCK_STALE_MS = 60000;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The name a journal would have inside the old folder. The move never writes one
|
|
93
|
+
* there and never reads one: a file of this name is repository content, an entry of
|
|
94
|
+
* the journal class (moveEntryClass) that stays in the backup. It never refuses a move,
|
|
95
|
+
* and it never steers one; it only takes a move that would rename the whole folder onto
|
|
96
|
+
* the per-entry path, so that the file stays in a backup and does not reach the live
|
|
97
|
+
* folder.
|
|
98
|
+
*/
|
|
99
|
+
const OLD_FOLDER_JOURNAL_NAME = '.curation-move.journal';
|
|
100
|
+
|
|
101
|
+
/** The most the journal is read at: far above any journal this module writes. */
|
|
102
|
+
const MOVE_JOURNAL_MAX_BYTES = 64 * 1024;
|
|
103
|
+
|
|
104
|
+
/** The most entries of a folder the move reads: past it the move refuses. */
|
|
105
|
+
const MOVE_MAX_ENTRIES = 5000;
|
|
106
|
+
|
|
107
|
+
/** The most backup folders one sweep merges from, and one status lists by name. */
|
|
108
|
+
const MOVE_MAX_BACKUPS = 50;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The most backup folders one sweep or one status looks into, to find those that still
|
|
112
|
+
* hold rows. A backup past it is not read: the sweep leaves it and status counts it by name.
|
|
113
|
+
*/
|
|
114
|
+
const MOVE_MAX_BACKUPS_SCANNED = 500;
|
|
115
|
+
|
|
116
|
+
/** The name of a path helper's file, whichever project it names. */
|
|
117
|
+
const nameOfHelper = helper => path.basename(helper(path.sep));
|
|
118
|
+
|
|
119
|
+
/** The four files of the ledger set, in the order the move renames them: the ledger last. */
|
|
120
|
+
const SET_MEMBERS = Object.freeze([
|
|
121
|
+
getDecisionsLogPath, getDecisionsArchivePath, getDecisionsHistoryPath, getDecisionsLedgerPath,
|
|
122
|
+
].map(nameOfHelper));
|
|
123
|
+
const LEDGER_NAME = SET_MEMBERS[SET_MEMBERS.length - 1];
|
|
124
|
+
|
|
125
|
+
const QUEUE_NAME = nameOfHelper(getCurationPendingTurnsPath);
|
|
126
|
+
const CLAIM_NAME = nameOfHelper(getCurationPendingTurnsProcessingPath);
|
|
127
|
+
const OWNER_NAME = nameOfHelper(getCurationClaimOwnerPath);
|
|
128
|
+
const STORE_LOCK_NAME = nameOfHelper(getDecisionsLockDir);
|
|
129
|
+
const QUEUE_LOCK_NAME = `${QUEUE_NAME}.lock`;
|
|
130
|
+
|
|
131
|
+
/** Recovery files the store makes next to its files: quarantined lines, one-time v1 copies. */
|
|
132
|
+
const RECOVERY_FILE_RE = /\.(?:rejected|pre-v2)\.jsonl$/;
|
|
133
|
+
|
|
134
|
+
/** The names, besides the set, the queue and the locks, that the move handles by a rule of its own: the owner file and the tuning files. */
|
|
135
|
+
const KNOWN_NAMES = new Set([OWNER_NAME, LEGACY_NAMES.tuningFile, CURATION_TUNING_FILE_NAME]);
|
|
136
|
+
|
|
137
|
+
/** The tail of a private, swept or overflow queue file's name: a UTC stamp and eight random hex characters. */
|
|
138
|
+
const MOVE_UNIQUE_TAIL_RE = '\\d{8}T\\d{6}Z-[0-9a-f]{8}';
|
|
139
|
+
|
|
140
|
+
/** `text` with every character that means something in a regular expression escaped. */
|
|
141
|
+
const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
142
|
+
|
|
143
|
+
/** The name of a queue file in one of the forms the move makes: `<base>.<word>-<stamp>-<hex>`. */
|
|
144
|
+
const queueFormRe = (base, word) => new RegExp(`^${escapeRegExp(base)}\\.${word}-${MOVE_UNIQUE_TAIL_RE}$`);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The forms of queue file the move makes. A private file is the queue (or a stale
|
|
148
|
+
* claim's batch) renamed to a unique name before it is read; a swept file is a private
|
|
149
|
+
* or overflow file whose lines are all merged; an overflow file holds the lines that
|
|
150
|
+
* did not fit under the queue's cap. Only the queue and the claim file themselves are
|
|
151
|
+
* ever renamed to a private name, and swept files are never read again.
|
|
152
|
+
*/
|
|
153
|
+
const QUEUE_FORMS = Object.freeze([
|
|
154
|
+
{ form: 'private', base: QUEUE_NAME, re: queueFormRe(QUEUE_NAME, 'merging') },
|
|
155
|
+
{ form: 'private', base: CLAIM_NAME, re: queueFormRe(CLAIM_NAME, 'merging') },
|
|
156
|
+
{ form: 'swept', base: QUEUE_NAME, re: queueFormRe(QUEUE_NAME, 'swept') },
|
|
157
|
+
{ form: 'swept', base: CLAIM_NAME, re: queueFormRe(CLAIM_NAME, 'swept') },
|
|
158
|
+
{ form: 'overflow', base: QUEUE_NAME, re: queueFormRe(QUEUE_NAME, 'overflow') },
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/** The form record of a queue file's name, or null when the name is none of the forms. */
|
|
162
|
+
function queueFormOf(name) {
|
|
163
|
+
return QUEUE_FORMS.find(entry => entry.re.test(name)) || null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Names of the files the renderer writes: the move never carries them, the new folder rebuilds its own. */
|
|
167
|
+
const RENDERED_NAMES = new Set([getDecisionsFilePath, getPitfallsFilePath, getDecisionsIndexPath].map(nameOfHelper));
|
|
168
|
+
|
|
169
|
+
/** The name a displaced copy of a set member takes: the member, a UTC stamp and a random suffix. */
|
|
170
|
+
const displacedRe = member => new RegExp(`^${escapeRegExp(member)}\\.displaced-${MOVE_UNIQUE_TAIL_RE}$`);
|
|
171
|
+
const DISPLACED_RES = Object.freeze(SET_MEMBERS.map(displacedRe));
|
|
172
|
+
|
|
173
|
+
/** The classes of entry in an old folder or a backup folder (moveEntryClass). */
|
|
174
|
+
const ENTRY = Object.freeze({
|
|
175
|
+
SET_MEMBER: 'set-member',
|
|
176
|
+
QUEUE: 'queue',
|
|
177
|
+
KNOWN: 'known',
|
|
178
|
+
LOCK: 'lock',
|
|
179
|
+
RECOVERY: 'recovery',
|
|
180
|
+
QUEUE_FORM: 'queue-form',
|
|
181
|
+
DISPLACED: 'displaced',
|
|
182
|
+
RENDERED: 'rendered',
|
|
183
|
+
JOURNAL: 'journal',
|
|
184
|
+
UNKNOWN: 'unknown',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What the move does with each class of entry, defined once.
|
|
189
|
+
*
|
|
190
|
+
* linkChecked: the refusal looks at an entry of the class before the move reads or
|
|
191
|
+
* changes anything, and refuses a link (a lock path must also be an empty directory).
|
|
192
|
+
* It holds for every class some move path renames or takes: the set members (renamed
|
|
193
|
+
* into the new folder), the queue files and the queue forms (renamed to a private and
|
|
194
|
+
* then a swept name), the owner file and the tuning files, the recovery files (moved),
|
|
195
|
+
* the two lock directories (taken) and the displaced copies (made, then left in the old
|
|
196
|
+
* folder). A rendered file, a journal-named file and an unknown entry are never read
|
|
197
|
+
* or renamed: a link among them never refuses a move.
|
|
198
|
+
*
|
|
199
|
+
* leftBehind: the entry stays in the backup, and one note lists it.
|
|
200
|
+
*/
|
|
201
|
+
const MOVE_ENTRY_CLASSES = Object.freeze({
|
|
202
|
+
[ENTRY.SET_MEMBER]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
203
|
+
[ENTRY.QUEUE]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
204
|
+
[ENTRY.KNOWN]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
205
|
+
[ENTRY.LOCK]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
206
|
+
[ENTRY.RECOVERY]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
207
|
+
[ENTRY.QUEUE_FORM]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
208
|
+
[ENTRY.DISPLACED]: Object.freeze({ linkChecked: true, leftBehind: false }),
|
|
209
|
+
[ENTRY.RENDERED]: Object.freeze({ linkChecked: false, leftBehind: false }),
|
|
210
|
+
[ENTRY.JOURNAL]: Object.freeze({ linkChecked: false, leftBehind: true }),
|
|
211
|
+
[ENTRY.UNKNOWN]: Object.freeze({ linkChecked: false, leftBehind: true }),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The class of an entry of an old folder or a backup folder, by its name: the one
|
|
216
|
+
* place that says what the move knows. The refusal, the whole-folder stray check, the
|
|
217
|
+
* entry listing and the backup checks all ask it, so no two of them can disagree.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} name
|
|
220
|
+
* @returns {string} one of the ENTRY classes
|
|
221
|
+
*/
|
|
222
|
+
function moveEntryClass(name) {
|
|
223
|
+
if (SET_MEMBERS.includes(name)) return ENTRY.SET_MEMBER;
|
|
224
|
+
if (name === QUEUE_NAME || name === CLAIM_NAME) return ENTRY.QUEUE;
|
|
225
|
+
if (KNOWN_NAMES.has(name)) return ENTRY.KNOWN;
|
|
226
|
+
if (name === STORE_LOCK_NAME || name === QUEUE_LOCK_NAME) return ENTRY.LOCK;
|
|
227
|
+
if (RECOVERY_FILE_RE.test(name)) return ENTRY.RECOVERY;
|
|
228
|
+
if (queueFormOf(name) !== null) return ENTRY.QUEUE_FORM;
|
|
229
|
+
if (DISPLACED_RES.some(re => re.test(name))) return ENTRY.DISPLACED;
|
|
230
|
+
if (RENDERED_NAMES.has(name)) return ENTRY.RENDERED;
|
|
231
|
+
if (name === OLD_FOLDER_JOURNAL_NAME) return ENTRY.JOURNAL;
|
|
232
|
+
return ENTRY.UNKNOWN;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** True for a class the refusal link-checks (MOVE_ENTRY_CLASSES). */
|
|
236
|
+
const isLinkChecked = cls => MOVE_ENTRY_CLASSES[cls].linkChecked;
|
|
237
|
+
|
|
238
|
+
/** True for a class that stays in the backup and is listed in a note (MOVE_ENTRY_CLASSES). */
|
|
239
|
+
const isLeftBehind = cls => MOVE_ENTRY_CLASSES[cls].leftBehind;
|
|
240
|
+
|
|
241
|
+
/** The op names that start every message an op answers. */
|
|
242
|
+
const MOVE_OP = 'move-ledger';
|
|
243
|
+
const SWEEP_OP = 'sweep-backups';
|
|
244
|
+
const ROWS_OP = 'backup-rows';
|
|
245
|
+
const QUEUE_FILES_OP = 'backup-queue-files';
|
|
246
|
+
|
|
247
|
+
/** True for a ledger root the ops accept before they look at the disk: a non-empty absolute path with no NUL. */
|
|
248
|
+
const isAbsoluteRoot = root => typeof root === 'string' && root !== '' && !root.includes('\0') && path.isAbsolute(root);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The error Result of an op that was refused before it changed anything.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} opName - the op, which starts the message
|
|
254
|
+
* @param {string} message
|
|
255
|
+
*/
|
|
256
|
+
function moveRefused(opName, message) {
|
|
257
|
+
return { ok: false, error: { kind: 'refused', message: `${opName}: ${message}; nothing was changed` } };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The error Result of an I/O failure during an op, or a rethrow for anything else.
|
|
262
|
+
* A failure that carries an fs error code is an expected outcome the caller reports;
|
|
263
|
+
* one without a code is a broken invariant and reaches the caller as a throw.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} opName - the op, which starts the message
|
|
266
|
+
* @param {unknown} err
|
|
267
|
+
*/
|
|
268
|
+
function moveIoFailure(opName, err) {
|
|
269
|
+
if (!err || typeof err.code !== 'string') throw err;
|
|
270
|
+
return { ok: false, error: { kind: 'io-error', message: `${opName}: ${err.code}: ${err.message}` } };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `result` with every message and note on one line (singleLine). A name that a
|
|
275
|
+
* repository holds (an entry, a backup folder) reaches a message through a path, and a
|
|
276
|
+
* name may carry a terminal escape sequence or a newline, so no message leaves the move
|
|
277
|
+
* ops with one. Answers a new Result and leaves `result` as it is.
|
|
278
|
+
*
|
|
279
|
+
* @param {{ ok: true, value: { notes?: string[] } } | { ok: false, error: { message: string } }} result
|
|
280
|
+
*/
|
|
281
|
+
function scrubMoveResult(result) {
|
|
282
|
+
if (!result.ok) return { ok: false, error: { ...result.error, message: singleLine(result.error.message) } };
|
|
283
|
+
const { notes } = result.value;
|
|
284
|
+
return Array.isArray(notes) ? { ok: true, value: { ...result.value, notes: notes.map(singleLine) } } : result;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* What is wrong with an entry the move will read or rename, or null when nothing is:
|
|
289
|
+
* a link is, and so is anything but an empty directory where a lock belongs, because
|
|
290
|
+
* a lock path holding a file, or a directory with something in it, can never be taken
|
|
291
|
+
* or broken, and acquireMkdirLock would retry it for ever. An absent entry is fine.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} file
|
|
294
|
+
* @param {{ lock?: boolean }} [opts]
|
|
295
|
+
* @returns {string|null}
|
|
296
|
+
*/
|
|
297
|
+
function moveEntryProblem(file, { lock = false } = {}) {
|
|
298
|
+
const stat = lstatOrNull(file);
|
|
299
|
+
if (stat === null) return null;
|
|
300
|
+
if (stat.isSymbolicLink()) return `${file} is a symbolic link`;
|
|
301
|
+
if (lock && !stat.isDirectory()) return `${file} is not a lock directory`;
|
|
302
|
+
if (lock && fs.readdirSync(file).length > 0) return `${file} is a lock directory that holds files`;
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Check a ledger root before the move reads or changes anything in it, and answer
|
|
308
|
+
* its physical path. The root must be a real ledger root: an absolute path to a
|
|
309
|
+
* directory that is not a link, not the home directory and not the machine
|
|
310
|
+
* directory. If any entry below the root that the move touches is a link, the move
|
|
311
|
+
* is refused: the root's `.devflow` directory, the old and new folders, each lock,
|
|
312
|
+
* and each allowlisted entry of both folders; in the whole-folder case every entry
|
|
313
|
+
* directly inside the old folder, because the single rename carries any link with
|
|
314
|
+
* it. The machine directory itself is not checked: a dotfile manager may link it, but
|
|
315
|
+
* the move lock and the journal that stand in it must not be links, and the journal
|
|
316
|
+
* must be a regular file. A file named like a journal in the old folder is no journal:
|
|
317
|
+
* it is an unknown entry.
|
|
318
|
+
* Nothing is created and nothing is locked here.
|
|
319
|
+
*
|
|
320
|
+
* @param {string} opName - the op, which starts every message
|
|
321
|
+
* @param {unknown} root
|
|
322
|
+
* @param {{ machineDir: string, homeDir: string|null }} dirs
|
|
323
|
+
* @returns {{ ok: true, value: { realRoot: string } } | { ok: false, error: { kind: 'refused', message: string } }}
|
|
324
|
+
*/
|
|
325
|
+
function curationMoveRefusal(opName, root, { machineDir, homeDir }) {
|
|
326
|
+
if (!isAbsoluteRoot(root)) return moveRefused(opName, 'the ledger root must be an absolute path');
|
|
327
|
+
const rootPath = path.resolve(root);
|
|
328
|
+
const rootStat = lstatOrNull(rootPath);
|
|
329
|
+
if (rootStat === null || !(rootStat.isDirectory() || rootStat.isSymbolicLink())) {
|
|
330
|
+
return moveRefused(opName, `${rootPath} is not a directory`);
|
|
331
|
+
}
|
|
332
|
+
if (rootStat.isSymbolicLink()) return moveRefused(opName, `the ledger root ${rootPath} is a symbolic link`);
|
|
333
|
+
const realRoot = fs.realpathSync(rootPath);
|
|
334
|
+
if (homeDir && physicalPath(homeDir) === realRoot) return moveRefused(opName, 'the ledger root is the home directory');
|
|
335
|
+
if (physicalPath(machineDir) === realRoot) return moveRefused(opName, 'the ledger root is the machine directory');
|
|
336
|
+
|
|
337
|
+
const devflowDir = path.join(rootPath, '.devflow');
|
|
338
|
+
const oldDir = path.join(devflowDir, LEGACY_NAMES.folder);
|
|
339
|
+
const newDir = path.join(devflowDir, CURATION_DIR_NAME);
|
|
340
|
+
const dirs = [];
|
|
341
|
+
for (const dir of [devflowDir, oldDir, newDir]) {
|
|
342
|
+
const stat = lstatOrNull(dir);
|
|
343
|
+
if (stat === null) continue;
|
|
344
|
+
if (stat.isSymbolicLink()) return moveRefused(opName, `${dir} is a symbolic link`);
|
|
345
|
+
if (!stat.isDirectory()) return moveRefused(opName, `${dir} is not a directory`);
|
|
346
|
+
dirs.push(dir);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const lockProblem = moveEntryProblem(curationMoveLockDir(machineDir, realRoot), { lock: true });
|
|
350
|
+
if (lockProblem !== null) return moveRefused(opName, lockProblem);
|
|
351
|
+
const journal = curationMoveJournalPath(machineDir, realRoot);
|
|
352
|
+
const journalStat = lstatOrNull(journal);
|
|
353
|
+
if (journalStat !== null && !journalStat.isFile()) return moveRefused(opName, `${journal} is not a regular file`);
|
|
354
|
+
|
|
355
|
+
const wholeFolder = dirs.includes(oldDir) && !dirs.includes(newDir);
|
|
356
|
+
for (const dir of dirs.filter(d => d !== devflowDir)) {
|
|
357
|
+
const entries = fs.readdirSync(dir);
|
|
358
|
+
if (entries.length > MOVE_MAX_ENTRIES) return moveRefused(opName, `${dir} holds more than ${MOVE_MAX_ENTRIES} entries`);
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
const cls = moveEntryClass(entry);
|
|
361
|
+
if (!(wholeFolder && dir === oldDir) && !isLinkChecked(cls)) continue;
|
|
362
|
+
const problem = moveEntryProblem(path.join(dir, entry), { lock: cls === ENTRY.LOCK });
|
|
363
|
+
if (problem !== null) return moveRefused(opName, problem);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { ok: true, value: { realRoot } };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Take every lock in `specs`, in order, each with no wait: a lock that is held and
|
|
371
|
+
* younger than its own stale age makes the whole attempt busy, and the locks already
|
|
372
|
+
* taken are released first. Locks are plain mkdir locks (acquireMkdirLock).
|
|
373
|
+
*
|
|
374
|
+
* @param {Array<{ name: string, dir: string, staleMs: number }>} specs
|
|
375
|
+
* @returns {{ busy: string|null, held: Array<{ name: string, dir: string }> }}
|
|
376
|
+
* busy: the name of the lock that was busy, else null; held: the locks taken
|
|
377
|
+
* (empty when busy)
|
|
378
|
+
* @throws on any other I/O failure, after releasing the locks it took
|
|
379
|
+
*/
|
|
380
|
+
function curationMoveLockAll(specs) {
|
|
381
|
+
const held = [];
|
|
382
|
+
try {
|
|
383
|
+
for (const spec of specs) {
|
|
384
|
+
if (!acquireMkdirLock(spec.dir, 0, spec.staleMs)) {
|
|
385
|
+
curationMoveReleaseAll(held);
|
|
386
|
+
return { busy: spec.name, held: [] };
|
|
387
|
+
}
|
|
388
|
+
held.push({ name: spec.name, dir: spec.dir });
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
curationMoveReleaseAll(held);
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
return { busy: null, held };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Release the locks `curationMoveLockAll` took, newest first, each at the path it is
|
|
399
|
+
* given. A lock that is already gone is fine.
|
|
400
|
+
*
|
|
401
|
+
* @param {Array<{ dir: string }>} held
|
|
402
|
+
*/
|
|
403
|
+
function curationMoveReleaseAll(held) {
|
|
404
|
+
for (let i = held.length - 1; i >= 0; i--) releaseLock(held[i].dir);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** `held` with every lock that sits under `from` now under `to`, after a rename of that folder. */
|
|
408
|
+
function relocateLocks(held, from, to) {
|
|
409
|
+
return held.map(lock => (lock.dir.startsWith(from + path.sep)
|
|
410
|
+
? { ...lock, dir: path.join(to, lock.dir.slice(from.length + 1)) }
|
|
411
|
+
: lock));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The whole-folder case: the new folder is absent, so the old folder is renamed to
|
|
416
|
+
* it. The project tuning file is renamed first, inside the old folder, so a crash
|
|
417
|
+
* between the two renames leaves only a renamed tuning file, and a rerun renames just
|
|
418
|
+
* the folder; a `curation.json` already there stays the tuning file. No per-entry rule
|
|
419
|
+
* applies: one rename(2) carries the whole folder, its locks included. A private,
|
|
420
|
+
* swept or overflow queue file sitting directly in the old folder refuses the move,
|
|
421
|
+
* because the rename would carry it into the live folder, where no sweep reads it. A
|
|
422
|
+
* file named like a journal never gets here: moveLedger takes the per-entry path for it
|
|
423
|
+
* (oldFolderNeedsEntryPath).
|
|
424
|
+
*
|
|
425
|
+
* @param {{ oldDir: string, newDir: string }} ctx
|
|
426
|
+
* @returns {{ ok: true, value: { notes: string[] } } | { ok: false, error: { kind: string, message: string } }}
|
|
427
|
+
* @throws on an I/O failure
|
|
428
|
+
*/
|
|
429
|
+
function curationMoveWhole(ctx) {
|
|
430
|
+
const stray = fs.readdirSync(ctx.oldDir).filter(name => moveEntryClass(name) === ENTRY.QUEUE_FORM);
|
|
431
|
+
if (stray.length > 0) {
|
|
432
|
+
return moveRefused(MOVE_OP, `${path.basename(ctx.oldDir)} holds ${stray.join(', ')}, which must not move into the live folder`);
|
|
433
|
+
}
|
|
434
|
+
const tuningFrom = path.join(ctx.oldDir, LEGACY_NAMES.tuningFile);
|
|
435
|
+
const tuningTo = path.join(ctx.oldDir, CURATION_TUNING_FILE_NAME);
|
|
436
|
+
const tuning = lstatOrNull(tuningFrom);
|
|
437
|
+
if (tuning !== null && tuning.isFile() && lstatOrNull(tuningTo) === null) fs.renameSync(tuningFrom, tuningTo);
|
|
438
|
+
fs.renameSync(ctx.oldDir, ctx.newDir);
|
|
439
|
+
return { ok: true, value: { notes: [`moved ${path.basename(ctx.oldDir)} to ${path.basename(ctx.newDir)}`] } };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The most lines a merge fills a pending queue to. It equals the number of lines the
|
|
444
|
+
* capture hooks' trim keeps (`tail -100` in queue-append). That trim starts only above
|
|
445
|
+
* 200 lines, so a merge never takes a queue within reach of it, and no merged row can
|
|
446
|
+
* be dropped by it. A row that does not fit goes to an overflow file.
|
|
447
|
+
*/
|
|
448
|
+
const QUEUE_MERGE_CAP_LINES = 100;
|
|
449
|
+
|
|
450
|
+
/** The largest queue file the move reads, in bytes: far above a queue the cap allows. */
|
|
451
|
+
const QUEUE_FILE_MAX_BYTES = 64 * 1024 * 1024;
|
|
452
|
+
|
|
453
|
+
/** Age past which an unfinished last line counts as written by a dead writer (ms). */
|
|
454
|
+
const DEAD_FRAGMENT_AGE_MS = 60000;
|
|
455
|
+
|
|
456
|
+
/** The most queue files one pass merges from one folder. */
|
|
457
|
+
const MOVE_MAX_QUEUE_FILES = 200;
|
|
458
|
+
|
|
459
|
+
/** The most unknown entry names a note lists. */
|
|
460
|
+
const MOVE_MAX_NAMES_LISTED = 20;
|
|
461
|
+
|
|
462
|
+
/** The largest ledger the move's re-render reads, in bytes: far above a ledger the store grows. */
|
|
463
|
+
const LEDGER_RENDER_MAX_BYTES = 64 * 1024 * 1024;
|
|
464
|
+
|
|
465
|
+
/** The folder under the ledger root that holds both ledger folders and the backups. */
|
|
466
|
+
const DEVFLOW_DIR_NAME = '.devflow';
|
|
467
|
+
|
|
468
|
+
/** `YYYYMMDDTHHMMSSZ` for an epoch ms. */
|
|
469
|
+
function utcStamp(now) {
|
|
470
|
+
return new Date(now).toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, '');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The unique tail of a name the move makes: a UTC stamp and eight random hex characters. */
|
|
474
|
+
function uniqueTail(now) {
|
|
475
|
+
return `${utcStamp(now)}-${crypto.randomBytes(4).toString('hex')}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Rename `from` to `to` without ever replacing an existing `to`: link(2) to the new
|
|
480
|
+
* name, which fails when the name exists, then unlink(2) of the old one. If the
|
|
481
|
+
* unlink fails after the link, the same file is under two names; a later run merges
|
|
482
|
+
* it again and the dedupe keeps that harmless. On a filesystem without hard links
|
|
483
|
+
* (EPERM or ENOTSUP) it renames instead, only where lstat shows the new name free,
|
|
484
|
+
* as the claim does. Any other link failure leaves `from` where it is and throws.
|
|
485
|
+
*
|
|
486
|
+
* @param {string} from
|
|
487
|
+
* @param {string} to
|
|
488
|
+
*/
|
|
489
|
+
function renameNoReplace(from, to) {
|
|
490
|
+
try {
|
|
491
|
+
fs.linkSync(from, to);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
if (!err || !NO_HARD_LINK_CODES.includes(err.code)) throw err;
|
|
494
|
+
if (lstatOrNull(to) !== null) throw Object.assign(new Error(`${path.basename(to)} already exists`), { code: 'EEXIST' });
|
|
495
|
+
fs.renameSync(from, to);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
fs.unlinkSync(from);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The text of a queue file, or null when nothing readable is there: a link, a folder
|
|
503
|
+
* or any other non-regular file reads as absent (D-NO-LINKED-READ, at readJsonl).
|
|
504
|
+
*
|
|
505
|
+
* @param {string} file
|
|
506
|
+
* @returns {string|null}
|
|
507
|
+
* @throws on a queue file larger than QUEUE_FILE_MAX_BYTES, or any other read error
|
|
508
|
+
*/
|
|
509
|
+
function readQueueText(file) {
|
|
510
|
+
const stat = lstatOrNull(file);
|
|
511
|
+
if (stat === null || !stat.isFile()) return null;
|
|
512
|
+
if (stat.size > QUEUE_FILE_MAX_BYTES) {
|
|
513
|
+
throw Object.assign(new Error(`${path.basename(file)} is larger than ${QUEUE_FILE_MAX_BYTES} bytes`), { code: 'EFBIG' });
|
|
514
|
+
}
|
|
515
|
+
return readTextUnlinked(file, { maxBytes: QUEUE_FILE_MAX_BYTES });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Create `file` exclusively, without following a link, at mode 0600, holding `content`. */
|
|
519
|
+
function writeNewPrivateFile(file, content) {
|
|
520
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0);
|
|
521
|
+
const fd = fs.openSync(file, flags, 0o600);
|
|
522
|
+
try {
|
|
523
|
+
fs.writeFileSync(fd, content);
|
|
524
|
+
fs.fsyncSync(fd);
|
|
525
|
+
} finally {
|
|
526
|
+
fs.closeSync(fd);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The pending queue of a target folder, read once: its line count and its non-blank
|
|
532
|
+
* lines. The count is the number of line ends, as the capture hooks' trim counts.
|
|
533
|
+
*
|
|
534
|
+
* @param {string} targetDir
|
|
535
|
+
* @returns {{ count: number, lines: string[] }}
|
|
536
|
+
*/
|
|
537
|
+
function readPendingQueue(targetDir) {
|
|
538
|
+
const text = readQueueText(path.join(targetDir, QUEUE_NAME)) || '';
|
|
539
|
+
return { count: text.split('\n').length - 1, lines: text.split('\n').filter(line => line.trim() !== '') };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The lines a merge must not append again: every non-blank line of the target's
|
|
544
|
+
* processing file, read once for the whole merged file. The pending lines join the set
|
|
545
|
+
* as each merge step reads them (mergeQueueLines).
|
|
546
|
+
*
|
|
547
|
+
* @param {string} targetDir
|
|
548
|
+
* @returns {Set<string>}
|
|
549
|
+
*/
|
|
550
|
+
function readClaimedLines(targetDir) {
|
|
551
|
+
const text = readQueueText(path.join(targetDir, CLAIM_NAME)) || '';
|
|
552
|
+
return new Set(text.split('\n').filter(line => line.trim() !== ''));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Merge the complete `lines` of a queue file into the target folder's pending queue:
|
|
557
|
+
* only the lines it does not already hold verbatim, appended in one write at mode
|
|
558
|
+
* 0600, and only while the queue stays within QUEUE_MERGE_CAP_LINES. The lines that do
|
|
559
|
+
* not fit go to a new overflow file in `fromDir`. The target queue is appended to,
|
|
560
|
+
* never replaced, because a replace would drop an append that lands during the merge.
|
|
561
|
+
* The pending queue is read again before each append, so a capture that landed since
|
|
562
|
+
* the last step counts against the cap.
|
|
563
|
+
*
|
|
564
|
+
* @param {{ now: number }} ctx
|
|
565
|
+
* @param {string[]} lines
|
|
566
|
+
* @param {string} targetDir
|
|
567
|
+
* @param {string} fromDir
|
|
568
|
+
* @param {Set<string>} seen - the lines the target already holds; the step adds what it reads and appends
|
|
569
|
+
* @returns {{ merged: number, overflowed: number }}
|
|
570
|
+
*/
|
|
571
|
+
function mergeQueueLines(ctx, lines, targetDir, fromDir, seen) {
|
|
572
|
+
if (lines.length === 0) return { merged: 0, overflowed: 0 };
|
|
573
|
+
const pending = readPendingQueue(targetDir);
|
|
574
|
+
for (const line of pending.lines) seen.add(line);
|
|
575
|
+
const fresh = lines.filter(line => !seen.has(line));
|
|
576
|
+
const room = Math.max(0, QUEUE_MERGE_CAP_LINES - pending.count);
|
|
577
|
+
const fit = fresh.slice(0, room);
|
|
578
|
+
const over = fresh.slice(room);
|
|
579
|
+
if (fit.length > 0) {
|
|
580
|
+
appendNoFollow(path.join(targetDir, QUEUE_NAME), `${fit.join('\n')}\n`, 0o600);
|
|
581
|
+
for (const line of fit) seen.add(line);
|
|
582
|
+
}
|
|
583
|
+
if (over.length > 0) writeNewPrivateFile(path.join(fromDir, `${QUEUE_NAME}.overflow-${uniqueTail(ctx.now)}`), `${over.join('\n')}\n`);
|
|
584
|
+
return { merged: fit.length, overflowed: over.length };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The non-blank lines of `text`, which ends at a line end. */
|
|
588
|
+
const completeLines = text => text.split('\n').filter(line => line.trim() !== '');
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Merge one private or overflow queue file into the target folder's pending queue,
|
|
592
|
+
* then mark it swept. It reads the file, merges its newline-terminated lines, reads
|
|
593
|
+
* it again and merges whatever a writer appended meanwhile, and renames it to a swept
|
|
594
|
+
* name: a swept file is never unlinked and never read again. A trailing fragment
|
|
595
|
+
* stays unmerged and the file stays unswept for the next run, unless its writer is
|
|
596
|
+
* taken to be dead: the fragment is unchanged across the re-read and the file is
|
|
597
|
+
* older than DEAD_FRAGMENT_AGE_MS, and then it goes to the queue's rejected file
|
|
598
|
+
* through the store's quarantine rule and the file is swept. An overflow file is
|
|
599
|
+
* merged only while the target has room. The target's processing file is read once
|
|
600
|
+
* for the whole merged file.
|
|
601
|
+
*
|
|
602
|
+
* @param {{ now: number }} ctx
|
|
603
|
+
* @param {string} file - the queue file, in a form queueFormOf names
|
|
604
|
+
* @param {string} targetDir - the folder whose pending queue receives the lines
|
|
605
|
+
* @returns {{ merged: number, overflowed: number, swept: boolean, note: string|null }}
|
|
606
|
+
*/
|
|
607
|
+
function mergeQueueFile(ctx, file, targetDir) {
|
|
608
|
+
const dir = path.dirname(file);
|
|
609
|
+
const record = queueFormOf(path.basename(file));
|
|
610
|
+
const idle = { merged: 0, overflowed: 0, swept: false, note: null };
|
|
611
|
+
if (record.form === 'overflow' && readPendingQueue(targetDir).count >= QUEUE_MERGE_CAP_LINES) return idle;
|
|
612
|
+
|
|
613
|
+
const first = readQueueText(file);
|
|
614
|
+
if (first === null) return idle;
|
|
615
|
+
const seen = readClaimedLines(targetDir);
|
|
616
|
+
const complete = first.slice(0, first.lastIndexOf('\n') + 1);
|
|
617
|
+
const fragment = first.slice(complete.length);
|
|
618
|
+
const one = mergeQueueLines(ctx, completeLines(complete), targetDir, dir, seen);
|
|
619
|
+
|
|
620
|
+
const second = readQueueText(file);
|
|
621
|
+
if (second === null) return { ...one, swept: false, note: null };
|
|
622
|
+
const rest = second.startsWith(complete) ? second.slice(complete.length) : second;
|
|
623
|
+
const appended = rest.slice(0, rest.lastIndexOf('\n') + 1);
|
|
624
|
+
const trailing = rest.slice(appended.length);
|
|
625
|
+
const two = mergeQueueLines(ctx, completeLines(appended), targetDir, dir, seen);
|
|
626
|
+
const total = { merged: one.merged + two.merged, overflowed: one.overflowed + two.overflowed };
|
|
627
|
+
|
|
628
|
+
let note = null;
|
|
629
|
+
if (trailing.trim() !== '') {
|
|
630
|
+
const stat = lstatOrNull(file);
|
|
631
|
+
const dead = stat !== null && stat.isFile() && trailing === fragment && ctx.now - stat.mtimeMs > DEAD_FRAGMENT_AGE_MS;
|
|
632
|
+
if (!dead) {
|
|
633
|
+
return { ...total, swept: false, note: `${path.basename(file)} ends in an unfinished line and stays for the next sweep` };
|
|
634
|
+
}
|
|
635
|
+
const line = second.slice(0, second.length - trailing.length).split('\n').length;
|
|
636
|
+
quarantineRejected(path.join(dir, QUEUE_NAME), [{ line, text: trailing }], { now: ctx.now, mode: 0o600 });
|
|
637
|
+
note = `${path.basename(file)} ended in an unfinished line from a writer that is gone; it went to the rejected file`;
|
|
638
|
+
}
|
|
639
|
+
renameNoReplace(file, path.join(dir, `${record.base}.swept-${uniqueTail(ctx.now)}`));
|
|
640
|
+
return { ...total, swept: true, note };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The queue rule: merge a folder's pending queue (and, for a stale claim, its
|
|
645
|
+
* processing batch) into the target folder's pending queue.
|
|
646
|
+
*
|
|
647
|
+
* Every private file already in the folder (left by a crash or an earlier error) is
|
|
648
|
+
* merged first, before a new private name is made. Then each live queue file in
|
|
649
|
+
* `bases` is renamed, no-replace, to a new private name, so later appends by path
|
|
650
|
+
* create a fresh queue that the next run catches, and the private file goes through
|
|
651
|
+
* mergeQueueFile. With `overflow` set, overflow files are merged too.
|
|
652
|
+
*
|
|
653
|
+
* @param {{ now: number }} ctx
|
|
654
|
+
* @param {{ dir: string, targetDir: string, bases: string[], overflow?: boolean }} opts
|
|
655
|
+
* dir: the folder holding the files; targetDir: the folder whose queue receives the
|
|
656
|
+
* lines; bases: the live queue files to rename and merge, in order
|
|
657
|
+
* @returns {{ files: number, merged: number, overflowed: number, notes: string[] }}
|
|
658
|
+
*/
|
|
659
|
+
function curationMoveMergeQueue(ctx, { dir, targetDir, bases, overflow = false }) {
|
|
660
|
+
const total = { files: 0, merged: 0, overflowed: 0, notes: [] };
|
|
661
|
+
const take = file => {
|
|
662
|
+
const result = mergeQueueFile(ctx, file, targetDir);
|
|
663
|
+
total.merged += result.merged;
|
|
664
|
+
total.overflowed += result.overflowed;
|
|
665
|
+
if (result.swept) total.files++;
|
|
666
|
+
if (result.note !== null) total.notes.push(result.note);
|
|
667
|
+
};
|
|
668
|
+
const isWanted = name => {
|
|
669
|
+
const record = queueFormOf(name);
|
|
670
|
+
return record !== null && (record.form === 'private' || (overflow && record.form === 'overflow'));
|
|
671
|
+
};
|
|
672
|
+
const waiting = fs.readdirSync(dir).filter(isWanted).sort().slice(0, MOVE_MAX_QUEUE_FILES);
|
|
673
|
+
for (const name of waiting) take(path.join(dir, name));
|
|
674
|
+
|
|
675
|
+
for (const base of bases) {
|
|
676
|
+
const live = path.join(dir, base);
|
|
677
|
+
const stat = lstatOrNull(live);
|
|
678
|
+
if (stat === null || !stat.isFile()) continue;
|
|
679
|
+
const privateFile = path.join(dir, `${base}.merging-${uniqueTail(ctx.now)}`);
|
|
680
|
+
renameNoReplace(live, privateFile);
|
|
681
|
+
take(privateFile);
|
|
682
|
+
}
|
|
683
|
+
return total;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The backup sweep, shared by the race close at the end of a move and by init: run the
|
|
688
|
+
* queue rule over a backup folder's pending and processing files and over every unswept
|
|
689
|
+
* private and overflow file in it, into the new folder's pending queue. With the new
|
|
690
|
+
* folder absent it does nothing. The caller holds the move lock and the new folder's
|
|
691
|
+
* queue lock.
|
|
692
|
+
*
|
|
693
|
+
* @param {{ now: number, newDir: string }} ctx
|
|
694
|
+
* @param {string} backupDir
|
|
695
|
+
* @returns {{ files: number, merged: number, overflowed: number, notes: string[] }}
|
|
696
|
+
*/
|
|
697
|
+
function curationMoveSweepBackup(ctx, backupDir) {
|
|
698
|
+
if (lstatOrNull(ctx.newDir) === null) return { files: 0, merged: 0, overflowed: 0, notes: [] };
|
|
699
|
+
return curationMoveMergeQueue(ctx, { dir: backupDir, targetDir: ctx.newDir, bases: [QUEUE_NAME, CLAIM_NAME], overflow: true });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** True when the new folder's observation log holds data: it is a regular file with bytes in it. */
|
|
703
|
+
function newFolderHasData(ctx) {
|
|
704
|
+
const stat = lstatOrNull(path.join(ctx.newDir, SET_MEMBERS[0]));
|
|
705
|
+
return stat !== null && stat.isFile() && stat.size > 0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* What a journal records: a step is either the displacement of the new folder's copy
|
|
710
|
+
* of a set member into the old folder under a displaced name, or the move of the old
|
|
711
|
+
* folder's member into the new folder.
|
|
712
|
+
*
|
|
713
|
+
* @typedef {{ op: 'displace', name: string, to: string } | { op: 'move', name: string }} MoveStep
|
|
714
|
+
*/
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* What identifies a folder across passes: the device, the inode and the creation time
|
|
718
|
+
* that a bigint lstat reports, each as a decimal string. A folder that was deleted and
|
|
719
|
+
* made again is another folder, whatever its name. The inode number alone does not say
|
|
720
|
+
* so: Linux file systems often give the freed inode number of a deleted folder to the
|
|
721
|
+
* next one made, so the creation time (birthtimeNs) is the part that tells them apart.
|
|
722
|
+
* Where the file system reports no creation time (zero), `birth` is null, and such an
|
|
723
|
+
* identity confirms no folder: a journal that holds one is dropped, and the move decides
|
|
724
|
+
* afresh. A folder made again within one kernel clock tick of the first, with the same
|
|
725
|
+
* inode number, is the one case that stays unseen.
|
|
726
|
+
*
|
|
727
|
+
* @typedef {{ dev: string, ino: string, birth: string|null }} FolderIdentity
|
|
728
|
+
*/
|
|
729
|
+
|
|
730
|
+
/** The bigint lstat of `file`, or null when nothing is there. @param {string} file @returns {import('fs').BigIntStats|null} @throws on another I/O failure */
|
|
731
|
+
function lstatBigintOrNull(file) {
|
|
732
|
+
try {
|
|
733
|
+
return fs.lstatSync(file, { bigint: true });
|
|
734
|
+
} catch (err) {
|
|
735
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
|
|
736
|
+
throw err;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** @param {import('fs').BigIntStats} stat @returns {FolderIdentity} */
|
|
741
|
+
const folderIdentity = stat => ({
|
|
742
|
+
dev: String(stat.dev),
|
|
743
|
+
ino: String(stat.ino),
|
|
744
|
+
birth: stat.birthtimeNs > 0n ? String(stat.birthtimeNs) : null,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* True when `identity` is the identity of the folder `stat` describes. False when either
|
|
749
|
+
* side has no creation time: the inode number alone is not enough to say it is the same folder.
|
|
750
|
+
*
|
|
751
|
+
* @param {FolderIdentity} identity
|
|
752
|
+
* @param {import('fs').BigIntStats|null} stat
|
|
753
|
+
*/
|
|
754
|
+
function isSameFolder(identity, stat) {
|
|
755
|
+
if (stat === null || identity.birth === null) return false;
|
|
756
|
+
const now = folderIdentity(stat);
|
|
757
|
+
return now.birth !== null && identity.dev === now.dev && identity.ino === now.ino && identity.birth === now.birth;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** True for a string that a journal can hold as a device, an inode or a creation time: decimal digits. */
|
|
761
|
+
const isIdentityDigits = value => typeof value === 'string' && /^[0-9]{1,24}$/.test(value);
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The steps of a journal file and the identity of the old folder it was planned for, or
|
|
765
|
+
* null when the file is not a journal this module wrote: unreadable, not JSON, naming
|
|
766
|
+
* anything but a set member and a displaced name of that member, or with no folder
|
|
767
|
+
* identity. A journal is trusted only that far, and only the machine-side file is ever
|
|
768
|
+
* read: the folders can hold a file a repository committed.
|
|
769
|
+
*
|
|
770
|
+
* @param {string} file
|
|
771
|
+
* @returns {{ steps: MoveStep[], oldDir: FolderIdentity }|null}
|
|
772
|
+
*/
|
|
773
|
+
function readMoveJournal(file) {
|
|
774
|
+
const text = readTextUnlinked(file, { maxBytes: MOVE_JOURNAL_MAX_BYTES });
|
|
775
|
+
if (text === null) return null;
|
|
776
|
+
let parsed;
|
|
777
|
+
try {
|
|
778
|
+
parsed = JSON.parse(text);
|
|
779
|
+
} catch {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
if (!isPlainObject(parsed) || parsed.version !== 1 || !Array.isArray(parsed.steps)) return null;
|
|
783
|
+
if (parsed.steps.length > SET_MEMBERS.length * 2) return null;
|
|
784
|
+
const recordedDir = parsed.oldDir;
|
|
785
|
+
if (!isPlainObject(recordedDir) || !isIdentityDigits(recordedDir.dev) || !isIdentityDigits(recordedDir.ino)) return null;
|
|
786
|
+
if (recordedDir.birth !== null && !isIdentityDigits(recordedDir.birth)) return null;
|
|
787
|
+
const steps = [];
|
|
788
|
+
for (const step of parsed.steps) {
|
|
789
|
+
if (!isPlainObject(step)) return null;
|
|
790
|
+
const index = SET_MEMBERS.indexOf(step.name);
|
|
791
|
+
if (index === -1) return null;
|
|
792
|
+
if (step.op === 'move') steps.push({ op: 'move', name: step.name });
|
|
793
|
+
else if (step.op === 'displace' && typeof step.to === 'string' && DISPLACED_RES[index].test(step.to)) {
|
|
794
|
+
steps.push({ op: 'displace', name: step.name, to: step.to });
|
|
795
|
+
} else return null;
|
|
796
|
+
}
|
|
797
|
+
return { steps, oldDir: { dev: recordedDir.dev, ino: recordedDir.ino, birth: recordedDir.birth } };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Drop a machine-side journal that was planned for an old folder other than the one that
|
|
802
|
+
* stands now, or whose old folder cannot be confirmed to be this one. The journal lives in the machine directory and the old folder can be
|
|
803
|
+
* deleted and made again (a downgrade that starts a new one, a manual delete), so a
|
|
804
|
+
* journal that outlives its folder would replay its steps against another folder's files
|
|
805
|
+
* and move the live set into the backup. A journal that cannot be read is left for
|
|
806
|
+
* curationMoveSetWithJournal, which removes it with its own note. Only a regular file is
|
|
807
|
+
* removed, never a link.
|
|
808
|
+
*
|
|
809
|
+
* @param {{ oldDir: string, journal: string }} ctx
|
|
810
|
+
* @returns {string[]} one note when a journal was dropped, else none
|
|
811
|
+
* @throws on an I/O failure
|
|
812
|
+
*/
|
|
813
|
+
function curationMoveDropStaleJournal(ctx) {
|
|
814
|
+
const stat = lstatOrNull(ctx.journal);
|
|
815
|
+
if (stat === null || !stat.isFile()) return [];
|
|
816
|
+
const journal = readMoveJournal(ctx.journal);
|
|
817
|
+
if (journal === null || isSameFolder(journal.oldDir, lstatBigintOrNull(ctx.oldDir))) return [];
|
|
818
|
+
removeIfPresent(ctx.journal);
|
|
819
|
+
return [`dropped a move journal that was not confirmed to belong to this ${path.basename(ctx.oldDir)} folder; the move decides afresh`];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Carry out one journal step when its source still exists and its target does not.
|
|
824
|
+
* A step whose source and target both exist is left undone and nothing is overwritten.
|
|
825
|
+
* For a move step that is a split set, a member present in both folders, and it is
|
|
826
|
+
* reported by name. For a displace step it is the normal state of a resumed move (the
|
|
827
|
+
* member that was displaced has since moved in), so it is not reported.
|
|
828
|
+
*
|
|
829
|
+
* @returns {{ renamed: boolean, note: string|null }}
|
|
830
|
+
*/
|
|
831
|
+
function applyMoveStep(ctx, step) {
|
|
832
|
+
const from = step.op === 'move' ? path.join(ctx.oldDir, step.name) : path.join(ctx.newDir, step.name);
|
|
833
|
+
const to = step.op === 'move' ? path.join(ctx.newDir, step.name) : path.join(ctx.oldDir, step.to);
|
|
834
|
+
if (lstatOrNull(from) === null) return { renamed: false, note: null };
|
|
835
|
+
if (lstatOrNull(to) !== null) {
|
|
836
|
+
const note = step.op === 'move'
|
|
837
|
+
? `${step.name} exists in both ${path.basename(ctx.newDir)} and ${path.basename(ctx.oldDir)}; both were left in place`
|
|
838
|
+
: null;
|
|
839
|
+
return { renamed: false, note };
|
|
840
|
+
}
|
|
841
|
+
fs.renameSync(from, to);
|
|
842
|
+
return { renamed: true, note: null };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* The set move: the old folder's ledger set moves into the new folder, which has no
|
|
847
|
+
* log data to lose, in a journalled order that a crash cannot leave half done.
|
|
848
|
+
*
|
|
849
|
+
* For each set member the old folder holds, the new folder's copy (empty or not) is
|
|
850
|
+
* first displaced into the old folder under a displaced name, never overwritten; then
|
|
851
|
+
* the members are renamed in, in the fixed order: log, archive, history, and the
|
|
852
|
+
* ledger last. The plan is written first to the root's journal in the machine
|
|
853
|
+
* directory, `ctx.journal` (mode 0600, created exclusively, never through a link), with
|
|
854
|
+
* the identity (device, inode and creation time) of the old folder it was planned for, and the journal
|
|
855
|
+
* is removed once the set is complete. Only that file is a journal: one in
|
|
856
|
+
* the old folder is repository content and is never read. If a journal exists when the
|
|
857
|
+
* op starts, the op completes each listed step whose source still exists and whose
|
|
858
|
+
* target does not, and does not decide the winner again. An error before the first
|
|
859
|
+
* rename removes the journal; after one the journal stays and the next run completes
|
|
860
|
+
* it. While a journal exists every store write on the root returns busy.
|
|
861
|
+
*
|
|
862
|
+
* @param {{ oldDir: string, newDir: string, journal: string, now: number }} ctx
|
|
863
|
+
* @returns {{ notes: string[] }}
|
|
864
|
+
* @throws on an I/O failure
|
|
865
|
+
*/
|
|
866
|
+
function curationMoveSetWithJournal(ctx) {
|
|
867
|
+
const notes = [];
|
|
868
|
+
const journal = ctx.journal;
|
|
869
|
+
let steps = null;
|
|
870
|
+
if (lstatOrNull(journal) !== null) {
|
|
871
|
+
const recorded = readMoveJournal(journal);
|
|
872
|
+
steps = recorded === null ? null : recorded.steps;
|
|
873
|
+
if (steps === null) {
|
|
874
|
+
removeIfPresent(journal);
|
|
875
|
+
notes.push('removed an unreadable move journal; no rename had been recorded');
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
const resumed = steps !== null;
|
|
879
|
+
if (!resumed) {
|
|
880
|
+
const members = SET_MEMBERS.filter(name => {
|
|
881
|
+
const stat = lstatOrNull(path.join(ctx.oldDir, name));
|
|
882
|
+
return stat !== null && stat.isFile();
|
|
883
|
+
});
|
|
884
|
+
steps = [
|
|
885
|
+
...members.filter(name => lstatOrNull(path.join(ctx.newDir, name)) !== null)
|
|
886
|
+
.map(name => ({ op: 'displace', name, to: `${name}.displaced-${uniqueTail(ctx.now)}` })),
|
|
887
|
+
...members.map(name => ({ op: 'move', name })),
|
|
888
|
+
];
|
|
889
|
+
if (steps.length === 0) return { notes };
|
|
890
|
+
try {
|
|
891
|
+
writeNewPrivateFile(journal, JSON.stringify({ version: 1, oldDir: folderIdentity(fs.lstatSync(ctx.oldDir, { bigint: true })), steps }));
|
|
892
|
+
} catch (err) {
|
|
893
|
+
removeIfPresent(journal);
|
|
894
|
+
throw err;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
let renamed = 0;
|
|
898
|
+
try {
|
|
899
|
+
for (const step of steps) {
|
|
900
|
+
const applied = applyMoveStep(ctx, step);
|
|
901
|
+
if (applied.renamed) renamed++;
|
|
902
|
+
if (applied.note !== null) notes.push(applied.note);
|
|
903
|
+
}
|
|
904
|
+
} catch (err) {
|
|
905
|
+
if (!resumed && renamed === 0) removeIfPresent(journal);
|
|
906
|
+
throw err;
|
|
907
|
+
}
|
|
908
|
+
removeIfPresent(journal);
|
|
909
|
+
return { notes };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* The entries of the old folder other than the ledger set and the queue: the project
|
|
914
|
+
* tuning file moves in under its curation name when the new folder lacks one, and a
|
|
915
|
+
* recovery file (`*.rejected.jsonl`, `*.pre-v2.jsonl`) moves when the new folder lacks
|
|
916
|
+
* a file of that name; otherwise the old one stays in the old folder, which becomes
|
|
917
|
+
* the backup, with one note. Rendered files, locks, claim state and queue forms are
|
|
918
|
+
* never moved. An entry of an unknown or journal class stays and is listed in a note.
|
|
919
|
+
*
|
|
920
|
+
* @param {{ oldDir: string, newDir: string }} ctx
|
|
921
|
+
* @returns {{ notes: string[] }}
|
|
922
|
+
* @throws on an I/O failure
|
|
923
|
+
*/
|
|
924
|
+
function curationMoveEntries(ctx) {
|
|
925
|
+
const notes = [];
|
|
926
|
+
const entries = fs.readdirSync(ctx.oldDir).sort();
|
|
927
|
+
const leftBehind = [];
|
|
928
|
+
const move = name => {
|
|
929
|
+
const targetName = name === LEGACY_NAMES.tuningFile ? CURATION_TUNING_FILE_NAME : name;
|
|
930
|
+
if (lstatOrNull(path.join(ctx.newDir, targetName)) !== null) {
|
|
931
|
+
notes.push(`${targetName} already exists in ${path.basename(ctx.newDir)}; ${name} stays in the backup`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
fs.renameSync(path.join(ctx.oldDir, name), path.join(ctx.newDir, targetName));
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const tuning = [CURATION_TUNING_FILE_NAME, LEGACY_NAMES.tuningFile]
|
|
938
|
+
.find(name => { const stat = lstatOrNull(path.join(ctx.oldDir, name)); return stat !== null && stat.isFile(); });
|
|
939
|
+
if (tuning !== undefined) move(tuning);
|
|
940
|
+
for (const name of entries) {
|
|
941
|
+
const cls = moveEntryClass(name);
|
|
942
|
+
if (cls === ENTRY.RECOVERY) {
|
|
943
|
+
const stat = lstatOrNull(path.join(ctx.oldDir, name));
|
|
944
|
+
if (stat !== null && stat.isFile()) move(name);
|
|
945
|
+
} else if (isLeftBehind(cls)) {
|
|
946
|
+
leftBehind.push(name);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (leftBehind.length > 0) {
|
|
950
|
+
const shown = leftBehind.slice(0, MOVE_MAX_NAMES_LISTED).join(', ');
|
|
951
|
+
notes.push(`left in the backup, not moved: ${shown}${leftBehind.length > MOVE_MAX_NAMES_LISTED ? ', ...' : ''}`);
|
|
952
|
+
}
|
|
953
|
+
return { notes };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* The end of a pass, while it holds the new folder's store lock: rebuild the new
|
|
958
|
+
* folder's rendered files, so no index names the old folder; then, when the old folder
|
|
959
|
+
* is still there, rename it to the backup name (the legacy prefix, a UTC stamp and a
|
|
960
|
+
* random suffix) and run the race-close sweep over it, which merges an append that
|
|
961
|
+
* landed in the old folder before the rename. A rebuild or a sweep that fails is
|
|
962
|
+
* reported and does not undo or stop the rest: the move stays done. A ledger larger
|
|
963
|
+
* than LEDGER_RENDER_MAX_BYTES is not read: the rebuild is skipped with a note, the
|
|
964
|
+
* rendered files stay as they are, and `rendered` is false. The size is read from the
|
|
965
|
+
* file before anything is rendered, so a render never starts from a partial read.
|
|
966
|
+
*
|
|
967
|
+
* Rule: no step after a folder rename may throw before the locks are released at their
|
|
968
|
+
* new path (locksAfter). In this function the rename of the old folder to its backup is
|
|
969
|
+
* the last step that may throw. A step added after it must not throw, or must update
|
|
970
|
+
* locksAfter.
|
|
971
|
+
*
|
|
972
|
+
* @param {{ root: string, oldDir: string, newDir: string, devflowDir: string, now: number }} ctx
|
|
973
|
+
* @param {{ whole: boolean }} opts - whole: the folder itself was renamed, so there is no backup
|
|
974
|
+
* @returns {{ notes: string[], rendered: boolean, backup: string|null }}
|
|
975
|
+
* @throws when the backup rename fails
|
|
976
|
+
*/
|
|
977
|
+
function curationMoveFinish(ctx, { whole }) {
|
|
978
|
+
const notes = [];
|
|
979
|
+
let rendered = true;
|
|
980
|
+
try {
|
|
981
|
+
const ledger = lstatOrNull(path.join(ctx.newDir, LEDGER_NAME));
|
|
982
|
+
if (ledger !== null && ledger.isFile() && ledger.size > LEDGER_RENDER_MAX_BYTES) {
|
|
983
|
+
rendered = false;
|
|
984
|
+
notes.push(`the rendered files were not rebuilt: ${LEDGER_NAME} is larger than ${LEDGER_RENDER_MAX_BYTES} bytes`);
|
|
985
|
+
} else {
|
|
986
|
+
renderAll(ctx.root, readJsonl(path.join(ctx.newDir, LEDGER_NAME)).rows, ctx.newDir);
|
|
987
|
+
}
|
|
988
|
+
} catch (err) {
|
|
989
|
+
rendered = false;
|
|
990
|
+
notes.push(`the rendered files could not be rebuilt (re-render failed: ${err && err.message})`);
|
|
991
|
+
}
|
|
992
|
+
if (whole) return { notes, rendered, backup: null };
|
|
993
|
+
|
|
994
|
+
let backup = null;
|
|
995
|
+
for (let attempt = 0; attempt < 5 && backup === null; attempt++) {
|
|
996
|
+
const name = `${LEGACY_NAMES.backupPrefix}${uniqueTail(ctx.now)}`;
|
|
997
|
+
if (lstatOrNull(path.join(ctx.devflowDir, name)) === null) backup = name;
|
|
998
|
+
}
|
|
999
|
+
if (backup === null) throw Object.assign(new Error('no free backup name'), { code: 'EEXIST' });
|
|
1000
|
+
const backupDir = path.join(ctx.devflowDir, backup);
|
|
1001
|
+
fs.renameSync(ctx.oldDir, backupDir);
|
|
1002
|
+
try {
|
|
1003
|
+
const swept = curationMoveSweepBackup(ctx, backupDir);
|
|
1004
|
+
notes.push(...swept.notes);
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
notes.push(`the backup sweep stopped (${err && err.code}: ${err && err.message}); the next init finishes it`);
|
|
1007
|
+
}
|
|
1008
|
+
return { notes, rendered, backup };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Move a project's old ledger folder to the new name, once.
|
|
1013
|
+
*
|
|
1014
|
+
* The root must be a real ledger root (curationMoveRefusal). With no old folder the
|
|
1015
|
+
* answer is `none`, from two lstat calls, and nothing is locked, created or read; a
|
|
1016
|
+
* journal that is left in the machine directory is stale then, and the next store write
|
|
1017
|
+
* on the root removes it (moveJournalHoldsWriters).
|
|
1018
|
+
*
|
|
1019
|
+
* Right after the refusal checks, and before it makes, locks, reads or renames anything,
|
|
1020
|
+
* the pass asks whether git tracks any entry under a data folder or a backup folder, or
|
|
1021
|
+
* an entry named `.devflow` itself (checkTrackedAreas). If it does, or if a `.git` entry
|
|
1022
|
+
* stands and git does not answer, the answer is `skipped` with the reason `tracked` or
|
|
1023
|
+
* `unknown`, and nothing at all changes: the folders, the machine directory and the
|
|
1024
|
+
* journal stay as they are. With `unknown` the answer also carries `cause`, one fixed
|
|
1025
|
+
* word that says why git did not answer (UNKNOWN_CAUSES in tracked-areas.cjs). A root
|
|
1026
|
+
* with no `.git` entry makes no git call.
|
|
1027
|
+
*
|
|
1028
|
+
* Otherwise the machine directory is made if absent, and every lock the pass needs is
|
|
1029
|
+
* taken with no wait: the move lock, the old folder's store lock and queue lock, and,
|
|
1030
|
+
* when the new folder exists, its two. Any busy lock releases what was taken and answers
|
|
1031
|
+
* `busy`; a fresh claim in either folder answers `busy` with the reason `claim`. With
|
|
1032
|
+
* the new folder absent the whole folder is renamed (curationMoveWhole), unless the old
|
|
1033
|
+
* folder holds a file named like a journal (oldFolderNeedsEntryPath): then an empty new
|
|
1034
|
+
* folder is made and the pass takes the per-entry path. A pass that then answers busy,
|
|
1035
|
+
* or fails before it changes anything, removes that empty folder again. Errors (EXDEV,
|
|
1036
|
+
* EBUSY, EACCES, EROFS, any other I/O failure) answer an `io-error` Result and copy
|
|
1037
|
+
* nothing: the old folder stays whole.
|
|
1038
|
+
*
|
|
1039
|
+
* @param {unknown} root - the ledger root, an absolute path
|
|
1040
|
+
* @param {{ machineDir?: string, homeDir?: string, now?: number }} [options]
|
|
1041
|
+
* machineDir: the machine root (default `$HOME/.devflow`); homeDir: the home directory
|
|
1042
|
+
* (default `$HOME`); now: epoch ms (default Date.now())
|
|
1043
|
+
* Every message and note is one line with no control character (scrubMoveResult).
|
|
1044
|
+
*
|
|
1045
|
+
* @returns {{ ok: true, value: { state: 'none', notes: string[] }
|
|
1046
|
+
* | { state: 'busy', reason?: 'claim', notes: string[] }
|
|
1047
|
+
* | { state: 'skipped', reason: 'tracked'|'unknown', cause?: string, notes: string[] }
|
|
1048
|
+
* | { state: 'moved', mode: 'whole-folder'|'kept-new'|'moved-in', backup: string|null, rendered: boolean, notes: string[] } }
|
|
1049
|
+
* | { ok: false, error: { kind: 'refused'|'io-error', message: string } }}
|
|
1050
|
+
*/
|
|
1051
|
+
function moveLedger(root, options = {}) {
|
|
1052
|
+
return scrubMoveResult(moveLedgerUnscrubbed(root, options));
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* True when `root` is an absolute path to a plain directory (two lstat calls: the root
|
|
1057
|
+
* itself, never a link) and holds no old ledger folder. It reads nothing else.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {unknown} root
|
|
1060
|
+
* @returns {boolean}
|
|
1061
|
+
*/
|
|
1062
|
+
function oldFolderAbsent(root) {
|
|
1063
|
+
if (!isAbsoluteRoot(root)) return false;
|
|
1064
|
+
const resolved = path.resolve(root);
|
|
1065
|
+
const rootStat = lstatOrNull(resolved);
|
|
1066
|
+
if (rootStat === null || !rootStat.isDirectory()) return false;
|
|
1067
|
+
return lstatOrNull(path.join(resolved, DEVFLOW_DIR_NAME, LEGACY_NAMES.folder)) === null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* The setup both ops share, before either reads or changes anything: the machine
|
|
1072
|
+
* directory, the checks on the ledger root (curationMoveRefusal) and the context the
|
|
1073
|
+
* pass runs on. The old and new folders are the two names the path helpers hold. An
|
|
1074
|
+
* unknown machine directory answers an `io-error`, a refused root a `refused`, and an
|
|
1075
|
+
* I/O failure in the checks an `io-error`, each starting with `opName`.
|
|
1076
|
+
*
|
|
1077
|
+
* The checks read the whole root (a real path, the folder's entries, a link check on
|
|
1078
|
+
* each), and a project that is already moved pays for them at every init. With
|
|
1079
|
+
* `stopIfOldFolderAbsent` set, a root that is a plain directory with no old folder
|
|
1080
|
+
* answers `value: null` from two lstat calls, before those checks run. A root that is
|
|
1081
|
+
* not an absolute path, is missing, is a link or is not a directory still goes through
|
|
1082
|
+
* them and is refused.
|
|
1083
|
+
*
|
|
1084
|
+
* @param {string} opName - the op, which starts every message
|
|
1085
|
+
* @param {unknown} root - the ledger root, an absolute path
|
|
1086
|
+
* @param {{ machineDir?: string, homeDir?: string, now?: number }} [options]
|
|
1087
|
+
* @param {{ stopIfOldFolderAbsent?: boolean }} [flags]
|
|
1088
|
+
* @returns {{ ok: true, value: { root: string, realRoot: string, devflowDir: string, oldDir: string,
|
|
1089
|
+
* newDir: string, machineDir: string, journal: string, now: number } | null }
|
|
1090
|
+
* | { ok: false, error: { kind: 'refused'|'io-error', message: string } }}
|
|
1091
|
+
*/
|
|
1092
|
+
function moveContext(opName, root, { machineDir, homeDir, now = Date.now() } = {}, { stopIfOldFolderAbsent = false } = {}) {
|
|
1093
|
+
const home = homeDir === undefined ? homeDirOrNull() : homeDir;
|
|
1094
|
+
const machine = machineDir === undefined ? (home === null ? null : path.join(home, '.devflow')) : machineDir;
|
|
1095
|
+
if (machine === null) {
|
|
1096
|
+
return { ok: false, error: { kind: 'io-error', message: `${opName}: no home directory is set, so the machine directory is unknown` } };
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
if (stopIfOldFolderAbsent && oldFolderAbsent(root)) return { ok: true, value: null };
|
|
1100
|
+
const refusal = curationMoveRefusal(opName, root, { machineDir: machine, homeDir: home });
|
|
1101
|
+
if (!refusal.ok) return refusal;
|
|
1102
|
+
const { realRoot } = refusal.value;
|
|
1103
|
+
const devflowDir = path.join(path.resolve(root), DEVFLOW_DIR_NAME);
|
|
1104
|
+
return {
|
|
1105
|
+
ok: true,
|
|
1106
|
+
value: {
|
|
1107
|
+
root: path.resolve(root), realRoot, devflowDir,
|
|
1108
|
+
oldDir: path.join(devflowDir, LEGACY_NAMES.folder), newDir: path.join(devflowDir, CURATION_DIR_NAME),
|
|
1109
|
+
machineDir: machine, journal: curationMoveJournalPath(machine, realRoot), now,
|
|
1110
|
+
},
|
|
1111
|
+
};
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
return moveIoFailure(opName, err);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/** moveLedger before its messages and notes are made single-line. */
|
|
1118
|
+
function moveLedgerUnscrubbed(root, options = {}) {
|
|
1119
|
+
const opened = moveContext(MOVE_OP, root, options, { stopIfOldFolderAbsent: true });
|
|
1120
|
+
if (!opened.ok) return opened;
|
|
1121
|
+
if (opened.value === null) return { ok: true, value: { state: 'none', notes: [] } };
|
|
1122
|
+
const ctx = opened.value;
|
|
1123
|
+
const { machineDir: machine, realRoot } = ctx;
|
|
1124
|
+
let madeNewDir = false;
|
|
1125
|
+
let result = null;
|
|
1126
|
+
try {
|
|
1127
|
+
if (lstatOrNull(ctx.oldDir) === null) return { ok: true, value: { state: 'none', notes: [] } };
|
|
1128
|
+
|
|
1129
|
+
// Git's index decides, before this pass makes, locks, reads or renames anything.
|
|
1130
|
+
// The check runs before the move takes its locks, and every session start checks again.
|
|
1131
|
+
const tracking = inspectTrackedAreas(ctx.root);
|
|
1132
|
+
if (tracking.answer !== 'untracked') {
|
|
1133
|
+
const cause = tracking.cause === null ? {} : { cause: tracking.cause };
|
|
1134
|
+
return { ok: true, value: { state: 'skipped', reason: tracking.answer, ...cause, notes: [] } };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
fs.mkdirSync(machine, { recursive: true, mode: 0o700 });
|
|
1138
|
+
let newExisted = lstatOrNull(ctx.newDir) !== null;
|
|
1139
|
+
if (!newExisted && oldFolderNeedsEntryPath(ctx)) {
|
|
1140
|
+
// The one rename of the whole-folder case would carry that file into the live folder.
|
|
1141
|
+
// An empty new folder puts the pass on the per-entry path, where the file stays in the
|
|
1142
|
+
// backup. The folder is made before the locks, so a pass that does not run (busy, or a
|
|
1143
|
+
// failure) takes it away again (finally below), and no empty live folder is left for a
|
|
1144
|
+
// store write to fill and so change which folder wins.
|
|
1145
|
+
madeNewDir = fs.mkdirSync(ctx.newDir, { recursive: true }) !== undefined;
|
|
1146
|
+
newExisted = true;
|
|
1147
|
+
}
|
|
1148
|
+
const specs = [
|
|
1149
|
+
{ name: 'move', dir: curationMoveLockDir(machine, realRoot), staleMs: MOVE_LOCK_STALE_MS },
|
|
1150
|
+
{ name: 'old-store', dir: path.join(ctx.oldDir, STORE_LOCK_NAME), staleMs: LOCK_STALE_MS },
|
|
1151
|
+
{ name: 'old-queue', dir: path.join(ctx.oldDir, QUEUE_LOCK_NAME), staleMs: QUEUE_LOCK_STALE_MS },
|
|
1152
|
+
...(newExisted ? [
|
|
1153
|
+
{ name: 'new-store', dir: path.join(ctx.newDir, STORE_LOCK_NAME), staleMs: LOCK_STALE_MS },
|
|
1154
|
+
{ name: 'new-queue', dir: path.join(ctx.newDir, QUEUE_LOCK_NAME), staleMs: QUEUE_LOCK_STALE_MS },
|
|
1155
|
+
] : []),
|
|
1156
|
+
];
|
|
1157
|
+
const taken = curationMoveLockAll(specs);
|
|
1158
|
+
if (taken.busy !== null) return { ok: true, value: { state: 'busy', notes: [] } };
|
|
1159
|
+
try {
|
|
1160
|
+
result = movePass(ctx, { newExisted });
|
|
1161
|
+
return result;
|
|
1162
|
+
} finally {
|
|
1163
|
+
curationMoveReleaseAll(locksAfter(taken.held, ctx, result));
|
|
1164
|
+
}
|
|
1165
|
+
} catch (err) {
|
|
1166
|
+
return moveIoFailure(MOVE_OP, err);
|
|
1167
|
+
} finally {
|
|
1168
|
+
if (madeNewDir && !(result !== null && result.ok && result.value.state === 'moved')) removeEmptyDir(ctx.newDir);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* True when the old folder must not be renamed whole, because the one rename would carry
|
|
1174
|
+
* something into the live folder that must stay in the backup: a file named like a
|
|
1175
|
+
* journal (repository content, never read).
|
|
1176
|
+
*
|
|
1177
|
+
* @param {{ oldDir: string }} ctx
|
|
1178
|
+
* @returns {boolean}
|
|
1179
|
+
*/
|
|
1180
|
+
function oldFolderNeedsEntryPath(ctx) {
|
|
1181
|
+
return lstatOrNull(path.join(ctx.oldDir, OLD_FOLDER_JOURNAL_NAME)) !== null;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** Remove `dir` when it is empty; any other outcome (it holds something, it is gone) leaves it, and nothing throws. */
|
|
1185
|
+
function removeEmptyDir(dir) {
|
|
1186
|
+
try {
|
|
1187
|
+
fs.rmdirSync(dir);
|
|
1188
|
+
} catch {
|
|
1189
|
+
// A folder that holds an entry now is a live folder, and stays.
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Where the locks a pass took are once the pass is over: a lock is released at the
|
|
1195
|
+
* path it has after the pass's last rename. A whole-folder move carries the old
|
|
1196
|
+
* folder's locks to the new folder; a merge turns the old folder into the backup and
|
|
1197
|
+
* carries them there. A pass that renamed nothing leaves them where they were.
|
|
1198
|
+
*
|
|
1199
|
+
* This holds only while no step after a folder rename throws: a throw leaves `result`
|
|
1200
|
+
* unset, and the locks are then released at the paths they had before the rename. A new
|
|
1201
|
+
* step after a rename must not throw, or must update this function.
|
|
1202
|
+
*/
|
|
1203
|
+
function locksAfter(held, ctx, result) {
|
|
1204
|
+
if (!result || !result.ok || result.value.state !== 'moved') return held;
|
|
1205
|
+
if (result.value.mode === 'whole-folder') return relocateLocks(held, ctx.oldDir, ctx.newDir);
|
|
1206
|
+
return relocateLocks(held, ctx.oldDir, path.join(ctx.devflowDir, result.value.backup));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The body of a move, run under the locks. It re-probes both folders first: a folder
|
|
1211
|
+
* that changed since the locks were chosen makes the pass busy, to try again at the
|
|
1212
|
+
* next event, because a lock it does not hold would not protect it.
|
|
1213
|
+
*
|
|
1214
|
+
* With the new folder absent the whole folder is renamed. With it present: if its
|
|
1215
|
+
* observation log has data it wins and the old folder becomes the backup whole;
|
|
1216
|
+
* otherwise the old ledger set moves in (curationMoveSetWithJournal), and the old
|
|
1217
|
+
* folder becomes the backup with what is left. Only a machine-side journal of this very
|
|
1218
|
+
* old folder can override the winner rule: a journal planned for an old folder that has
|
|
1219
|
+
* been replaced is dropped first (curationMoveDropStaleJournal). Either way the pending
|
|
1220
|
+
* queue (and a stale claim's batch) merges into the winner's queue and the rendered
|
|
1221
|
+
* files are rebuilt. A fresh claim in either folder is busy too, with the reason `claim`: it can
|
|
1222
|
+
* last as long as CLAIM_STALE_SECS, so the caller may stop asking until its next session
|
|
1223
|
+
* start. A folder that changed is busy with no reason, and the next event asks again.
|
|
1224
|
+
*
|
|
1225
|
+
* @param {object} ctx
|
|
1226
|
+
* @param {{ newExisted: boolean }} state
|
|
1227
|
+
*/
|
|
1228
|
+
function movePass(ctx, { newExisted }) {
|
|
1229
|
+
if (lstatOrNull(ctx.oldDir) === null) return { ok: true, value: { state: 'none', notes: [] } };
|
|
1230
|
+
if ((lstatOrNull(ctx.newDir) !== null) !== newExisted) return { ok: true, value: { state: 'busy', notes: [] } };
|
|
1231
|
+
if (claimIsFresh(path.join(ctx.oldDir, CLAIM_NAME), ctx.now) || (newExisted && claimIsFresh(path.join(ctx.newDir, CLAIM_NAME), ctx.now))) {
|
|
1232
|
+
return { ok: true, value: { state: 'busy', reason: 'claim', notes: [] } };
|
|
1233
|
+
}
|
|
1234
|
+
if (!newExisted) {
|
|
1235
|
+
const whole = curationMoveWhole(ctx);
|
|
1236
|
+
if (!whole.ok) return whole;
|
|
1237
|
+
const journalNotes = curationMoveDropJournal(ctx);
|
|
1238
|
+
const finish = curationMoveFinish(ctx, { whole: true });
|
|
1239
|
+
return {
|
|
1240
|
+
ok: true,
|
|
1241
|
+
value: { state: 'moved', mode: 'whole-folder', backup: null, rendered: finish.rendered, notes: [...whole.value.notes, ...journalNotes, ...finish.notes] },
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const notes = curationMoveDropStaleJournal(ctx);
|
|
1246
|
+
const journalPresent = lstatOrNull(ctx.journal) !== null;
|
|
1247
|
+
const keptNew = !journalPresent && newFolderHasData(ctx);
|
|
1248
|
+
if (!keptNew) notes.push(...curationMoveSetWithJournal(ctx).notes);
|
|
1249
|
+
notes.push(...curationMoveEntries(ctx).notes);
|
|
1250
|
+
|
|
1251
|
+
const staleClaim = lstatOrNull(path.join(ctx.oldDir, CLAIM_NAME)) !== null;
|
|
1252
|
+
const merged = curationMoveMergeQueue(ctx, {
|
|
1253
|
+
dir: ctx.oldDir, targetDir: ctx.newDir, bases: staleClaim ? [CLAIM_NAME, QUEUE_NAME] : [QUEUE_NAME],
|
|
1254
|
+
});
|
|
1255
|
+
if (staleClaim) removeIfPresent(path.join(ctx.oldDir, OWNER_NAME));
|
|
1256
|
+
notes.push(...merged.notes);
|
|
1257
|
+
|
|
1258
|
+
const finish = curationMoveFinish(ctx, { whole: false });
|
|
1259
|
+
const verdict = keptNew
|
|
1260
|
+
? `kept ${path.basename(ctx.newDir)}, whose log has data; ${path.basename(ctx.oldDir)} is now ${finish.backup}`
|
|
1261
|
+
: `moved the ledger set of ${path.basename(ctx.oldDir)} into ${path.basename(ctx.newDir)}; ${path.basename(ctx.oldDir)} is now ${finish.backup}`;
|
|
1262
|
+
return {
|
|
1263
|
+
ok: true,
|
|
1264
|
+
value: {
|
|
1265
|
+
state: 'moved',
|
|
1266
|
+
mode: keptNew ? 'kept-new' : 'moved-in',
|
|
1267
|
+
backup: finish.backup,
|
|
1268
|
+
rendered: finish.rendered,
|
|
1269
|
+
notes: [verdict, ...notes, ...finish.notes],
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* After a whole-folder rename, remove the machine-side journal an earlier pass left: one
|
|
1276
|
+
* rename carried the whole set, so the journal has nothing to finish. It never throws,
|
|
1277
|
+
* because the folder is already renamed and a throw would release the locks at their old
|
|
1278
|
+
* paths (locksAfter). A journal that stays is stale, because its old folder is gone, and
|
|
1279
|
+
* the next store write drops it.
|
|
1280
|
+
*
|
|
1281
|
+
* @param {{ journal: string }} ctx
|
|
1282
|
+
* @returns {string[]} one note when the journal could not be removed, else none
|
|
1283
|
+
*/
|
|
1284
|
+
function curationMoveDropJournal(ctx) {
|
|
1285
|
+
try {
|
|
1286
|
+
removeIfPresent(ctx.journal);
|
|
1287
|
+
return [];
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
return [`the move journal could not be removed (${err && err.code}: ${err && err.message}); the next store write drops it`];
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** True when `file` is a regular file written to within CLAIM_STALE_SECS before `now`: a live claim. */
|
|
1294
|
+
function claimIsFresh(file, now) {
|
|
1295
|
+
const stat = lstatOrNull(file);
|
|
1296
|
+
return stat !== null && stat.isFile() && now - stat.mtimeMs < CLAIM_STALE_SECS * 1000;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Merge what is left in the backup folders a move left behind into the new folder's
|
|
1301
|
+
* pending queue: init runs it once per project per run, whenever a backup exists. A
|
|
1302
|
+
* backup is `<legacy prefix><stamp>-<random>` under `.devflow`. The sweep takes the
|
|
1303
|
+
* move lock and the new folder's queue lock with no wait (busy answers `busy`), merges
|
|
1304
|
+
* the pending and processing files, and the unswept private and overflow files, of
|
|
1305
|
+
* each backup by the queue rule, and marks each merged file swept. A merged file is
|
|
1306
|
+
* never read again, so a repeated sweep adds nothing. A backup that holds nothing to
|
|
1307
|
+
* merge is passed over before the limit of MOVE_MAX_BACKUPS backups per pass applies,
|
|
1308
|
+
* and the pass looks into at most MOVE_MAX_BACKUPS_SCANNED backups. With the new
|
|
1309
|
+
* folder absent or no backup it answers `none`.
|
|
1310
|
+
*
|
|
1311
|
+
* Before it makes, locks or merges anything, the sweep asks the same question the move
|
|
1312
|
+
* asks (checkTrackedAreas), which covers the backup folders too. If git tracks any
|
|
1313
|
+
* entry under a data folder or a backup folder, or if a `.git` entry stands and git
|
|
1314
|
+
* does not answer, the answer is `skipped` with the reason `tracked` or `unknown` and
|
|
1315
|
+
* zero counts, and nothing changes: the folders, the machine directory and the locks
|
|
1316
|
+
* stay as they are. A root with no `.git` entry makes no git call, and neither does a
|
|
1317
|
+
* pass that finds nothing to merge.
|
|
1318
|
+
*
|
|
1319
|
+
* @param {unknown} root - the ledger root, an absolute path
|
|
1320
|
+
* @param {{ machineDir?: string, homeDir?: string, now?: number }} [options]
|
|
1321
|
+
* Every message and note is one line with no control character (scrubMoveResult).
|
|
1322
|
+
*
|
|
1323
|
+
* @returns {{ ok: true, value: { state: 'none'|'busy'|'swept', files: number, merged: number, overflowed: number, notes: string[] }
|
|
1324
|
+
* | { state: 'skipped', reason: 'tracked'|'unknown', files: number, merged: number, overflowed: number, notes: string[] } }
|
|
1325
|
+
* | { ok: false, error: { kind: 'refused'|'io-error', message: string } }}
|
|
1326
|
+
*/
|
|
1327
|
+
function sweepLedgerBackups(root, options = {}) {
|
|
1328
|
+
return scrubMoveResult(sweepLedgerBackupsUnscrubbed(root, options));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** sweepLedgerBackups before its messages and notes are made single-line. */
|
|
1332
|
+
function sweepLedgerBackupsUnscrubbed(root, options = {}) {
|
|
1333
|
+
const idle = (state, extra = {}) => ({ ok: true, value: { state, ...extra, files: 0, merged: 0, overflowed: 0, notes: [] } });
|
|
1334
|
+
const opened = moveContext(SWEEP_OP, root, options);
|
|
1335
|
+
if (!opened.ok) return opened;
|
|
1336
|
+
const ctx = opened.value;
|
|
1337
|
+
const { devflowDir } = ctx;
|
|
1338
|
+
try {
|
|
1339
|
+
if (lstatOrNull(ctx.newDir) === null) return idle('none');
|
|
1340
|
+
const backups = backupsHoldingRows(devflowDir).slice(0, MOVE_MAX_BACKUPS);
|
|
1341
|
+
if (backups.length === 0) return idle('none');
|
|
1342
|
+
|
|
1343
|
+
// Git's index decides, before this pass makes a folder, takes a lock or merges a row.
|
|
1344
|
+
// The check runs before the sweep takes its locks, and each later run checks again.
|
|
1345
|
+
const tracking = checkTrackedAreas(ctx.root);
|
|
1346
|
+
if (tracking !== 'untracked') return idle('skipped', { reason: tracking });
|
|
1347
|
+
|
|
1348
|
+
for (const name of backups) {
|
|
1349
|
+
const problem = backupProblem(path.join(devflowDir, name));
|
|
1350
|
+
if (problem !== null) return moveRefused(SWEEP_OP, problem);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
fs.mkdirSync(ctx.machineDir, { recursive: true, mode: 0o700 });
|
|
1354
|
+
const taken = curationMoveLockAll([
|
|
1355
|
+
{ name: 'move', dir: curationMoveLockDir(ctx.machineDir, ctx.realRoot), staleMs: MOVE_LOCK_STALE_MS },
|
|
1356
|
+
{ name: 'new-queue', dir: path.join(ctx.newDir, QUEUE_LOCK_NAME), staleMs: QUEUE_LOCK_STALE_MS },
|
|
1357
|
+
]);
|
|
1358
|
+
if (taken.busy !== null) return idle('busy');
|
|
1359
|
+
try {
|
|
1360
|
+
const total = { state: 'swept', files: 0, merged: 0, overflowed: 0, notes: [] };
|
|
1361
|
+
for (const name of backups) {
|
|
1362
|
+
const swept = curationMoveSweepBackup(ctx, path.join(devflowDir, name));
|
|
1363
|
+
total.files += swept.files;
|
|
1364
|
+
total.merged += swept.merged;
|
|
1365
|
+
total.overflowed += swept.overflowed;
|
|
1366
|
+
total.notes.push(...swept.notes);
|
|
1367
|
+
}
|
|
1368
|
+
return { ok: true, value: total };
|
|
1369
|
+
} finally {
|
|
1370
|
+
curationMoveReleaseAll(taken.held);
|
|
1371
|
+
}
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
return moveIoFailure(SWEEP_OP, err);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** The names of the backup folders under `devflowDir`, oldest first (the stamp sorts by time). */
|
|
1378
|
+
function backupNames(devflowDir) {
|
|
1379
|
+
return fs.readdirSync(devflowDir).filter(name => name.startsWith(LEGACY_NAMES.backupPrefix)).sort();
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* The backups the sweep and the drain look into: of the first MOVE_MAX_BACKUPS_SCANNED
|
|
1384
|
+
* backup folders, in name order, those that may hold rows (backupMayHoldRows).
|
|
1385
|
+
*/
|
|
1386
|
+
function backupsHoldingRows(devflowDir) {
|
|
1387
|
+
return backupNames(devflowDir).slice(0, MOVE_MAX_BACKUPS_SCANNED)
|
|
1388
|
+
.filter(name => backupMayHoldRows(path.join(devflowDir, name)));
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* True unless `dir` is a backup folder with nothing for the sweep to merge: a folder
|
|
1393
|
+
* that holds no pending, processing, private or overflow file (a swept file counts for
|
|
1394
|
+
* nothing) is skipped before the per-pass limit applies, so a backup past the limit
|
|
1395
|
+
* is reached once the older ones are done. Anything that is not a plain folder is kept,
|
|
1396
|
+
* so that backupProblem refuses it as before; a folder that has gone is skipped.
|
|
1397
|
+
*/
|
|
1398
|
+
function backupMayHoldRows(dir) {
|
|
1399
|
+
const stat = lstatOrNull(dir);
|
|
1400
|
+
if (stat === null) return false;
|
|
1401
|
+
if (!stat.isDirectory()) return true;
|
|
1402
|
+
return fs.readdirSync(dir).some(holdsPendingRows);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** What is wrong with a backup folder the sweep would read, or null: a link, or a link among its queue files. */
|
|
1406
|
+
function backupProblem(dir) {
|
|
1407
|
+
const stat = lstatOrNull(dir);
|
|
1408
|
+
if (stat === null) return null;
|
|
1409
|
+
if (stat.isSymbolicLink()) return `${dir} is a symbolic link`;
|
|
1410
|
+
if (!stat.isDirectory()) return `${dir} is not a directory`;
|
|
1411
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1412
|
+
const cls = moveEntryClass(entry);
|
|
1413
|
+
if (cls === ENTRY.QUEUE || cls === ENTRY.QUEUE_FORM) {
|
|
1414
|
+
const problem = moveEntryProblem(path.join(dir, entry));
|
|
1415
|
+
if (problem !== null) return problem;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** True for the names in a backup folder that hold rows a later sweep would still merge. */
|
|
1422
|
+
function holdsPendingRows(name) {
|
|
1423
|
+
if (moveEntryClass(name) === ENTRY.QUEUE) return true;
|
|
1424
|
+
const record = queueFormOf(name);
|
|
1425
|
+
return record !== null && (record.form === 'private' || record.form === 'overflow');
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* The names of the files of the plain folder `dir` that hold rows a later sweep would
|
|
1430
|
+
* still merge (holdsPendingRows), in name order, at most MOVE_MAX_QUEUE_FILES of them:
|
|
1431
|
+
* the files the status count reads and the drain removes.
|
|
1432
|
+
*/
|
|
1433
|
+
function pendingFilesOf(dir) {
|
|
1434
|
+
return fs.readdirSync(dir).filter(holdsPendingRows).sort().slice(0, MOVE_MAX_QUEUE_FILES);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* The rows a backup folder still holds for a later sweep: the non-blank lines of the files
|
|
1439
|
+
* holdsPendingRows names, at most MOVE_MAX_QUEUE_FILES of them. Null for a path that is
|
|
1440
|
+
* not a plain folder (a link, a file or nothing), which is never read.
|
|
1441
|
+
*
|
|
1442
|
+
* @param {string} dir
|
|
1443
|
+
* @returns {number|null}
|
|
1444
|
+
*/
|
|
1445
|
+
function pendingRowsOf(dir) {
|
|
1446
|
+
const stat = lstatOrNull(dir);
|
|
1447
|
+
if (stat === null || !stat.isDirectory()) return null;
|
|
1448
|
+
let pendingRows = 0;
|
|
1449
|
+
for (const file of pendingFilesOf(dir)) {
|
|
1450
|
+
const text = readQueueText(path.join(dir, file));
|
|
1451
|
+
if (text !== null) pendingRows += completeLines(text).length;
|
|
1452
|
+
}
|
|
1453
|
+
return pendingRows;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Count the rows still waiting in the backup folders a move left, for the status
|
|
1458
|
+
* command: per backup, the non-blank lines of the pending and processing files and of
|
|
1459
|
+
* the unswept private and overflow files (an unfinished last line counts, because the
|
|
1460
|
+
* sweep has not merged it). A swept file counts for nothing. Read-only: no lock is
|
|
1461
|
+
* taken and nothing is written, no link is followed (a backup that is a link, and a
|
|
1462
|
+
* queue file that is a link, read as absent), and the read is bounded by the sweep's
|
|
1463
|
+
* limits on backup folders and on queue files per folder. A backup's `name` is for
|
|
1464
|
+
* display: its control characters are collapsed to a space (singleLine), so it may differ
|
|
1465
|
+
* from the folder's own name.
|
|
1466
|
+
*
|
|
1467
|
+
* The first MOVE_MAX_BACKUPS backups, in name order, are listed. Those after them, up to
|
|
1468
|
+
* the MOVE_MAX_BACKUPS_SCANNED-th, are read and counted in `beyondLimit`: `backups` is how
|
|
1469
|
+
* many there are and `pendingRows` the rows they hold in all, so the two describe one set.
|
|
1470
|
+
* A backup past the MOVE_MAX_BACKUPS_SCANNED-th is not read, and no sweep or drain
|
|
1471
|
+
* reaches it: `beyondLimit.unread` counts those by name only, so none is left out of
|
|
1472
|
+
* the account.
|
|
1473
|
+
*
|
|
1474
|
+
* @param {unknown} root - the ledger root, an absolute path
|
|
1475
|
+
* @returns {{ ok: true, value: { backups: Array<{ name: string, pendingRows: number }>,
|
|
1476
|
+
* beyondLimit: { backups: number, pendingRows: number, unread: number } } }
|
|
1477
|
+
* | { ok: false, error: { kind: 'refused'|'io-error', message: string } }}
|
|
1478
|
+
*/
|
|
1479
|
+
function backupPendingRows(root) {
|
|
1480
|
+
if (!isAbsoluteRoot(root)) {
|
|
1481
|
+
return { ok: false, error: { kind: 'refused', message: `${ROWS_OP}: the ledger root must be an absolute path` } };
|
|
1482
|
+
}
|
|
1483
|
+
try {
|
|
1484
|
+
const devflowDir = path.join(path.resolve(root), '.devflow');
|
|
1485
|
+
const devflowStat = lstatOrNull(devflowDir);
|
|
1486
|
+
if (devflowStat === null || !devflowStat.isDirectory()) return { ok: true, value: { backups: [], beyondLimit: { backups: 0, pendingRows: 0, unread: 0 } } };
|
|
1487
|
+
const names = backupNames(devflowDir);
|
|
1488
|
+
const backups = [];
|
|
1489
|
+
for (const name of names.slice(0, MOVE_MAX_BACKUPS)) {
|
|
1490
|
+
const rows = pendingRowsOf(path.join(devflowDir, name));
|
|
1491
|
+
if (rows !== null) backups.push({ name: singleLine(name), pendingRows: rows });
|
|
1492
|
+
}
|
|
1493
|
+
const beyond = names.slice(MOVE_MAX_BACKUPS, MOVE_MAX_BACKUPS_SCANNED);
|
|
1494
|
+
let beyondRows = 0;
|
|
1495
|
+
for (const name of beyond) beyondRows += pendingRowsOf(path.join(devflowDir, name)) || 0;
|
|
1496
|
+
const unread = Math.max(0, names.length - MOVE_MAX_BACKUPS_SCANNED);
|
|
1497
|
+
return { ok: true, value: { backups, beyondLimit: { backups: beyond.length, pendingRows: beyondRows, unread } } };
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
return scrubMoveResult(moveIoFailure(ROWS_OP, err));
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* The first symbolic link a drain of the backup `dir` would act through: the folder
|
|
1505
|
+
* itself, or one of its queue files (the entries backupProblem link-checks). Null when
|
|
1506
|
+
* there is none, and for a path that is no folder: it holds no queue file.
|
|
1507
|
+
*/
|
|
1508
|
+
function backupLinkedPath(dir) {
|
|
1509
|
+
const stat = lstatOrNull(dir);
|
|
1510
|
+
if (stat === null) return null;
|
|
1511
|
+
if (stat.isSymbolicLink()) return dir;
|
|
1512
|
+
if (!stat.isDirectory()) return null;
|
|
1513
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
1514
|
+
const cls = moveEntryClass(entry);
|
|
1515
|
+
if ((cls === ENTRY.QUEUE || cls === ENTRY.QUEUE_FORM) && moveEntryProblem(path.join(dir, entry)) !== null) {
|
|
1516
|
+
return path.join(dir, entry);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return null;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* The queue files the backup folders still hold, for the drain a disable runs: the
|
|
1524
|
+
* files backupPendingRows counts (holdsPendingRows), as absolute paths, never a swept
|
|
1525
|
+
* file, the claim's owner file or a ledger file. Read-only: it takes no lock and writes
|
|
1526
|
+
* nothing. It is bounded by the sweep's limits: at most MOVE_MAX_BACKUPS_SCANNED backup
|
|
1527
|
+
* folders, and MOVE_MAX_QUEUE_FILES files in each. The caller deletes.
|
|
1528
|
+
*
|
|
1529
|
+
* Refused, naming the path in `error.path`, when a backup folder that may hold rows is
|
|
1530
|
+
* a symbolic link or has a link among its queue files (the sweep's own refusal): the
|
|
1531
|
+
* answer then lists no file at all, so a caller that deletes what it is told deletes
|
|
1532
|
+
* nothing. A path that is no folder holds no file and is passed over.
|
|
1533
|
+
*
|
|
1534
|
+
* @param {unknown} root - the ledger root, an absolute path
|
|
1535
|
+
* @returns {{ ok: true, value: { files: string[] } }
|
|
1536
|
+
* | { ok: false, error: { kind: 'refused'|'io-error', message: string, path?: string } }}
|
|
1537
|
+
*/
|
|
1538
|
+
function backupQueueFiles(root) {
|
|
1539
|
+
if (!isAbsoluteRoot(root)) return moveRefused(QUEUE_FILES_OP, 'the ledger root must be an absolute path');
|
|
1540
|
+
try {
|
|
1541
|
+
const devflowDir = path.join(path.resolve(root), '.devflow');
|
|
1542
|
+
const devflowStat = lstatOrNull(devflowDir);
|
|
1543
|
+
if (devflowStat === null || !devflowStat.isDirectory()) return { ok: true, value: { files: [] } };
|
|
1544
|
+
const dirs = backupsHoldingRows(devflowDir).map(name => path.join(devflowDir, name));
|
|
1545
|
+
for (const dir of dirs) {
|
|
1546
|
+
const linked = backupLinkedPath(dir);
|
|
1547
|
+
if (linked !== null) {
|
|
1548
|
+
const refused = moveRefused(QUEUE_FILES_OP, `${linked} is a symbolic link`);
|
|
1549
|
+
return scrubMoveResult({ ok: false, error: { ...refused.error, path: singleLine(linked) } });
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const files = [];
|
|
1553
|
+
for (const dir of dirs) {
|
|
1554
|
+
const stat = lstatOrNull(dir);
|
|
1555
|
+
if (stat === null || !stat.isDirectory()) continue;
|
|
1556
|
+
for (const file of pendingFilesOf(dir)) files.push(path.join(dir, file));
|
|
1557
|
+
}
|
|
1558
|
+
return { ok: true, value: { files } };
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
return scrubMoveResult(moveIoFailure(QUEUE_FILES_OP, err));
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
module.exports = {
|
|
1565
|
+
moveLedger,
|
|
1566
|
+
QUEUE_MERGE_CAP_LINES,
|
|
1567
|
+
curationMoveRefusal,
|
|
1568
|
+
curationMoveLockAll,
|
|
1569
|
+
curationMoveReleaseAll,
|
|
1570
|
+
curationMoveWhole,
|
|
1571
|
+
curationMoveSetWithJournal,
|
|
1572
|
+
curationMoveEntries,
|
|
1573
|
+
curationMoveMergeQueue,
|
|
1574
|
+
curationMoveFinish,
|
|
1575
|
+
curationMoveSweepBackup,
|
|
1576
|
+
moveEntryClass,
|
|
1577
|
+
isLinkChecked,
|
|
1578
|
+
MOVE_ENTRY_CLASSES,
|
|
1579
|
+
sweepLedgerBackups,
|
|
1580
|
+
backupPendingRows,
|
|
1581
|
+
backupQueueFiles,
|
|
1582
|
+
};
|