devflow-kit 3.7.0 → 3.9.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 +479 -1
- package/README.md +15 -15
- package/dist/agents/code.md +6 -8
- package/dist/agents/design.md +1 -1
- package/dist/agents/fix.md +7 -8
- package/dist/agents/git.md +3 -3
- package/dist/agents/harden.md +1 -1
- package/dist/agents/refactor.md +2 -4
- package/dist/agents/review.md +4 -4
- package/dist/agents/skim.md +2 -2
- package/dist/agents/triage.md +9 -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/curation.js +808 -0
- package/dist/cli/commands/gate-level-prompts.js +428 -0
- package/dist/cli/commands/gates.js +35 -11
- package/dist/cli/commands/init-seed.js +11 -8
- package/dist/cli/commands/init.js +244 -57
- package/dist/cli/commands/memory.js +60 -13
- package/dist/cli/commands/prompt-io.js +37 -0
- package/dist/cli/commands/uninstall.js +157 -17
- package/dist/cli.js +2 -2
- package/dist/commands/bug-analysis.md +25 -36
- package/dist/commands/code-review.md +40 -44
- package/dist/commands/debug.md +5 -5
- package/dist/commands/dynamic-build.md +26 -25
- 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 +53 -82
- package/dist/commands/plan.md +6 -6
- package/dist/commands/release.md +5 -5
- package/dist/commands/research.md +6 -6
- package/dist/commands/resolve.md +39 -41
- package/dist/commands/self-review.md +5 -13
- package/dist/core/area-pause-notice.js +44 -0
- 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 +35 -0
- package/dist/core/curation-move.js +35 -0
- package/dist/core/curation-queue-cleanup.js +135 -0
- package/dist/core/curation-store.js +99 -0
- package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
- package/dist/core/echo-value.js +11 -5
- package/dist/core/evidence-policy.js +11 -9
- package/dist/core/feature-switch.js +95 -19
- package/dist/core/fs-atomic.js +1 -1
- package/dist/core/gate-level.js +10 -2
- 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 +58 -14
- package/dist/core/migrations.js +1 -13
- package/dist/core/observations.js +2 -2
- package/dist/core/plugins.js +19 -19
- 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 +159 -0
- package/dist/core/tracker.js +1 -1
- package/dist/{learning-off → curation-off}/agents/code.md +6 -8
- package/dist/{learning-off → curation-off}/agents/fix.md +7 -8
- package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
- package/dist/{learning-off → curation-off}/agents/review.md +3 -3
- package/dist/{learning-off → curation-off}/agents/triage.md +8 -1
- package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
- package/dist/{learning-off → curation-off}/commands/code-review.md +37 -41
- package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
- package/dist/{learning-off → curation-off}/commands/dynamic-build.md +23 -22
- package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
- package/dist/{learning-off → curation-off}/commands/implement.md +50 -79
- package/dist/{learning-off → curation-off}/commands/plan.md +2 -2
- package/dist/{learning-off → curation-off}/commands/resolve.md +36 -38
- package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
- package/dist/hud/components/{learning-counts.js → curation-counts.js} +63 -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/post-resolution-summary.md +12 -11
- package/dist/skills/git/references/pr/post-review-summary.md +12 -11
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
- package/dist/skills/git/references/pr/validate-branch.md +8 -5
- package/dist/skills/git/references/publication-gate.md +6 -7
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
- package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
- 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 +11 -11
- package/src/assets/agents/code.mds +6 -6
- package/src/assets/agents/curate.md +8 -8
- package/src/assets/agents/debug.mds +6 -6
- package/src/assets/agents/design.mds +18 -18
- package/src/assets/agents/evaluate.md +6 -12
- package/src/assets/agents/fix.mds +7 -6
- package/src/assets/agents/git.mds +3 -3
- 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 +43 -43
- package/src/assets/agents/skim.mds +11 -11
- package/src/assets/agents/triage.mds +29 -22
- package/src/assets/agents/validate.md +30 -50
- package/src/assets/commands/_partials/_decisions.mds +22 -20
- 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 +7 -7
- package/src/assets/commands/bug-analysis.mds +37 -48
- package/src/assets/commands/code-review.mds +53 -57
- package/src/assets/commands/debug.mds +14 -14
- package/src/assets/commands/dynamic-build.mds +38 -37
- 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 +77 -98
- package/src/assets/commands/plan.mds +27 -27
- package/src/assets/commands/release.mds +12 -12
- package/src/assets/commands/research.mds +19 -19
- package/src/assets/commands/resolve.mds +66 -60
- package/src/assets/commands/self-review.mds +20 -20
- package/src/assets/mds/git/_ops.mds +9 -2
- package/src/assets/mds/git/_ops_pr.mds +21 -11
- package/src/assets/mds/git/_ops_steps.mds +20 -4
- package/src/assets/mds/git/_pr.mds +21 -18
- package/src/assets/mds/git/_references.mds +6 -7
- package/src/assets/mds/tracker/_github.mds +1 -4
- package/src/assets/mds/tracker/_jira.mds +1 -4
- package/src/assets/mds/tracker/_linear.mds +1 -4
- package/src/assets/rules/quality.md +1 -0
- package/src/assets/scripts/ci-wait.cjs +389 -92
- package/src/assets/scripts/hooks/background-memory-update +81 -3
- 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 +176 -51
- package/src/assets/scripts/hooks/json-parse +1 -1
- package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
- package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +308 -122
- 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 +42 -22
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +452 -0
- package/src/assets/scripts/hooks/memory-permission +10 -2
- package/src/assets/scripts/hooks/memory-worker +12 -1
- package/src/assets/scripts/hooks/pre-compact-memory +17 -16
- package/src/assets/scripts/hooks/queue-append +444 -102
- package/src/assets/scripts/hooks/resolve-project-root +2 -2
- package/src/assets/scripts/hooks/session-start-context +198 -72
- package/src/assets/scripts/hooks/session-start-memory +42 -43
- package/src/assets/scripts/lib/project-config.cjs +12 -5
- package/src/assets/scripts/pr-evidence.cjs +5 -2
- package/src/assets/scripts/resolve-base.cjs +1043 -0
- package/src/assets/scripts/resolve-settings.cjs +283 -45
- package/src/assets/scripts/verify-evidence.cjs +307 -75
- package/src/assets/skills/agent-references/SKILL.md +11 -2
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +29 -0
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +25 -0
- package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
- package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
- 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/cli/commands/learning.js +0 -554
- 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
|
@@ -1,55 +1,65 @@
|
|
|
1
|
-
// src/assets/scripts/hooks/lib/
|
|
1
|
+
// src/assets/scripts/hooks/lib/curation-store.cjs
|
|
2
2
|
//
|
|
3
|
-
// The v2
|
|
3
|
+
// The v2 curation store: the one place the curation plumbing reads, validates,
|
|
4
4
|
// projects and writes the decisions log, the ledger and their side files.
|
|
5
5
|
//
|
|
6
6
|
// DESIGN: plumbing only. No function here prints or calls process.exit. Work that
|
|
7
7
|
// can fail on its input or on the lock returns a Result — { ok: true, value } or
|
|
8
8
|
// { ok: false, error: { kind, message } } — and its caller prints it: json-helper.cjs
|
|
9
|
-
// for the ops, or the `devflow
|
|
9
|
+
// for the ops, or the `devflow curation` CLI through src/core/curation-store.ts. A throw
|
|
10
10
|
// means a broken invariant or an I/O failure, never an expected outcome; a lock
|
|
11
11
|
// held by withDecisionsLock is released on every path, the throw included.
|
|
12
12
|
//
|
|
13
|
-
// Loading: node built-ins and
|
|
13
|
+
// Loading: node built-ins and four sibling libs only (the path helpers, the lock, the
|
|
14
|
+
// path guard and the tracked-areas check). This module never requires
|
|
14
15
|
// decisions-format.cjs or render-decisions.cjs at load time: both require it, for
|
|
15
16
|
// the status list and the one-line and inactive-note text they share with `list`.
|
|
16
17
|
//
|
|
17
|
-
//
|
|
18
|
+
// The one-time move of the old ledger folder to its new name is not here: it lives in
|
|
19
|
+
// curation-move.cjs, which requires this module. The store holds only the guard that
|
|
20
|
+
// keeps its writers off an unfinished move (withDecisionsLock reads the move journal),
|
|
21
|
+
// so a store write loads no move code.
|
|
22
|
+
//
|
|
23
|
+
// Files under <root>/.devflow/curation/:
|
|
18
24
|
// decisions-log.jsonl observation rows — the content authority
|
|
19
25
|
// decisions-ledger.jsonl anchored rows — projections of log rows
|
|
20
26
|
// decisions-log.archive.jsonl rotated-out observation rows (D-ROTATE-UNREFERENCED)
|
|
21
27
|
// decisions-history.jsonl prior content versions (D-CONTENT-HISTORY)
|
|
22
28
|
// *.rejected.jsonl quarantined malformed lines (D-QUARANTINE-MALFORMED)
|
|
23
29
|
// *.pre-v2.jsonl one-time copies of the v1 files (D-V1-BACKUP-ONCE)
|
|
24
|
-
// .decisions.lock/ the one
|
|
30
|
+
// .decisions.lock/ the one curation lock (D-ONE-LEARNING-LOCK)
|
|
25
31
|
// .pending-turns.jsonl the queue the capture hooks append to
|
|
26
32
|
// .pending-turns.processing the claimed batch, and .pending-turns.owner its
|
|
27
33
|
// owner's token (D-OWNED-CLAIM)
|
|
28
34
|
//
|
|
29
35
|
// TS COUNTERPARTS: src/core/observations.ts mirrors the status lists (D201), and
|
|
30
|
-
// src/core/
|
|
36
|
+
// src/core/curation-store.ts transcribes the functions the CLI calls from their
|
|
31
37
|
// JSDoc here (D-LEARNING-STORE-SEAM) — change both together.
|
|
32
38
|
|
|
33
39
|
'use strict';
|
|
34
40
|
|
|
35
41
|
const crypto = require('crypto');
|
|
36
42
|
const fs = require('fs');
|
|
43
|
+
const os = require('os');
|
|
37
44
|
const path = require('path');
|
|
38
45
|
const { execFileSync } = require('child_process');
|
|
39
46
|
|
|
40
47
|
const {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
LEGACY_NAMES,
|
|
49
|
+
getCurationDir,
|
|
50
|
+
getCurationPendingTurnsPath,
|
|
51
|
+
getCurationPendingTurnsProcessingPath,
|
|
52
|
+
getCurationClaimOwnerPath,
|
|
45
53
|
getDecisionsLedgerPath,
|
|
46
54
|
getDecisionsLogPath,
|
|
47
55
|
getDecisionsArchivePath,
|
|
48
56
|
getDecisionsHistoryPath,
|
|
49
57
|
getDecisionsLockDir,
|
|
58
|
+
curationMoveJournalPath,
|
|
50
59
|
} = require('./project-paths.cjs');
|
|
51
60
|
const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
|
|
52
61
|
const { safePath } = require('./safe-path.cjs');
|
|
62
|
+
const { inspectTrackedAreaOnce } = require('./tracked-areas.cjs');
|
|
53
63
|
|
|
54
64
|
// ---------------------------------------------------------------------------
|
|
55
65
|
// Constants
|
|
@@ -401,17 +411,17 @@ function readTextUnlinked(file, { maxBytes = Infinity } = {}) {
|
|
|
401
411
|
* delete them without a trace, and a reader that quarantined would make list,
|
|
402
412
|
* show and the HUD write files.
|
|
403
413
|
*
|
|
404
|
-
* D-NO-LINKED-READ: a
|
|
414
|
+
* D-NO-LINKED-READ: a curation file that is itself a symbolic link reads as
|
|
405
415
|
* missing, and nothing is read through it; the pre-v2 backup skips one the same
|
|
406
416
|
* way (ensurePreV2Backup), and release-claim reads the claim's owner file the same
|
|
407
417
|
* way, and only up to CLAIM_OWNER_MAX_BYTES (readClaimOwner). readJsonl and
|
|
408
418
|
* readClaimOwner read a regular file alone (readTextUnlinked): a directory, a FIFO
|
|
409
419
|
* or a device where the file belongs reads as missing too, and neither waits on
|
|
410
|
-
* one. Reason: a repository can commit any
|
|
420
|
+
* one. Reason: a repository can commit any curation file as a link to a file
|
|
411
421
|
* elsewhere on the machine, and a read that followed it would put that file's
|
|
412
422
|
* lines into list and show and, through a rewrite, the quarantine or a render,
|
|
413
|
-
* into the project's
|
|
414
|
-
* /dev/zero would wait forever or fill memory, holding the
|
|
423
|
+
* into the project's curation folder; a read that followed one to a FIFO or to
|
|
424
|
+
* /dev/zero would wait forever or fill memory, holding the curation lock when a
|
|
415
425
|
* writer or release-claim reads. The writers never write through a link either:
|
|
416
426
|
* a rename replaces one, and an append refuses one.
|
|
417
427
|
*
|
|
@@ -448,11 +458,16 @@ function rejectedPathFor(file) {
|
|
|
448
458
|
|
|
449
459
|
/**
|
|
450
460
|
* Append to `file`, creating it when absent. O_NOFOLLOW refuses a symlink planted
|
|
451
|
-
* at `file` (ELOOP) instead of writing through it.
|
|
461
|
+
* at `file` (ELOOP) instead of writing through it. A file this call creates gets
|
|
462
|
+
* `mode`, as an explicit open mode (the process umask can only remove bits from it).
|
|
463
|
+
*
|
|
464
|
+
* @param {string} file
|
|
465
|
+
* @param {string} content
|
|
466
|
+
* @param {number} [mode] - the mode of a created file (default 0o666, as the umask leaves it)
|
|
452
467
|
*/
|
|
453
|
-
function appendNoFollow(file, content) {
|
|
468
|
+
function appendNoFollow(file, content, mode = 0o666) {
|
|
454
469
|
const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0);
|
|
455
|
-
const fd = fs.openSync(file, flags,
|
|
470
|
+
const fd = fs.openSync(file, flags, mode);
|
|
456
471
|
try {
|
|
457
472
|
fs.writeFileSync(fd, content);
|
|
458
473
|
} finally {
|
|
@@ -467,17 +482,18 @@ function appendNoFollow(file, content) {
|
|
|
467
482
|
*
|
|
468
483
|
* @param {string} file - the file the lines were read from
|
|
469
484
|
* @param {Array<{ line: number, text: string }>} rejected
|
|
470
|
-
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
485
|
+
* @param {{ now?: number, mode?: number }} [opts] - now: epoch ms (default Date.now());
|
|
486
|
+
* mode: the mode of a quarantine file this call creates (default as the umask leaves it)
|
|
471
487
|
* @returns {number} the number of records appended
|
|
472
488
|
*/
|
|
473
|
-
function quarantineRejected(file, rejected, { now = Date.now() } = {}) {
|
|
489
|
+
function quarantineRejected(file, rejected, { now = Date.now(), mode } = {}) {
|
|
474
490
|
if (rejected.length === 0) return 0;
|
|
475
491
|
const rejectedAt = new Date(now).toISOString();
|
|
476
492
|
const source = path.basename(file);
|
|
477
493
|
const content = rejected
|
|
478
494
|
.map(r => JSON.stringify({ rejected_at: rejectedAt, source, line: r.line, text: r.text }))
|
|
479
495
|
.join('\n') + '\n';
|
|
480
|
-
appendNoFollow(rejectedPathFor(file), content);
|
|
496
|
+
appendNoFollow(rejectedPathFor(file), content, mode);
|
|
481
497
|
return rejected.length;
|
|
482
498
|
}
|
|
483
499
|
|
|
@@ -545,14 +561,14 @@ function writeJsonlAtomic(file, rows) {
|
|
|
545
561
|
// ---------------------------------------------------------------------------
|
|
546
562
|
|
|
547
563
|
/**
|
|
548
|
-
* True when `<root>/.devflow/
|
|
564
|
+
* True when `<root>/.devflow/curation` exists and is a directory.
|
|
549
565
|
*
|
|
550
566
|
* @param {string} root - project root
|
|
551
567
|
* @returns {boolean}
|
|
552
568
|
*/
|
|
553
|
-
function
|
|
569
|
+
function hasCurationDir(root) {
|
|
554
570
|
try {
|
|
555
|
-
return fs.statSync(
|
|
571
|
+
return fs.statSync(getCurationDir(root)).isDirectory();
|
|
556
572
|
} catch (err) {
|
|
557
573
|
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
|
|
558
574
|
throw err;
|
|
@@ -560,17 +576,17 @@ function hasLearningDir(root) {
|
|
|
560
576
|
}
|
|
561
577
|
|
|
562
578
|
/**
|
|
563
|
-
* The error Result of an op run where `<root>/.devflow/
|
|
579
|
+
* The error Result of an op run where `<root>/.devflow/curation/` is absent
|
|
564
580
|
* (D-NO-STRAY-TREE).
|
|
565
581
|
*
|
|
566
582
|
* @param {string} opName - operation name, for the message
|
|
567
583
|
* @param {string} root - project root
|
|
568
|
-
* @returns {{ ok: false, error: { kind: 'no-
|
|
584
|
+
* @returns {{ ok: false, error: { kind: 'no-curation-dir', message: string } }}
|
|
569
585
|
*/
|
|
570
|
-
function
|
|
586
|
+
function noCurationDir(opName, root) {
|
|
571
587
|
return {
|
|
572
588
|
ok: false,
|
|
573
|
-
error: { kind: 'no-
|
|
589
|
+
error: { kind: 'no-curation-dir', message: `${opName}: no .devflow/curation/ under ${root} — run from the project root` },
|
|
574
590
|
};
|
|
575
591
|
}
|
|
576
592
|
|
|
@@ -585,22 +601,22 @@ function isSymbolicLink(file) {
|
|
|
585
601
|
}
|
|
586
602
|
|
|
587
603
|
/**
|
|
588
|
-
* The first of `<root>/.devflow` and `<root>/.devflow/
|
|
604
|
+
* The first of `<root>/.devflow` and `<root>/.devflow/curation` that is itself a
|
|
589
605
|
* symbolic link, or null when neither is (D-NO-LINKED-TREE).
|
|
590
606
|
*
|
|
591
607
|
* @param {string} root - project root
|
|
592
608
|
* @returns {string|null}
|
|
593
609
|
*/
|
|
594
|
-
function
|
|
595
|
-
const
|
|
596
|
-
for (const dir of [path.dirname(
|
|
610
|
+
function linkedCurationFolder(root) {
|
|
611
|
+
const curationDir = getCurationDir(root);
|
|
612
|
+
for (const dir of [path.dirname(curationDir), curationDir]) {
|
|
597
613
|
if (isSymbolicLink(dir)) return dir;
|
|
598
614
|
}
|
|
599
615
|
return null;
|
|
600
616
|
}
|
|
601
617
|
|
|
602
618
|
/**
|
|
603
|
-
* The error Result of an op run where `.devflow` or `.devflow/
|
|
619
|
+
* The error Result of an op run where `.devflow` or `.devflow/curation` under its
|
|
604
620
|
* root is a symbolic link (D-NO-LINKED-TREE).
|
|
605
621
|
*
|
|
606
622
|
* @param {string} opName - operation name, for the message
|
|
@@ -614,6 +630,112 @@ function linkedFolder(opName, dir) {
|
|
|
614
630
|
};
|
|
615
631
|
}
|
|
616
632
|
|
|
633
|
+
/**
|
|
634
|
+
* The refusal of an op run where git tracks files in the curation data folders under
|
|
635
|
+
* `root` (the curation folder, the old ledger folder and the backup folders), or does
|
|
636
|
+
* not say whether it does, or null where git tracks none. A folder that git tracks is the
|
|
637
|
+
* repository's content, so the curation feature is paused there: the op reads and writes
|
|
638
|
+
* nothing. The messages state the fact and name no command; the root is shown so the
|
|
639
|
+
* folder can be found. The check asks git about the index, not the disk, so it holds
|
|
640
|
+
* whether or not the folder is on disk, and it makes no git call for a root with no `.git`
|
|
641
|
+
* entry. It comes first in every op, before a ledger file is read, a lock is taken or a git
|
|
642
|
+
* call of the op's own is made. The answer is asked once per root for the life of the
|
|
643
|
+
* process (inspectTrackedAreaOnce), so the heartbeat, the op and its lock share one call.
|
|
644
|
+
*
|
|
645
|
+
* The refusal carries `reason`, `tracked` where git tracks files and `unknown` where it
|
|
646
|
+
* did not answer, so a caller that picks a notice reads the field, never the message.
|
|
647
|
+
*
|
|
648
|
+
* @param {string} opName - operation name, for the message
|
|
649
|
+
* @param {string} root - project root
|
|
650
|
+
* @returns {{ ok: false, error: { kind: 'tracked', reason: 'tracked'|'unknown', message: string } } | null}
|
|
651
|
+
*/
|
|
652
|
+
function trackedCurationArea(opName, root) {
|
|
653
|
+
const { answer } = inspectTrackedAreaOnce(root, 'curation');
|
|
654
|
+
if (answer === 'untracked') return null;
|
|
655
|
+
const reason = answer === 'tracked' ? 'tracked' : 'unknown';
|
|
656
|
+
const fact = reason === 'tracked' ? 'git tracks files' : 'git did not answer whether it tracks files';
|
|
657
|
+
return {
|
|
658
|
+
ok: false,
|
|
659
|
+
error: { kind: 'tracked', reason, message: `${opName}: ${fact} in the curation data folders under ${root}; nothing was written` },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** The Stats of `file` without following a link, or null when nothing is there. */
|
|
664
|
+
function lstatOrNull(file) {
|
|
665
|
+
try {
|
|
666
|
+
return fs.lstatSync(file);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The physical path of `target`, or its resolved path when it does not exist yet. */
|
|
674
|
+
function physicalPath(target) {
|
|
675
|
+
try {
|
|
676
|
+
return fs.realpathSync(target);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return path.resolve(target);
|
|
679
|
+
throw err;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** The home directory: `HOME`, else the passwd entry, else null. */
|
|
684
|
+
function homeDirOrNull() {
|
|
685
|
+
if (process.env.HOME) return process.env.HOME;
|
|
686
|
+
try {
|
|
687
|
+
return os.homedir() || null;
|
|
688
|
+
} catch {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Where a root's move journal is, resolved the way moveLedger resolves its machine
|
|
695
|
+
* directory (`$HOME/.devflow`) and the root's key (the root's physical path); null when
|
|
696
|
+
* no home directory is known.
|
|
697
|
+
*/
|
|
698
|
+
function moveJournalPathOrNull(root) {
|
|
699
|
+
const home = homeDirOrNull();
|
|
700
|
+
if (home === null) return null;
|
|
701
|
+
return curationMoveJournalPath(path.join(home, '.devflow'), physicalPath(path.resolve(root)));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Remove a machine-side journal whose old folder is gone: it has no set left to
|
|
706
|
+
* finish. Only a regular file is removed, never a link or any other entry, and nothing
|
|
707
|
+
* is followed. It never throws: a journal that cannot be removed (EACCES, EPERM, EROFS
|
|
708
|
+
* on the machine directory) stays, and the writer goes on, because the journal holds
|
|
709
|
+
* no writer once its old folder is gone. A later write tries again.
|
|
710
|
+
*
|
|
711
|
+
* @param {string} journal
|
|
712
|
+
*/
|
|
713
|
+
function dropStaleMoveJournal(journal) {
|
|
714
|
+
try {
|
|
715
|
+
const stat = lstatOrNull(journal);
|
|
716
|
+
if (stat === null || !stat.isFile()) return;
|
|
717
|
+
removeIfPresent(journal);
|
|
718
|
+
} catch {
|
|
719
|
+
// Best effort only: see the note above.
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Whether the journal at `journal` holds store writers off: it exists and the old
|
|
725
|
+
* folder it belongs to still exists. A journal whose old folder is gone is stale and is
|
|
726
|
+
* dropped (dropStaleMoveJournal), and the writer goes on.
|
|
727
|
+
*
|
|
728
|
+
* @param {string} journal
|
|
729
|
+
* @param {string} oldFolder
|
|
730
|
+
* @returns {boolean}
|
|
731
|
+
*/
|
|
732
|
+
function moveJournalHoldsWriters(journal, oldFolder) {
|
|
733
|
+
if (lstatOrNull(journal) === null) return false;
|
|
734
|
+
if (lstatOrNull(oldFolder) !== null) return true;
|
|
735
|
+
dropStaleMoveJournal(journal);
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
|
|
617
739
|
/** True for a Result: `{ ok: true, … }` or `{ ok: false, error: { … } }`. */
|
|
618
740
|
function isResult(value) {
|
|
619
741
|
return isPlainObject(value) && (value.ok === true || (value.ok === false && isPlainObject(value.error)));
|
|
@@ -624,50 +746,71 @@ function isResult(value) {
|
|
|
624
746
|
* The lock is released on every path, a throw from `fn` included; the throw then
|
|
625
747
|
* reaches the caller.
|
|
626
748
|
*
|
|
627
|
-
* D-ONE-LEARNING-LOCK:
|
|
749
|
+
* D-ONE-LEARNING-LOCK: curation writers take one lock, `.decisions.lock`, through
|
|
628
750
|
* this wrapper — every write to the log, the ledger, their side files and the
|
|
629
751
|
* rendered files happens under it. Reason: two writers under two locks can each
|
|
630
752
|
* read one file, change it and rename their copy over it, and the second rename
|
|
631
753
|
* silently discards the first one's change.
|
|
632
754
|
*
|
|
633
|
-
* D-NO-STRAY-TREE: a
|
|
634
|
-
* `.devflow/
|
|
755
|
+
* D-NO-STRAY-TREE: a curation writer refuses with `no-curation-dir` when
|
|
756
|
+
* `.devflow/curation/` is absent under its root, and the store never creates that
|
|
635
757
|
* directory or its parent — the lock directory is the only thing made inside it.
|
|
636
|
-
* Reason: a writer run from the wrong directory would otherwise create a
|
|
758
|
+
* Reason: a writer run from the wrong directory would otherwise create a curation
|
|
637
759
|
* tree there and write a ledger that no session ever reads.
|
|
638
760
|
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
761
|
+
* An unfinished ledger move (a move journal in the machine directory, next to the move
|
|
762
|
+
* lock, see moveLedger in curation-move.cjs) makes every writer answer `busy` without taking the lock, so no
|
|
763
|
+
* write lands between two renames of the move's ledger set. The journal is checked
|
|
764
|
+
* again once the lock is held: a move can start, and stop part way through the set,
|
|
765
|
+
* while the writer waits for the lock. Only the machine-side file counts, and only
|
|
766
|
+
* while the old folder it belongs to exists (moveJournalHoldsWriters): a file in the
|
|
767
|
+
* repository's old folder is repository content and is never read. With no home
|
|
768
|
+
* directory no move can have started (moveLedger refuses without a machine directory),
|
|
769
|
+
* so there is no journal to find and the writer goes on.
|
|
770
|
+
*
|
|
771
|
+
* D-NO-LINKED-TREE: a curation writer refuses with `not-a-directory`, changing
|
|
772
|
+
* nothing, when `.devflow` or `.devflow/curation` under its root is a symbolic
|
|
641
773
|
* link. Reason: a repository can commit either one as a link to a folder elsewhere
|
|
642
774
|
* on the machine, and a writer that followed it would take its lock there and
|
|
643
775
|
* rewrite, quarantine, archive, render or delete files in whatever folder the link
|
|
644
776
|
* names. Refused here, before the lock is taken, so every writer refuses in one
|
|
645
777
|
* place; the claim heartbeat makes the same check, and read-only paths still read.
|
|
646
778
|
*
|
|
779
|
+
* A writer also refuses with `tracked`, changing nothing, where git tracks files in the
|
|
780
|
+
* curation data folders under its root, or does not say whether it does
|
|
781
|
+
* (trackedCurationArea). That check follows the link check and comes before the journal,
|
|
782
|
+
* the folder check and the lock, so a paused project gets no lock folder.
|
|
783
|
+
*
|
|
647
784
|
* @param {string} opName - operation name, for messages
|
|
648
785
|
* @param {string} root - project root
|
|
649
786
|
* @param {() => { ok: boolean }} fn - the locked body; it must return a Result
|
|
650
787
|
* @param {{ timeoutMs?: number, staleMs?: number }} [opts]
|
|
651
788
|
* @returns {{ ok: true, value?: unknown } | { ok: false, error: { kind: string, message: string } }}
|
|
652
|
-
* fn's Result, or an error of kind `not-a-directory`, `no-
|
|
789
|
+
* fn's Result, or an error of kind `not-a-directory`, `tracked`, `no-curation-dir` or `busy`.
|
|
653
790
|
*/
|
|
654
791
|
function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS, staleMs = LOCK_STALE_MS } = {}) {
|
|
655
|
-
const linked =
|
|
792
|
+
const linked = linkedCurationFolder(root);
|
|
656
793
|
if (linked !== null) return linkedFolder(opName, linked);
|
|
657
|
-
|
|
794
|
+
const paused = trackedCurationArea(opName, root);
|
|
795
|
+
if (paused !== null) return paused;
|
|
796
|
+
const journal = moveJournalPathOrNull(root);
|
|
797
|
+
const oldFolder = path.join(root, '.devflow', LEGACY_NAMES.folder);
|
|
798
|
+
if (journal !== null && moveJournalHoldsWriters(journal, oldFolder)) return unfinishedMove(opName, journal);
|
|
799
|
+
if (!hasCurationDir(root)) return noCurationDir(opName, root);
|
|
658
800
|
const lockDir = getDecisionsLockDir(root);
|
|
659
801
|
let acquired;
|
|
660
802
|
try {
|
|
661
803
|
acquired = acquireMkdirLock(lockDir, timeoutMs, staleMs);
|
|
662
804
|
} catch (err) {
|
|
663
|
-
// The
|
|
664
|
-
if (err && err.code === 'ENOENT') return
|
|
805
|
+
// The curation directory went away between the check and the mkdir.
|
|
806
|
+
if (err && err.code === 'ENOENT') return noCurationDir(opName, root);
|
|
665
807
|
throw err;
|
|
666
808
|
}
|
|
667
809
|
if (!acquired) {
|
|
668
810
|
return { ok: false, error: { kind: 'busy', message: `${opName}: timeout acquiring lock at ${lockDir}` } };
|
|
669
811
|
}
|
|
670
812
|
try {
|
|
813
|
+
if (journal !== null && moveJournalHoldsWriters(journal, oldFolder)) return unfinishedMove(opName, journal);
|
|
671
814
|
const result = fn();
|
|
672
815
|
if (!isResult(result)) {
|
|
673
816
|
throw new TypeError(`${opName}: the locked body must return a Result ({ ok: true, value } or { ok: false, error })`);
|
|
@@ -678,6 +821,14 @@ function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_
|
|
|
678
821
|
}
|
|
679
822
|
}
|
|
680
823
|
|
|
824
|
+
/** The `busy` Result of a writer that finds an unfinished ledger move's journal. */
|
|
825
|
+
function unfinishedMove(opName, journal) {
|
|
826
|
+
return {
|
|
827
|
+
ok: false,
|
|
828
|
+
error: { kind: 'busy', message: `${opName}: an unfinished ledger move is recorded at ${journal}; it finishes at the next session start or devflow init` },
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
681
832
|
// ---------------------------------------------------------------------------
|
|
682
833
|
// State and the ledger registry
|
|
683
834
|
// ---------------------------------------------------------------------------
|
|
@@ -690,7 +841,7 @@ function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_
|
|
|
690
841
|
* @param {string} root - project root
|
|
691
842
|
* @returns {{ ledgerRows: object[], logRows: object[], rejected: { ledger: Array<{ line: number, text: string }>, log: Array<{ line: number, text: string }> } }}
|
|
692
843
|
*/
|
|
693
|
-
function
|
|
844
|
+
function readCurationState(root) {
|
|
694
845
|
const ledger = readJsonl(getDecisionsLedgerPath(root));
|
|
695
846
|
const log = readJsonl(getDecisionsLogPath(root));
|
|
696
847
|
return {
|
|
@@ -1191,7 +1342,7 @@ function ensurePreV2Backup(root, { logRows = [], ledgerRows = [] } = {}) {
|
|
|
1191
1342
|
/** Days of inactivity after which an observation no ledger row carries leaves the log (D-ROTATE-UNREFERENCED). */
|
|
1192
1343
|
const ROTATE_AGE_DAYS = 30;
|
|
1193
1344
|
|
|
1194
|
-
/** What an older install's usage telemetry left in the
|
|
1345
|
+
/** What an older install's usage telemetry left in the curation directory: a file and a lock directory. */
|
|
1195
1346
|
const USAGE_LEFTOVERS = Object.freeze(['.decisions-usage.json', '.decisions-usage.lock']);
|
|
1196
1347
|
|
|
1197
1348
|
/**
|
|
@@ -1221,7 +1372,7 @@ function lastActivityMs(row) {
|
|
|
1221
1372
|
* anchor_id copied onto a log row can say what to keep, and a dedup by id dropped
|
|
1222
1373
|
* the newer version of a row whose older version was already archived.
|
|
1223
1374
|
*
|
|
1224
|
-
* Under the
|
|
1375
|
+
* Under the curation lock it first deletes the usage leftovers. When no row is
|
|
1225
1376
|
* due it writes nothing else. When rows are due it backs up a v1 tree
|
|
1226
1377
|
* (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
|
|
1227
1378
|
* (D-QUARANTINE-MALFORMED) before it appends to the archive and rewrites the log;
|
|
@@ -1232,12 +1383,12 @@ function lastActivityMs(row) {
|
|
|
1232
1383
|
* @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1233
1384
|
* @returns {{ ok: true, value: { rotated: number, appended: number } } | { ok: false, error: { kind: string, message: string } }}
|
|
1234
1385
|
* rotated: rows removed from the log; appended: rows added to the archive.
|
|
1235
|
-
* Errors are withDecisionsLock's not-a-directory, no-
|
|
1386
|
+
* Errors are withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy.
|
|
1236
1387
|
*/
|
|
1237
1388
|
function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
|
|
1238
1389
|
return withDecisionsLock('rotate-observations', root, () => {
|
|
1239
1390
|
for (const name of USAGE_LEFTOVERS) {
|
|
1240
|
-
fs.rmSync(path.join(
|
|
1391
|
+
fs.rmSync(path.join(getCurationDir(root), name), { recursive: true, force: true });
|
|
1241
1392
|
}
|
|
1242
1393
|
|
|
1243
1394
|
const logPath = getDecisionsLogPath(root);
|
|
@@ -1276,7 +1427,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
|
|
|
1276
1427
|
// ---------------------------------------------------------------------------
|
|
1277
1428
|
|
|
1278
1429
|
/**
|
|
1279
|
-
* Drop the observations no entry uses — `devflow
|
|
1430
|
+
* Drop the observations no entry uses — `devflow curation --clear`.
|
|
1280
1431
|
*
|
|
1281
1432
|
* D-CLEAR-UNREFERENCED: clearing removes from the log exactly the rows no ledger
|
|
1282
1433
|
* row carries (D-LEDGER-REGISTRY), whatever their age or status, and keeps every
|
|
@@ -1286,7 +1437,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
|
|
|
1286
1437
|
* row that is its content authority, and refresh then refused them all — and a
|
|
1287
1438
|
* malformed ledger line may be the one carrying a row clearing would drop.
|
|
1288
1439
|
*
|
|
1289
|
-
* It runs under the
|
|
1440
|
+
* It runs under the curation lock (D-ONE-LEARNING-LOCK). When a row is dropped it
|
|
1290
1441
|
* backs up a v1 tree (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
|
|
1291
1442
|
* (D-QUARANTINE-MALFORMED) before it rewrites the log; with nothing to drop it
|
|
1292
1443
|
* writes nothing.
|
|
@@ -1295,7 +1446,7 @@ function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
|
|
|
1295
1446
|
* @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1296
1447
|
* @returns {{ ok: true, value: { cleared: number, kept: number } } | { ok: false, error: { kind: string, message: string } }}
|
|
1297
1448
|
* cleared: rows removed from the log; kept: rows left in it. Error kinds:
|
|
1298
|
-
* ledger-malformed, and withDecisionsLock's not-a-directory, no-
|
|
1449
|
+
* ledger-malformed, and withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy.
|
|
1299
1450
|
*/
|
|
1300
1451
|
function clearUnreferenced(root, { now = Date.now(), timeoutMs } = {}) {
|
|
1301
1452
|
return withDecisionsLock('clear', root, () => {
|
|
@@ -1340,11 +1491,11 @@ function removeEmptyDir(dir) {
|
|
|
1340
1491
|
}
|
|
1341
1492
|
|
|
1342
1493
|
/**
|
|
1343
|
-
* Remove every
|
|
1494
|
+
* Remove every curation file — `devflow curation --reset`: the log, the ledger
|
|
1344
1495
|
* and their side files, the rendered files, the tuning config, and the queue
|
|
1345
|
-
* with its claim and owner file — and then the
|
|
1496
|
+
* with its claim and owner file — and then the curation directory itself.
|
|
1346
1497
|
*
|
|
1347
|
-
* D-RESET-UNDER-LOCK: reset empties the
|
|
1498
|
+
* D-RESET-UNDER-LOCK: reset empties the curation directory under the curation
|
|
1348
1499
|
* lock, sparing only the lock directory, and removes the emptied directory once
|
|
1349
1500
|
* the lock is released, and only if nothing has arrived in it. Reason: the lock
|
|
1350
1501
|
* is released by its path, so removing it with the directory would let this
|
|
@@ -1352,8 +1503,8 @@ function removeEmptyDir(dir) {
|
|
|
1352
1503
|
* and what arrives once the lock is free — a captured turn, or the next writer's
|
|
1353
1504
|
* lock — belongs to the next run.
|
|
1354
1505
|
*
|
|
1355
|
-
* Like every
|
|
1356
|
-
* nothing (D-NO-STRAY-TREE), refuses a
|
|
1506
|
+
* Like every curation writer it refuses without `.devflow/curation/` and creates
|
|
1507
|
+
* nothing (D-NO-STRAY-TREE), refuses a curation directory or a `.devflow` that is a
|
|
1357
1508
|
* symbolic link and removes nothing (D-NO-LINKED-TREE), since emptying it would
|
|
1358
1509
|
* empty whatever directory the link leads to, and waits at most `timeoutMs` for the
|
|
1359
1510
|
* lock, breaking one a crashed run left behind (D-ONE-LEARNING-LOCK). A symbolic
|
|
@@ -1362,18 +1513,18 @@ function removeEmptyDir(dir) {
|
|
|
1362
1513
|
* @param {string} root - project root
|
|
1363
1514
|
* @param {{ timeoutMs?: number }} [opts]
|
|
1364
1515
|
* @returns {{ ok: true, value: { removed: number } } | { ok: false, error: { kind: string, message: string } }}
|
|
1365
|
-
* removed: the entries removed from the
|
|
1366
|
-
* withDecisionsLock's not-a-directory, no-
|
|
1516
|
+
* removed: the entries removed from the curation directory. Errors are
|
|
1517
|
+
* withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy.
|
|
1367
1518
|
*/
|
|
1368
|
-
function
|
|
1369
|
-
const
|
|
1519
|
+
function resetCuration(root, { timeoutMs } = {}) {
|
|
1520
|
+
const curationDir = getCurationDir(root);
|
|
1370
1521
|
const lockName = path.basename(getDecisionsLockDir(root));
|
|
1371
1522
|
const reset = withDecisionsLock('reset', root, () => {
|
|
1372
|
-
const entries = fs.readdirSync(
|
|
1373
|
-
for (const name of entries) fs.rmSync(path.join(
|
|
1523
|
+
const entries = fs.readdirSync(curationDir).filter(name => name !== lockName);
|
|
1524
|
+
for (const name of entries) fs.rmSync(path.join(curationDir, name), { recursive: true, force: true });
|
|
1374
1525
|
return { ok: true, value: { removed: entries.length } };
|
|
1375
1526
|
}, { timeoutMs });
|
|
1376
|
-
if (reset.ok) removeEmptyDir(
|
|
1527
|
+
if (reset.ok) removeEmptyDir(curationDir);
|
|
1377
1528
|
return reset;
|
|
1378
1529
|
}
|
|
1379
1530
|
|
|
@@ -1391,7 +1542,7 @@ const CLAIM_STALE_SECS = 900;
|
|
|
1391
1542
|
/** How long a claim waits for the queue's own lock (ms): the wait of queue-append's overflow truncation. */
|
|
1392
1543
|
const QUEUE_LOCK_TIMEOUT_MS = 2000;
|
|
1393
1544
|
|
|
1394
|
-
/** Age after which the queue's own lock counts as abandoned (ms):
|
|
1545
|
+
/** Age after which the queue's own lock counts as abandoned (ms): curation-lock's threshold. */
|
|
1395
1546
|
const QUEUE_LOCK_STALE_MS = 30000;
|
|
1396
1547
|
|
|
1397
1548
|
/** A claim token: 16 lowercase hex characters. */
|
|
@@ -1462,7 +1613,7 @@ function removeIfPresent(file) {
|
|
|
1462
1613
|
* larger than CLAIM_OWNER_MAX_BYTES reads as absent (D-NO-LINKED-READ, at readJsonl).
|
|
1463
1614
|
*/
|
|
1464
1615
|
function readClaimOwner(root) {
|
|
1465
|
-
const text = readTextUnlinked(
|
|
1616
|
+
const text = readTextUnlinked(getCurationClaimOwnerPath(root), { maxBytes: CLAIM_OWNER_MAX_BYTES });
|
|
1466
1617
|
if (text === null) return null;
|
|
1467
1618
|
const token = text.trim();
|
|
1468
1619
|
return CLAIM_TOKEN_RE.test(token) ? token : null;
|
|
@@ -1473,7 +1624,7 @@ function readClaimOwner(root) {
|
|
|
1473
1624
|
* queue-append's overflow truncation takes, so a truncation can never rewrite the
|
|
1474
1625
|
* queue from rows already claimed. link(2) refuses an existing claim; on a
|
|
1475
1626
|
* filesystem without hard links a rename stands in, which is safe because the
|
|
1476
|
-
* caller found the claim path empty under the
|
|
1627
|
+
* caller found the claim path empty under the curation lock. A row a capture hook
|
|
1477
1628
|
* appends meanwhile lands in the claimed batch or in the queue formed after it,
|
|
1478
1629
|
* never in neither.
|
|
1479
1630
|
*
|
|
@@ -1516,22 +1667,23 @@ function moveQueueToClaim(queuePath, claimPath) {
|
|
|
1516
1667
|
}
|
|
1517
1668
|
|
|
1518
1669
|
/**
|
|
1519
|
-
* Claim the
|
|
1670
|
+
* Claim the curation queue for one Curate run.
|
|
1520
1671
|
*
|
|
1521
|
-
* D-OWNED-CLAIM: the
|
|
1522
|
-
* claim-queue and release-claim ops, under the
|
|
1672
|
+
* D-OWNED-CLAIM: the curation queue is claimed and released only through the
|
|
1673
|
+
* claim-queue and release-claim ops, under the curation lock. A claim moves the
|
|
1523
1674
|
* queue to .pending-turns.processing by link(2), under the queue's own lock (a
|
|
1524
1675
|
* rename stands in only where the filesystem has no hard links), sets the claim's
|
|
1525
1676
|
* mtime to now and records a fresh random token in .pending-turns.owner. A claim
|
|
1526
1677
|
* younger than CLAIM_STALE_SECS is busy to every other claimant; an older one is
|
|
1527
1678
|
* taken over with a new token, and the waiting queue is left for the next claim.
|
|
1528
1679
|
* Release deletes the claim only for the token that owns it, and every json-helper
|
|
1529
|
-
*
|
|
1680
|
+
* curation op refreshes an existing claim's mtime before it runs, without ever
|
|
1530
1681
|
* creating one. Reason: a check-then-mv claim let two runs claim at once and
|
|
1531
1682
|
* clobber a batch, mv kept the queue's old mtime so a fresh claim could look stale
|
|
1532
1683
|
* at once, and an unconditional final unlink deleted another run's claim.
|
|
1533
1684
|
*
|
|
1534
|
-
* Without .devflow/
|
|
1685
|
+
* Without .devflow/curation/ it answers none and creates nothing; where git tracks files
|
|
1686
|
+
* in the curation data folders it refuses, kind `tracked`, before it looks (trackedCurationArea).
|
|
1535
1687
|
*
|
|
1536
1688
|
* @param {string} root - project root
|
|
1537
1689
|
* @param {{ now?: number, token?: string, timeoutMs?: number }} [opts]
|
|
@@ -1542,17 +1694,19 @@ function moveQueueToClaim(queuePath, claimPath) {
|
|
|
1542
1694
|
*/
|
|
1543
1695
|
function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs } = {}) {
|
|
1544
1696
|
assertClaimToken('claimQueue', token);
|
|
1545
|
-
|
|
1697
|
+
const paused = trackedCurationArea('claim-queue', root);
|
|
1698
|
+
if (paused !== null) return paused;
|
|
1699
|
+
if (!hasCurationDir(root)) return { ok: true, value: { state: 'none' } };
|
|
1546
1700
|
return withDecisionsLock('claim-queue', root, () => {
|
|
1547
|
-
const claimPath =
|
|
1548
|
-
const queuePath =
|
|
1701
|
+
const claimPath = getCurationPendingTurnsProcessingPath(root);
|
|
1702
|
+
const queuePath = getCurationPendingTurnsPath(root);
|
|
1549
1703
|
const at = new Date(now);
|
|
1550
1704
|
|
|
1551
1705
|
const claim = regularFileStat(claimPath);
|
|
1552
1706
|
if (claim === false) return notRegularFile('claim-queue', claimPath);
|
|
1553
1707
|
if (claim !== null) {
|
|
1554
1708
|
if (now - claim.mtimeMs < CLAIM_STALE_SECS * 1000) return { ok: true, value: { state: 'busy' } };
|
|
1555
|
-
writeFileAtomic(
|
|
1709
|
+
writeFileAtomic(getCurationClaimOwnerPath(root), `${token}\n`);
|
|
1556
1710
|
fs.utimesSync(claimPath, at, at);
|
|
1557
1711
|
return { ok: true, value: { state: 'claimed', token, takeover: true } };
|
|
1558
1712
|
}
|
|
@@ -1564,7 +1718,7 @@ function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs
|
|
|
1564
1718
|
const moved = moveQueueToClaim(queuePath, claimPath);
|
|
1565
1719
|
if (moved !== 'moved') return { ok: true, value: { state: moved } };
|
|
1566
1720
|
fs.utimesSync(claimPath, at, at);
|
|
1567
|
-
writeFileAtomic(
|
|
1721
|
+
writeFileAtomic(getCurationClaimOwnerPath(root), `${token}\n`);
|
|
1568
1722
|
return { ok: true, value: { state: 'claimed', token, takeover: false } };
|
|
1569
1723
|
}, { timeoutMs });
|
|
1570
1724
|
}
|
|
@@ -1581,14 +1735,14 @@ function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs
|
|
|
1581
1735
|
* @param {string} token
|
|
1582
1736
|
* @param {{ timeoutMs?: number }} [opts]
|
|
1583
1737
|
* @returns {{ ok: true, value: { state: 'released'|'not-owner'|'gone' } } | { ok: false, error: { kind: string, message: string } }}
|
|
1584
|
-
* errors include withDecisionsLock's not-a-directory, no-
|
|
1738
|
+
* errors include withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy
|
|
1585
1739
|
* @throws {TypeError} when `token` is not a claim token
|
|
1586
1740
|
*/
|
|
1587
1741
|
function releaseClaim(root, token, { timeoutMs } = {}) {
|
|
1588
1742
|
assertClaimToken('releaseClaim', token);
|
|
1589
1743
|
return withDecisionsLock('release-claim', root, () => {
|
|
1590
|
-
const claimPath =
|
|
1591
|
-
const ownerPath =
|
|
1744
|
+
const claimPath = getCurationPendingTurnsProcessingPath(root);
|
|
1745
|
+
const ownerPath = getCurationClaimOwnerPath(root);
|
|
1592
1746
|
const owned = readClaimOwner(root) === token;
|
|
1593
1747
|
|
|
1594
1748
|
const claim = regularFileStat(claimPath);
|
|
@@ -1607,18 +1761,21 @@ function releaseClaim(root, token, { timeoutMs } = {}) {
|
|
|
1607
1761
|
/**
|
|
1608
1762
|
* The claim heartbeat (D-OWNED-CLAIM): set an existing claim's mtime to now. It
|
|
1609
1763
|
* never creates a claim and never follows a symlink at the claim path, and it
|
|
1610
|
-
* takes no lock — json-helper sends it before each
|
|
1611
|
-
*
|
|
1764
|
+
* takes no lock — json-helper sends it before each curation op runs. A claim in a
|
|
1765
|
+
* curation tree reached through a symbolic link is left alone (D-NO-LINKED-TREE):
|
|
1612
1766
|
* `lutimes` follows a linked folder above the claim, and the op that follows
|
|
1613
|
-
* refuses that tree anyway.
|
|
1767
|
+
* refuses that tree anyway. A claim in a project whose curation data folders git
|
|
1768
|
+
* tracks, or where git does not answer, is left alone as well (trackedCurationArea).
|
|
1614
1769
|
*
|
|
1615
1770
|
* @param {string} root - project root
|
|
1616
1771
|
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1617
1772
|
* @returns {{ ok: true, value: { touched: boolean } } | { ok: false, error: { kind: 'heartbeat-failed', message: string } }}
|
|
1618
1773
|
*/
|
|
1619
1774
|
function touchClaim(root, { now = Date.now() } = {}) {
|
|
1620
|
-
if (
|
|
1621
|
-
|
|
1775
|
+
if (linkedCurationFolder(root) !== null || trackedCurationArea('heartbeat', root) !== null) {
|
|
1776
|
+
return { ok: true, value: { touched: false } };
|
|
1777
|
+
}
|
|
1778
|
+
const claimPath = getCurationPendingTurnsProcessingPath(root);
|
|
1622
1779
|
const at = new Date(now);
|
|
1623
1780
|
try {
|
|
1624
1781
|
fs.lutimesSync(claimPath, at, at);
|
|
@@ -1950,16 +2107,17 @@ function resolveVerifyRef(root) {
|
|
|
1950
2107
|
/**
|
|
1951
2108
|
* Render decisions.md, pitfalls.md and index.md from `ledgerRows` and write each
|
|
1952
2109
|
* atomically, the index last. The caller holds .decisions.lock
|
|
1953
|
-
* (D-ONE-LEARNING-LOCK), so the
|
|
2110
|
+
* (D-ONE-LEARNING-LOCK), so the curation directory exists. render-decisions.cjs
|
|
1954
2111
|
* requires this module at load time, so it is required here, on first use. Prints
|
|
1955
2112
|
* nothing.
|
|
1956
2113
|
*
|
|
1957
2114
|
* @param {string} root - project root
|
|
1958
2115
|
* @param {object[]} ledgerRows - every ledger row
|
|
2116
|
+
* @param {string} [outputDir] - the folder the files belong in (default: the curation folder)
|
|
1959
2117
|
*/
|
|
1960
|
-
function renderAll(root, ledgerRows) {
|
|
1961
|
-
const {
|
|
1962
|
-
for (const file of
|
|
2118
|
+
function renderAll(root, ledgerRows, outputDir = getCurationDir(root)) {
|
|
2119
|
+
const { renderCurationFiles } = require('./render-decisions.cjs');
|
|
2120
|
+
for (const file of renderCurationFiles(root, ledgerRows, outputDir)) writeFileAtomic(file.path, file.content);
|
|
1963
2121
|
}
|
|
1964
2122
|
|
|
1965
2123
|
// ---------------------------------------------------------------------------
|
|
@@ -2118,13 +2276,15 @@ function restoreFirst(id, carriers) {
|
|
|
2118
2276
|
* observations: the count the log row holds afterwards; reprojected: the
|
|
2119
2277
|
* anchors re-projected, in anchor order. Error kinds: invalid-input (with
|
|
2120
2278
|
* problems), duplicate-log-id, restore-first, cannot-reproject, and
|
|
2121
|
-
* withDecisionsLock's not-a-directory, no-
|
|
2279
|
+
* withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy.
|
|
2122
2280
|
* @throws {TypeError} when `mode` is not a put mode
|
|
2123
2281
|
*/
|
|
2124
2282
|
function putObservation(root, mode, input, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
|
|
2125
2283
|
if (!VALIDATION_MODES.includes(mode)) {
|
|
2126
2284
|
throw new TypeError(`putObservation: mode must be one of ${VALIDATION_MODES.join(', ')}, got '${mode}'`);
|
|
2127
2285
|
}
|
|
2286
|
+
const paused = trackedCurationArea('put-observation', root);
|
|
2287
|
+
if (paused !== null) return paused;
|
|
2128
2288
|
const matches = scopeMatches || gitScopeMatcher(root);
|
|
2129
2289
|
return withDecisionsLock('put-observation', root, () => putUnderLock(root, mode, input, { now, scopeMatches: matches }), { timeoutMs });
|
|
2130
2290
|
}
|
|
@@ -2190,16 +2350,19 @@ function putUnderLock(root, mode, input, { now, scopeMatches }) {
|
|
|
2190
2350
|
* The data behind `list`, read-only: buildListing over the ledger and the log as
|
|
2191
2351
|
* they are, every glob scope checked against the files git tracks. Malformed
|
|
2192
2352
|
* lines are counted, never quarantined (D-QUARANTINE-MALFORMED). Refuses without
|
|
2193
|
-
* .devflow/
|
|
2353
|
+
* .devflow/curation/ (D-NO-STRAY-TREE), and, before it reads a file, where git tracks files
|
|
2354
|
+
* in the curation data folders (trackedCurationArea).
|
|
2194
2355
|
*
|
|
2195
2356
|
* @param {string} root - project root
|
|
2196
2357
|
* @param {{ scopeMatches?: (glob: string) => boolean }} [opts] - default gitScopeMatcher(root)
|
|
2197
2358
|
* @returns {{ ok: true, value: { active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } } }
|
|
2198
|
-
* | { ok: false, error: { kind: 'no-
|
|
2359
|
+
* | { ok: false, error: { kind: 'tracked'|'no-curation-dir', message: string } }}
|
|
2199
2360
|
*/
|
|
2200
2361
|
function readListing(root, { scopeMatches } = {}) {
|
|
2201
|
-
|
|
2202
|
-
|
|
2362
|
+
const paused = trackedCurationArea('list', root);
|
|
2363
|
+
if (paused !== null) return paused;
|
|
2364
|
+
if (!hasCurationDir(root)) return noCurationDir('list', root);
|
|
2365
|
+
const { ledgerRows, logRows, rejected } = readCurationState(root);
|
|
2203
2366
|
return {
|
|
2204
2367
|
ok: true,
|
|
2205
2368
|
value: buildListing(ledgerRows, logRows, { scopeMatches: scopeMatches || gitScopeMatcher(root), rejected }),
|
|
@@ -2280,16 +2443,19 @@ function formatListing(listing) {
|
|
|
2280
2443
|
* the log and the history as they are. When lines were skipped as malformed
|
|
2281
2444
|
* (D-QUARANTINE-MALFORMED), a found entry gains `malformed: { ledger, log }` and
|
|
2282
2445
|
* a not-found message says a skipped line may hold it. Refuses without
|
|
2283
|
-
* .devflow/
|
|
2446
|
+
* .devflow/curation/ (D-NO-STRAY-TREE), and, before it reads a file, where git tracks files
|
|
2447
|
+
* in the curation data folders (trackedCurationArea).
|
|
2284
2448
|
*
|
|
2285
2449
|
* @param {string} root - project root
|
|
2286
2450
|
* @param {string} key - an anchor id or an observation id
|
|
2287
2451
|
* @returns {{ ok: true, value: { key: string, ledger: object[], log: object|null, history_versions: object[], flags: object[], malformed?: { ledger: number, log: number } } }
|
|
2288
|
-
* | { ok: false, error: { kind: 'no-
|
|
2452
|
+
* | { ok: false, error: { kind: 'tracked'|'no-curation-dir'|'invalid-key'|'not-found', message: string } }}
|
|
2289
2453
|
*/
|
|
2290
2454
|
function showByKey(root, key) {
|
|
2291
|
-
|
|
2292
|
-
|
|
2455
|
+
const paused = trackedCurationArea('show', root);
|
|
2456
|
+
if (paused !== null) return paused;
|
|
2457
|
+
if (!hasCurationDir(root)) return noCurationDir('show', root);
|
|
2458
|
+
const { ledgerRows, logRows, rejected } = readCurationState(root);
|
|
2293
2459
|
const shown = showEntry(key, ledgerRows, logRows, { historyVersions: id => historyVersions(root, id) });
|
|
2294
2460
|
const malformed = { ledger: rejected.ledger.length, log: rejected.log.length };
|
|
2295
2461
|
const skipped = malformed.ledger + malformed.log;
|
|
@@ -2320,7 +2486,7 @@ function warmScopeMatcher(ledgerRows, scopeMatches) {
|
|
|
2320
2486
|
/**
|
|
2321
2487
|
* Hand out the entries maintenance works on next, and lease them.
|
|
2322
2488
|
*
|
|
2323
|
-
* Under the
|
|
2489
|
+
* Under the curation lock it flags integrity problems and selects the due
|
|
2324
2490
|
* entries (D-DUE-ORDER), then stamps each entry it hands out with
|
|
2325
2491
|
* `last_attempt` = now: claim-due is the one writer of the field the lease
|
|
2326
2492
|
* reads. A hand-out backs up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines
|
|
@@ -2335,11 +2501,13 @@ function warmScopeMatcher(ledgerRows, scopeMatches) {
|
|
|
2335
2501
|
* now: epoch ms (default Date.now()); scopeMatches: default gitScopeMatcher(root)
|
|
2336
2502
|
* @returns {{ ok: true, value: { ref: { ref: 'origin/HEAD'|'HEAD', commit: string } | null, due: Array<{ anchor_id: string, reason: string, bytes: number }> } }
|
|
2337
2503
|
* | { ok: false, error: { kind: string, message: string } }}
|
|
2338
|
-
* due is selectDue's answer; errors are withDecisionsLock's not-a-directory,
|
|
2339
|
-
* no-
|
|
2504
|
+
* due is selectDue's answer; errors are withDecisionsLock's tracked, not-a-directory,
|
|
2505
|
+
* no-curation-dir and busy
|
|
2340
2506
|
*/
|
|
2341
2507
|
function claimDue(root, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
|
|
2342
|
-
|
|
2508
|
+
const paused = trackedCurationArea('claim-due', root);
|
|
2509
|
+
if (paused !== null) return paused;
|
|
2510
|
+
if (!hasCurationDir(root)) return noCurationDir('claim-due', root);
|
|
2343
2511
|
const matches = scopeMatches || gitScopeMatcher(root);
|
|
2344
2512
|
const ref = resolveVerifyRef(root);
|
|
2345
2513
|
warmScopeMatcher(readJsonl(getDecisionsLedgerPath(root)).rows, matches);
|
|
@@ -2413,7 +2581,7 @@ function nextAnchorFromLedger(ledgerRows, type) {
|
|
|
2413
2581
|
|
|
2414
2582
|
/**
|
|
2415
2583
|
* True when a project-relative path is one the cited-number scan never reads: the
|
|
2416
|
-
*
|
|
2584
|
+
* curation tree, where every entry cites itself, or anything under an excluded
|
|
2417
2585
|
* directory segment.
|
|
2418
2586
|
*
|
|
2419
2587
|
* @param {string} relPath - relative to the project root, either separator style
|
|
@@ -2421,7 +2589,7 @@ function nextAnchorFromLedger(ledgerRows, type) {
|
|
|
2421
2589
|
*/
|
|
2422
2590
|
function isCitedScanExcluded(relPath) {
|
|
2423
2591
|
const norm = relPath.split(path.sep).join('/');
|
|
2424
|
-
if (norm === '.devflow/
|
|
2592
|
+
if (norm === '.devflow/curation' || norm.startsWith('.devflow/curation/')) return true;
|
|
2425
2593
|
return norm.split('/').some(segment => CITED_SCAN_EXCLUDED_SEGMENTS.includes(segment));
|
|
2426
2594
|
}
|
|
2427
2595
|
|
|
@@ -2432,7 +2600,7 @@ function isCitedScanExcluded(relPath) {
|
|
|
2432
2600
|
* command a repository's config names in `core.fsmonitor` — code chosen by the
|
|
2433
2601
|
* repository this hook runs inside. The call turns it off for itself
|
|
2434
2602
|
* (`-c core.fsmonitor=false`), so the listing stays a pure read. Reason: the
|
|
2435
|
-
*
|
|
2603
|
+
* curation ops run inside any repository a session opens, and a read that ran
|
|
2436
2604
|
* the repository's command would execute it with the user's privileges.
|
|
2437
2605
|
*
|
|
2438
2606
|
* @param {string} root - project root
|
|
@@ -2512,7 +2680,7 @@ function readScannedText(file) {
|
|
|
2512
2680
|
* Every anchor the project's files cite as a whole word, each mapped to its first
|
|
2513
2681
|
* citation: the scan behind D-E4-SKIP. It reads the files git tracks under `root`
|
|
2514
2682
|
* (D-NO-FSMONITOR) or, outside a git working tree, the files a directory walk
|
|
2515
|
-
* finds — never the
|
|
2683
|
+
* finds — never the curation tree, an excluded directory, a symbolic link, a
|
|
2516
2684
|
* binary file or a file over the size cap. A file it cannot read is passed over.
|
|
2517
2685
|
* It writes nothing and takes no lock.
|
|
2518
2686
|
*
|
|
@@ -2584,10 +2752,10 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
|
|
|
2584
2752
|
* Promote a v2 observation to a new ledger entry of `type` — the assign-anchor op.
|
|
2585
2753
|
*
|
|
2586
2754
|
* D-E4-SKIP: assign-anchor scans the project's tracked files once, before it takes
|
|
2587
|
-
* the
|
|
2755
|
+
* the curation lock, for every anchor they cite as a whole word, and mints the
|
|
2588
2756
|
* first number past the type's highest anchored number that no file cites; it
|
|
2589
2757
|
* reports each number it skips, skips at most E4_MAX_SKIPS of them and refuses
|
|
2590
|
-
* when the next one is cited too. The scan reads neither the
|
|
2758
|
+
* when the next one is cited too. The scan reads neither the curation tree nor
|
|
2591
2759
|
* .git or a vendored or build directory (node_modules, target, dist), nor a
|
|
2592
2760
|
* symbolic link, a binary file or a file over 5 MB. Reason: a document can cite a
|
|
2593
2761
|
* number the ledger has not minted yet, and minting over it silently binds that
|
|
@@ -2598,7 +2766,7 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
|
|
|
2598
2766
|
* whatever its status (D-LEDGER-REGISTRY). The new row is its projection
|
|
2599
2767
|
* (toLedgerRowV2) with the type's active status and today as both date and
|
|
2600
2768
|
* last_verified; it is appended to the ledger and the files are re-rendered, all
|
|
2601
|
-
* under the
|
|
2769
|
+
* under the curation lock. The log is never written. Like every writer it backs
|
|
2602
2770
|
* up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines the ledger's malformed
|
|
2603
2771
|
* lines before it rewrites the ledger (D-QUARANTINE-MALFORMED); a refusal writes
|
|
2604
2772
|
* nothing.
|
|
@@ -2612,13 +2780,15 @@ function mintAnchor(ledgerRows, type, citedAnchors) {
|
|
|
2612
2780
|
* | { ok: false, error: { kind: string, message: string } }}
|
|
2613
2781
|
* Error kinds: not-in-log, duplicate-log-id, already-promoted, v1-observation,
|
|
2614
2782
|
* type-mismatch, cited-numbers-exhausted, and withDecisionsLock's
|
|
2615
|
-
* not-a-directory, no-
|
|
2783
|
+
* tracked, not-a-directory, no-curation-dir and busy.
|
|
2616
2784
|
* @throws {TypeError} for a type other than decision or pitfall, or a malformed obsId
|
|
2617
2785
|
*/
|
|
2618
2786
|
function assignAnchor(root, type, obsId, { now = Date.now(), timeoutMs, citedAnchors } = {}) {
|
|
2619
2787
|
if (anchorPrefixFor(type) === null) throw new TypeError(`assignAnchor: type must be 'decision' or 'pitfall', got '${type}'`);
|
|
2620
2788
|
if (typeof obsId !== 'string' || !OBS_ID_RE.test(obsId)) throw new TypeError('assignAnchor: obsId must be an observation id');
|
|
2621
|
-
|
|
2789
|
+
const paused = trackedCurationArea('assign-anchor', root);
|
|
2790
|
+
if (paused !== null) return paused;
|
|
2791
|
+
if (!hasCurationDir(root)) return noCurationDir('assign-anchor', root);
|
|
2622
2792
|
const cited = citedAnchors || collectCitedAnchorIds(root);
|
|
2623
2793
|
return withDecisionsLock('assign-anchor', root, () => assignUnderLock(root, type, obsId, { now, cited }), { timeoutMs });
|
|
2624
2794
|
}
|
|
@@ -2760,7 +2930,7 @@ function recordRefreshHistory(root, plans, ledgerRows, { now }) {
|
|
|
2760
2930
|
* an inactive entry or a v1 one, and — when re-projecting — an entry with no
|
|
2761
2931
|
* observation id or whose log row is missing, doubled, v1 or of a type it cannot
|
|
2762
2932
|
* take refuses the whole batch, every refused anchor listed, nothing written. A
|
|
2763
|
-
* batch that changes no row writes nothing. Otherwise, under the
|
|
2933
|
+
* batch that changes no row writes nothing. Otherwise, under the curation lock,
|
|
2764
2934
|
* it backs up a v1 tree (D-V1-BACKUP-ONCE), quarantines the ledger's malformed
|
|
2765
2935
|
* lines (D-QUARANTINE-MALFORMED), then writes the ledger once and renders once.
|
|
2766
2936
|
*
|
|
@@ -2771,7 +2941,7 @@ function recordRefreshHistory(root, plans, ledgerRows, { now }) {
|
|
|
2771
2941
|
* @returns {{ ok: true, value: { refreshed: Array<{ anchor_id: string, state: 'verified'|'reprojected'|'unchanged' }> } }
|
|
2772
2942
|
* | { ok: false, error: { kind: string, message: string, problems?: Array<{ anchor_id: string, message: string }> } }}
|
|
2773
2943
|
* refreshed: each anchor once, in the order given. Error kinds: refused (with
|
|
2774
|
-
* problems), and withDecisionsLock's not-a-directory, no-
|
|
2944
|
+
* problems), and withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy.
|
|
2775
2945
|
* @throws {TypeError} when anchorIds is empty or holds anything but anchor ids
|
|
2776
2946
|
*/
|
|
2777
2947
|
function refreshAnchors(root, anchorIds, { verified = false, now = Date.now(), timeoutMs } = {}) {
|
|
@@ -2954,9 +3124,11 @@ function restoreRefusal(kind, message) {
|
|
|
2954
3124
|
* @param {{ verifyRef?: { ref: 'origin/HEAD'|'HEAD', commit: string } | null }} [opts]
|
|
2955
3125
|
* verifyRef: default resolveVerifyRef(root); null when there is no commit to check
|
|
2956
3126
|
* @returns {{ ok: true, value: { path: string, quote: string, ref: string, commit: string } }
|
|
2957
|
-
* | { ok: false, error: { kind: 'no-verify-ref'|'not-at-ref'|'git-failed'|'quote-not-found', message: string } }}
|
|
3127
|
+
* | { ok: false, error: { kind: 'tracked'|'no-verify-ref'|'not-at-ref'|'git-failed'|'quote-not-found', message: string } }}
|
|
2958
3128
|
*/
|
|
2959
3129
|
function quoteAtRef(root, at, quote, { verifyRef } = {}) {
|
|
3130
|
+
const paused = trackedCurationArea('retire-anchor', root);
|
|
3131
|
+
if (paused !== null) return paused;
|
|
2960
3132
|
const checkedAt = verifyRef === undefined ? resolveVerifyRef(root) : verifyRef;
|
|
2961
3133
|
if (checkedAt === null) {
|
|
2962
3134
|
return retireRefusal('no-verify-ref', 'there is no commit to check the quote at (not a git repository, or no commit yet); nothing was written');
|
|
@@ -2991,7 +3163,7 @@ function quoteAtRef(root, at, quote, { verifyRef } = {}) {
|
|
|
2991
3163
|
* Each also sets retired_on to today and clears the other notes; the rest of the
|
|
2992
3164
|
* row stays as it is, so a v1 entry stays v1. An entry already inactive, an
|
|
2993
3165
|
* anchor the ledger does not hold or holds twice, and a successor absent or
|
|
2994
|
-
* inactive are refused. The input and the quote are checked before the
|
|
3166
|
+
* inactive are refused. The input and the quote are checked before the curation
|
|
2995
3167
|
* lock is taken; under it the ledger is written once and the files are rendered,
|
|
2996
3168
|
* after a v1 tree is backed up (D-V1-BACKUP-ONCE) and malformed ledger lines are
|
|
2997
3169
|
* quarantined (D-QUARANTINE-MALFORMED). A refusal writes nothing.
|
|
@@ -3008,7 +3180,7 @@ function quoteAtRef(root, at, quote, { verifyRef } = {}) {
|
|
|
3008
3180
|
* kinds: invalid-input (with problems), quoteAtRef's, not-found,
|
|
3009
3181
|
* duplicate-anchor, already-inactive, successor-not-found,
|
|
3010
3182
|
* successor-duplicate-anchor, successor-inactive, and withDecisionsLock's
|
|
3011
|
-
* not-a-directory, no-
|
|
3183
|
+
* tracked, not-a-directory, no-curation-dir and busy.
|
|
3012
3184
|
* @throws {TypeError} for a malformed anchorId or a status that is not inactive
|
|
3013
3185
|
*/
|
|
3014
3186
|
function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeoutMs, verifyRef } = {}) {
|
|
@@ -3016,7 +3188,9 @@ function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeout
|
|
|
3016
3188
|
if (!INACTIVE_STATUSES.includes(status)) {
|
|
3017
3189
|
throw new TypeError(`retireAnchor: status must be one of ${INACTIVE_STATUSES.join(', ')}, got '${status}'`);
|
|
3018
3190
|
}
|
|
3019
|
-
|
|
3191
|
+
const paused = trackedCurationArea('retire-anchor', root);
|
|
3192
|
+
if (paused !== null) return paused;
|
|
3193
|
+
if (!hasCurationDir(root)) return noCurationDir('retire-anchor', root);
|
|
3020
3194
|
const problems = retireInputProblems(anchorId, status, input);
|
|
3021
3195
|
if (problems.length > 0) return invalidInput('retire-anchor', problems);
|
|
3022
3196
|
let note;
|
|
@@ -3078,7 +3252,7 @@ function retireUnderLock(root, anchorId, status, note, { now }) {
|
|
|
3078
3252
|
* claim-due hands it out ahead of every verified entry (D-DUE-ORDER); its content
|
|
3079
3253
|
* and its date stay, and the files are re-rendered. An entry already active, an
|
|
3080
3254
|
* anchor the ledger does not hold or holds twice, and a row whose type its anchor
|
|
3081
|
-
* does not name are refused. It writes as retireAnchor does, under the
|
|
3255
|
+
* does not name are refused. It writes as retireAnchor does, under the curation
|
|
3082
3256
|
* lock; a refusal writes nothing.
|
|
3083
3257
|
*
|
|
3084
3258
|
* @param {string} root - project root
|
|
@@ -3087,7 +3261,7 @@ function retireUnderLock(root, anchorId, status, note, { now }) {
|
|
|
3087
3261
|
* @returns {{ ok: true, value: { anchor_id: string, status: 'Accepted'|'Active' } }
|
|
3088
3262
|
* | { ok: false, error: { kind: string, message: string } }}
|
|
3089
3263
|
* Error kinds: not-found, duplicate-anchor, already-active, type-mismatch, and
|
|
3090
|
-
* withDecisionsLock's not-a-directory, no-
|
|
3264
|
+
* withDecisionsLock's tracked, not-a-directory, no-curation-dir and busy.
|
|
3091
3265
|
* @throws {TypeError} for a malformed anchorId
|
|
3092
3266
|
*/
|
|
3093
3267
|
function restoreAnchor(root, anchorId, { now = Date.now(), timeoutMs } = {}) {
|
|
@@ -3148,15 +3322,17 @@ module.exports = {
|
|
|
3148
3322
|
readJsonl,
|
|
3149
3323
|
rejectedPathFor,
|
|
3150
3324
|
quarantineRejected,
|
|
3325
|
+
appendNoFollow,
|
|
3151
3326
|
readJsonlForWrite,
|
|
3152
3327
|
writeExclusive,
|
|
3153
3328
|
writeFileAtomic,
|
|
3154
3329
|
writeJsonlAtomic,
|
|
3155
3330
|
// Locking
|
|
3156
|
-
|
|
3331
|
+
hasCurationDir,
|
|
3332
|
+
trackedCurationArea,
|
|
3157
3333
|
withDecisionsLock,
|
|
3158
3334
|
// State and the ledger registry
|
|
3159
|
-
|
|
3335
|
+
readCurationState,
|
|
3160
3336
|
ledgerRegistry,
|
|
3161
3337
|
// Validation
|
|
3162
3338
|
validateObservationInput,
|
|
@@ -3171,7 +3347,7 @@ module.exports = {
|
|
|
3171
3347
|
// Rotation, clearing and resetting
|
|
3172
3348
|
rotateObservations,
|
|
3173
3349
|
clearUnreferenced,
|
|
3174
|
-
|
|
3350
|
+
resetCuration,
|
|
3175
3351
|
// The queue claim
|
|
3176
3352
|
CLAIM_STALE_SECS,
|
|
3177
3353
|
CLAIM_TOKEN_RE,
|
|
@@ -3180,6 +3356,16 @@ module.exports = {
|
|
|
3180
3356
|
claimQueue,
|
|
3181
3357
|
releaseClaim,
|
|
3182
3358
|
touchClaim,
|
|
3359
|
+
// Primitives curation-move.cjs builds on
|
|
3360
|
+
QUEUE_LOCK_STALE_MS,
|
|
3361
|
+
NO_HARD_LINK_CODES,
|
|
3362
|
+
isPlainObject,
|
|
3363
|
+
readTextUnlinked,
|
|
3364
|
+
removeIfPresent,
|
|
3365
|
+
renderAll,
|
|
3366
|
+
lstatOrNull,
|
|
3367
|
+
physicalPath,
|
|
3368
|
+
homeDirOrNull,
|
|
3183
3369
|
// Integrity, listing, due selection and show
|
|
3184
3370
|
integrityFlags,
|
|
3185
3371
|
buildListing,
|