devflow-kit 3.0.1 → 3.2.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 +49 -0
- package/README.md +1 -1
- package/dist/agents/git.md +2 -2
- package/dist/cli/agents-view/index.js +1 -1
- package/dist/cli/agents-view/render.js +71 -17
- package/dist/cli/agents-view/state.js +42 -16
- package/dist/cli/agents-view/terminal.js +5 -5
- package/dist/cli/commands/agents.js +142 -51
- package/dist/cli/commands/ambient.js +1 -1
- package/dist/cli/commands/attribution-prompts.js +8 -8
- package/dist/cli/commands/capture.js +1 -1
- package/dist/cli/commands/compliance-prompts.js +8 -8
- package/dist/cli/commands/compliance.js +8 -7
- package/dist/cli/commands/flags.js +33 -31
- package/dist/cli/commands/hud.js +1 -1
- package/dist/cli/commands/init-seed.js +9 -9
- package/dist/cli/commands/init.js +162 -85
- package/dist/cli/commands/install-report.js +10 -10
- package/dist/cli/commands/learning.js +302 -136
- package/dist/cli/commands/memory.js +36 -15
- package/dist/cli/commands/proxy.js +23 -23
- package/dist/cli/commands/rules.js +6 -5
- package/dist/cli/commands/tracker-prompts.js +6 -6
- package/dist/cli/commands/tracker.js +9 -9
- package/dist/cli/commands/uninstall.js +183 -59
- package/dist/cli/flags-view/render.js +5 -5
- package/dist/cli/flags-view/state.js +9 -9
- package/dist/cli/flags-view/terminal.js +4 -4
- package/dist/cli/tui/cells.js +1 -1
- package/dist/cli/tui/terminal.js +6 -6
- package/dist/commands/code-review.md +0 -2
- package/dist/commands/debug.md +14 -11
- package/dist/commands/dynamic-build.md +51 -47
- package/dist/commands/dynamic-plan.md +27 -7
- package/dist/commands/dynamic-profile.md +17 -3
- package/dist/commands/dynamic-tickets.md +18 -4
- package/dist/commands/explore.md +9 -3
- package/dist/commands/implement.md +20 -16
- package/dist/commands/plan.md +13 -9
- package/dist/commands/release.md +23 -3
- package/dist/commands/research.md +9 -3
- package/dist/commands/resolve.md +9 -12
- package/dist/commands/self-review.md +0 -2
- package/dist/core/agent-frontmatter.js +28 -3
- package/dist/core/agent-models.js +204 -42
- package/dist/core/agent-state.js +28 -6
- package/dist/core/ansi.js +2 -2
- package/dist/core/assets.js +1 -1
- package/dist/core/cache.js +7 -8
- package/dist/core/codex-auth-inspect.js +4 -4
- package/dist/core/compliance-compose.js +3 -3
- package/dist/core/compliance.js +3 -4
- package/dist/core/evidence-policy.js +14 -13
- package/dist/core/external-models.js +1 -1
- package/dist/core/feature-config.js +71 -13
- package/dist/core/feature-switch.js +3 -3
- package/dist/core/flags.js +49 -25
- package/dist/core/fs-atomic.js +6 -7
- package/dist/core/learning-queue-cleanup.js +16 -81
- package/dist/core/learning-store.js +61 -0
- package/dist/core/linked-path.js +46 -0
- package/dist/core/manifest.js +5 -5
- package/dist/core/mds-variants.js +13 -13
- package/dist/core/model-discovery.js +8 -8
- package/dist/core/observations.js +17 -101
- package/dist/core/orphan-sweep.js +4 -4
- package/dist/core/plugins.js +13 -8
- package/dist/core/project-paths.js +9 -13
- package/dist/core/proxy-log.js +8 -8
- package/dist/core/proxy-state.js +3 -3
- package/dist/core/queue-drain.js +31 -0
- package/dist/core/reference-sweep.js +6 -6
- package/dist/core/teammate-mode-cleanup.js +1 -1
- package/dist/core/tracker.js +14 -14
- package/dist/hud/colors.js +2 -2
- package/dist/hud/components/learning-counts.js +54 -22
- package/dist/hud/components/version-badge.js +1 -1
- package/dist/skills/git/references/pr/resolve-review-threads.md +2 -2
- package/dist/skills/git/references/tracker/github/create-release.md +2 -2
- package/dist/skills/git/references/tracker/jira/create-release.md +2 -2
- package/dist/skills/git/references/tracker/linear/create-release.md +2 -2
- package/dist/targets/claude-code/compliance-install.js +17 -15
- package/dist/targets/claude-code/hooks.js +2 -2
- package/dist/targets/claude-code/installer.js +59 -32
- package/dist/targets/claude-code/legacy.js +1 -1
- package/dist/targets/claude-code/post-install.js +135 -45
- package/dist/targets/claude-code/tracker-install.js +2 -2
- package/package.json +1 -1
- package/src/assets/agents/code.md +15 -21
- package/src/assets/agents/design.md +4 -2
- package/src/assets/agents/diagnose.md +3 -1
- package/src/assets/agents/evaluate.md +4 -0
- package/src/assets/agents/git.mds +2 -2
- package/src/assets/agents/knowledge.md +5 -3
- package/src/assets/agents/learning.md +281 -196
- package/src/assets/agents/research.md +3 -1
- package/src/assets/agents/review.md +5 -3
- package/src/assets/agents/scrutinize.md +5 -1
- package/src/assets/agents/simplify.md +4 -0
- package/src/assets/agents/skim.md +4 -2
- package/src/assets/agents/synthesize.md +6 -0
- package/src/assets/agents/test.md +18 -10
- package/src/assets/agents/triage.md +11 -9
- package/src/assets/agents/validate.md +14 -10
- package/src/assets/commands/_partials/_decisions.mds +8 -3
- package/src/assets/commands/_partials/_docs_root.mds +3 -3
- package/src/assets/commands/_partials/_engine.mds +16 -32
- package/src/assets/commands/_partials/_knowledge.mds +0 -2
- package/src/assets/commands/_partials/_preamble.mds +6 -2
- package/src/assets/commands/_partials/_settings.mds +2 -2
- package/src/assets/commands/_partials/_tracker.mds +1 -1
- package/src/assets/commands/code-review.mds +0 -2
- package/src/assets/commands/debug.mds +13 -8
- package/src/assets/commands/dynamic-build.mds +18 -12
- package/src/assets/commands/dynamic-plan.mds +10 -4
- package/src/assets/commands/dynamic-profile.mds +1 -1
- package/src/assets/commands/dynamic-tickets.mds +2 -2
- package/src/assets/commands/explore.mds +9 -1
- package/src/assets/commands/implement.mds +19 -13
- package/src/assets/commands/plan.mds +12 -8
- package/src/assets/commands/release.md +23 -3
- package/src/assets/commands/research.mds +9 -3
- package/src/assets/commands/resolve.mds +9 -10
- package/src/assets/mds/git/_pr.mds +3 -3
- package/src/assets/mds/tracker/_common.mds +1 -1
- package/src/assets/mds/tracker/_github.mds +3 -3
- package/src/assets/mds/tracker/_jira.mds +3 -3
- package/src/assets/mds/tracker/_linear.mds +3 -3
- package/src/assets/mds/tracker/_mcp.mds +6 -5
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +4 -2
- package/src/assets/scripts/hooks/background-memory-update +97 -33
- package/src/assets/scripts/hooks/capture-prompt +4 -3
- package/src/assets/scripts/hooks/capture-question +4 -3
- package/src/assets/scripts/hooks/capture-turn +5 -20
- package/src/assets/scripts/hooks/ensure-devflow-init +14 -2
- package/src/assets/scripts/hooks/ensure-proxy +5 -6
- package/src/assets/scripts/hooks/ensure-root-gitignore +123 -11
- package/src/assets/scripts/hooks/git-marker +71 -0
- package/src/assets/scripts/hooks/is-hex-sha +1 -1
- package/src/assets/scripts/hooks/json-helper.cjs +345 -944
- package/src/assets/scripts/hooks/json-parse +25 -129
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +205 -156
- package/src/assets/scripts/hooks/lib/learning-store.cjs +3207 -0
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +7 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +13 -19
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +253 -226
- package/src/assets/scripts/hooks/memory-worker +10 -0
- package/src/assets/scripts/hooks/pre-compact-memory +66 -14
- package/src/assets/scripts/hooks/preamble +9 -1
- package/src/assets/scripts/hooks/queue-append +55 -23
- package/src/assets/scripts/hooks/resolve-project-root +3 -4
- package/src/assets/scripts/hooks/session-start-context +146 -45
- package/src/assets/scripts/hooks/session-start-memory +33 -11
- package/src/assets/scripts/lib/project-config.cjs +2 -2
- package/src/assets/scripts/pr-evidence.cjs +3 -3
- package/src/assets/scripts/redact-secrets.cjs +20 -20
- package/src/assets/scripts/release-trace.cjs +1 -1
- package/src/assets/scripts/resolve-evidence-policy.cjs +3 -3
- package/src/assets/scripts/resolve-settings.cjs +3 -3
- package/src/assets/scripts/verify-evidence.cjs +2 -2
- package/src/assets/skills/apply-decisions/SKILL.md +37 -17
- package/src/assets/skills/docs-framework/SKILL.md +2 -2
- package/src/assets/skills/feature-knowledge/SKILL.md +6 -5
- package/src/assets/skills/test-driven-development/SKILL.md +6 -4
- package/dist/core/observation-io.js +0 -50
- package/src/assets/scripts/hooks/decisions-usage-scan.cjs +0 -131
|
@@ -0,0 +1,3207 @@
|
|
|
1
|
+
// src/assets/scripts/hooks/lib/learning-store.cjs
|
|
2
|
+
//
|
|
3
|
+
// The v2 learning store: the one place the learning plumbing reads, validates,
|
|
4
|
+
// projects and writes the decisions log, the ledger and their side files.
|
|
5
|
+
//
|
|
6
|
+
// DESIGN: plumbing only. No function here prints or calls process.exit. Work that
|
|
7
|
+
// can fail on its input or on the lock returns a Result — { ok: true, value } or
|
|
8
|
+
// { ok: false, error: { kind, message } } — and its caller prints it: json-helper.cjs
|
|
9
|
+
// for the ops, or the `devflow learning` CLI through src/core/learning-store.ts. A throw
|
|
10
|
+
// means a broken invariant or an I/O failure, never an expected outcome; a lock
|
|
11
|
+
// held by withDecisionsLock is released on every path, the throw included.
|
|
12
|
+
//
|
|
13
|
+
// Loading: node built-ins and three sibling libs only. This module never requires
|
|
14
|
+
// decisions-format.cjs or render-decisions.cjs at load time: both require it, for
|
|
15
|
+
// the status list and the one-line and inactive-note text they share with `list`.
|
|
16
|
+
//
|
|
17
|
+
// Files under <root>/.devflow/learning/:
|
|
18
|
+
// decisions-log.jsonl observation rows — the content authority
|
|
19
|
+
// decisions-ledger.jsonl anchored rows — projections of log rows
|
|
20
|
+
// decisions-log.archive.jsonl rotated-out observation rows (D-ROTATE-UNREFERENCED)
|
|
21
|
+
// decisions-history.jsonl prior content versions (D-CONTENT-HISTORY)
|
|
22
|
+
// *.rejected.jsonl quarantined malformed lines (D-QUARANTINE-MALFORMED)
|
|
23
|
+
// *.pre-v2.jsonl one-time copies of the v1 files (D-V1-BACKUP-ONCE)
|
|
24
|
+
// .decisions.lock/ the one learning lock (D-ONE-LEARNING-LOCK)
|
|
25
|
+
// .pending-turns.jsonl the queue the capture hooks append to
|
|
26
|
+
// .pending-turns.processing the claimed batch, and .pending-turns.owner its
|
|
27
|
+
// owner's token (D-OWNED-CLAIM)
|
|
28
|
+
//
|
|
29
|
+
// TS COUNTERPARTS: src/core/observations.ts mirrors the status lists (D201), and
|
|
30
|
+
// src/core/learning-store.ts transcribes the functions the CLI calls from their
|
|
31
|
+
// JSDoc here (D-LEARNING-STORE-SEAM) — change both together.
|
|
32
|
+
|
|
33
|
+
'use strict';
|
|
34
|
+
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const { execFileSync } = require('child_process');
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
getLearningDir,
|
|
42
|
+
getLearningPendingTurnsPath,
|
|
43
|
+
getLearningPendingTurnsProcessingPath,
|
|
44
|
+
getLearningClaimOwnerPath,
|
|
45
|
+
getDecisionsLedgerPath,
|
|
46
|
+
getDecisionsLogPath,
|
|
47
|
+
getDecisionsArchivePath,
|
|
48
|
+
getDecisionsHistoryPath,
|
|
49
|
+
getDecisionsLockDir,
|
|
50
|
+
} = require('./project-paths.cjs');
|
|
51
|
+
const { acquireMkdirLock, releaseLock } = require('./mkdir-lock.cjs');
|
|
52
|
+
const { safePath } = require('./safe-path.cjs');
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Constants
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/** Schema version stamped on every v2 log and ledger row. */
|
|
59
|
+
const SCHEMA_VERSION = 2;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Field limits. Text limits count characters (code points); `scopeMin`/`scopeMax`
|
|
63
|
+
* and `evidenceMax` count entries. `note`, `quoteMin`, `quoteMax` and `path` bound
|
|
64
|
+
* the status-change inputs.
|
|
65
|
+
*/
|
|
66
|
+
const FIELD_LIMITS = Object.freeze({
|
|
67
|
+
title: 120,
|
|
68
|
+
rule: 400,
|
|
69
|
+
why: 300,
|
|
70
|
+
provenance: 120,
|
|
71
|
+
scopeMin: 1,
|
|
72
|
+
scopeMax: 5,
|
|
73
|
+
scopeEntry: 200,
|
|
74
|
+
evidenceMax: 5,
|
|
75
|
+
evidenceItem: 300,
|
|
76
|
+
note: 120,
|
|
77
|
+
quoteMin: 12,
|
|
78
|
+
quoteMax: 200,
|
|
79
|
+
path: 300,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Statuses of an entry that renders: decisions are Accepted, pitfalls Active. */
|
|
83
|
+
const ACTIVE_STATUSES = Object.freeze(['Accepted', 'Active']);
|
|
84
|
+
|
|
85
|
+
/** Statuses of an entry the ledger keeps but every rendered file and count leaves out. */
|
|
86
|
+
const INACTIVE_STATUSES = Object.freeze(['Encoded', 'Superseded', 'Retired', 'Deprecated']);
|
|
87
|
+
|
|
88
|
+
/** Every status a ledger entry may carry. src/core/observations.ts mirrors it (D201). */
|
|
89
|
+
const ENTRY_STATUSES = Object.freeze([...ACTIVE_STATUSES, ...INACTIVE_STATUSES]);
|
|
90
|
+
|
|
91
|
+
/** An observation's content keys, in the order they are stored (after `id`). */
|
|
92
|
+
const CONTENT_KEYS = Object.freeze(['type', 'title', 'rule', 'why', 'scope', 'provenance', 'evidence']);
|
|
93
|
+
|
|
94
|
+
/** Keys plumbing sets on a log row; an input carrying one is refused (D-PUT-NOT-MERGE). */
|
|
95
|
+
const PLUMBING_OWNED_KEYS = Object.freeze(['schema', 'observations', 'first_seen', 'last_seen', 'status', 'anchor_id']);
|
|
96
|
+
|
|
97
|
+
/** Keys only the ledger holds, carried across every re-projection (D-LOG-CONTENT-AUTHORITY). */
|
|
98
|
+
const LEDGER_OWNED_KEYS = Object.freeze([
|
|
99
|
+
'date', 'last_verified', 'last_attempt', 'status_note', 'superseded_by', 'encoded_at', 'retired_on',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/** Maintenance hand-out parameters (D-DUE-ORDER). */
|
|
103
|
+
const DUE = Object.freeze({ verifyAgeDays: 30, leaseHours: 24, maxEntries: 5, byteBudget: 61440 });
|
|
104
|
+
|
|
105
|
+
/** Prior content versions kept per observation id (D-CONTENT-HISTORY). */
|
|
106
|
+
const HISTORY_DEPTH = 3;
|
|
107
|
+
|
|
108
|
+
/** How long a writer waits for .decisions.lock before reporting busy (ms). */
|
|
109
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
|
|
110
|
+
|
|
111
|
+
/** Age after which a held .decisions.lock is treated as abandoned and broken (ms). */
|
|
112
|
+
const LOCK_STALE_MS = 60000;
|
|
113
|
+
|
|
114
|
+
/** An anchor id: ADR-NNN or PF-NNN, three or more digits. */
|
|
115
|
+
const ANCHOR_ID_RE = /^(ADR|PF)-\d{3,}$/;
|
|
116
|
+
|
|
117
|
+
/** An observation id. */
|
|
118
|
+
const OBS_ID_RE = /^obs_[a-z0-9_]{3,60}$/;
|
|
119
|
+
|
|
120
|
+
/** Bound on each git call (ms) and on its output (bytes). */
|
|
121
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
122
|
+
const GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
123
|
+
|
|
124
|
+
/** A full commit id, SHA-1 or SHA-256. */
|
|
125
|
+
const COMMIT_ID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
126
|
+
|
|
127
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
128
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
129
|
+
|
|
130
|
+
/** The content keys a ledger row projects from its log row (evidence stays in the log). */
|
|
131
|
+
const PROJECTED_CONTENT_KEYS = Object.freeze(['type', 'title', 'rule', 'why', 'scope', 'provenance']);
|
|
132
|
+
|
|
133
|
+
/** Keys a create or an update must carry (D-PUT-NOT-MERGE). */
|
|
134
|
+
const REQUIRED_KEYS = Object.freeze(['id', 'type', 'title', 'rule', 'why', 'scope', 'provenance']);
|
|
135
|
+
|
|
136
|
+
const VALIDATION_MODES = Object.freeze(['create', 'update', 'reinforce']);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* C0 and C1 control characters, DEL, the JS line terminators U+2028/U+2029 and the
|
|
140
|
+
* bidirectional formatting controls — none may appear in an input string, and
|
|
141
|
+
* listings collapse them to a space.
|
|
142
|
+
*/
|
|
143
|
+
const CONTROL_CHARS_CLASS = '[\\u0000-\\u001f\\u007f-\\u009f\\u200e\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]';
|
|
144
|
+
const CONTROL_CHAR_RE = new RegExp(CONTROL_CHARS_CLASS);
|
|
145
|
+
const CONTROL_RUN_RE = new RegExp(`${CONTROL_CHARS_CLASS}+`, 'g');
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An anchor id written as a whole word in free text: ADR-NNN or PF-NNN, three or
|
|
149
|
+
* more digits. Title, rule and why may not name one the ledger holds, and the
|
|
150
|
+
* cited-number scan collects the ones tracked files cite.
|
|
151
|
+
*/
|
|
152
|
+
const ANCHOR_WORD_RE = /\b(?:ADR|PF)-\d{3,}\b/g;
|
|
153
|
+
|
|
154
|
+
/** An issue or PR reference: `#` and digits after the start or a non-word character other than `&`. */
|
|
155
|
+
const ISSUE_REF_RE = /(?:^|[^\w&])#\d+/;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Source and text file extensions whose `name.ext:line` or `name.ext#Lline` form is
|
|
159
|
+
* a file-and-line reference. A host:port carries none of them, so it passes.
|
|
160
|
+
*/
|
|
161
|
+
const LINE_REF_EXTENSIONS = Object.freeze([
|
|
162
|
+
'ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'cjs', 'mjs', 'py', 'go', 'rs', 'java', 'kt', 'rb', 'php',
|
|
163
|
+
'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'swift', 'sh', 'bash', 'zsh', 'md', 'mds', 'mdx', 'json', 'jsonl',
|
|
164
|
+
'ya?ml', 'toml', 'txt', 'html', 'css', 'scss', 'sql', 'xml',
|
|
165
|
+
]);
|
|
166
|
+
const FILE_LINE_REF_RE = new RegExp(`[\\w-]\\.(?:${LINE_REF_EXTENSIONS.join('|')})(?::\\d+|#L\\d+)`, 'i');
|
|
167
|
+
|
|
168
|
+
/** A scope area tag. */
|
|
169
|
+
const AREA_TAG_RE = /^area:[a-z0-9][a-z0-9-]{0,39}$/;
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Small helpers
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/** @param {unknown} value @returns {boolean} */
|
|
176
|
+
function isPlainObject(value) {
|
|
177
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {unknown} value @returns {boolean} */
|
|
181
|
+
function isNonEmptyString(value) {
|
|
182
|
+
return typeof value === 'string' && value.length > 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Length in characters (code points), not UTF-16 code units. */
|
|
186
|
+
function codePointLength(text) {
|
|
187
|
+
let n = 0;
|
|
188
|
+
for (const _ of text) n += 1;
|
|
189
|
+
return n;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A deep copy of a JSON value, so a returned row never shares structure with its inputs. */
|
|
193
|
+
function copyJson(value) {
|
|
194
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when both values serialize to the same JSON. */
|
|
198
|
+
function sameJson(a, b) {
|
|
199
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `text` with each run of control characters collapsed to one space: listings and
|
|
204
|
+
* the rendered files show every field on one line.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} text
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function singleLine(text) {
|
|
210
|
+
return text.replace(CONTROL_RUN_RE, ' ');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `text` cut to at most `max` characters, the last one `…` when it was cut. */
|
|
214
|
+
function cutTo(text, max) {
|
|
215
|
+
const chars = Array.from(text);
|
|
216
|
+
return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The anchor prefix rows of `type` take, or null for any other type. */
|
|
220
|
+
function anchorPrefixFor(type) {
|
|
221
|
+
if (type === 'decision') return 'ADR';
|
|
222
|
+
if (type === 'pitfall') return 'PF';
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Sort key of an anchor id: decisions before pitfalls, then by number; anything else last. */
|
|
227
|
+
function anchorOrder(anchorId) {
|
|
228
|
+
const m = typeof anchorId === 'string' ? /^(ADR|PF)-(\d+)$/.exec(anchorId) : null;
|
|
229
|
+
if (!m) return [2, Infinity];
|
|
230
|
+
return [m[1] === 'ADR' ? 0 : 1, parseInt(m[2], 10)];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Comparator for rows by anchor_id (see anchorOrder). */
|
|
234
|
+
function compareByAnchor(a, b) {
|
|
235
|
+
const [rankA, numA] = anchorOrder(a.anchor_id);
|
|
236
|
+
const [rankB, numB] = anchorOrder(b.anchor_id);
|
|
237
|
+
if (rankA !== rankB) return rankA - rankB;
|
|
238
|
+
if (numA !== numB) return numA < numB ? -1 : 1;
|
|
239
|
+
return String(a.anchor_id).localeCompare(String(b.anchor_id));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Comparator for strings by UTF-16 code unit, the order `<` gives. */
|
|
243
|
+
function compareText(a, b) {
|
|
244
|
+
if (a < b) return -1;
|
|
245
|
+
return a > b ? 1 : 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A sorted copy of `rows`, by anchor. */
|
|
249
|
+
function sortedByAnchor(rows) {
|
|
250
|
+
return [...rows].sort(compareByAnchor);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Ledger rows that carry an anchor and an active status. */
|
|
254
|
+
function activeAnchoredRows(ledger) {
|
|
255
|
+
return ledger.filter(row => isNonEmptyString(row.anchor_id) && isActive(row));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `file` with its `.jsonl` extension replaced by `suffix` (appended when it has none). */
|
|
259
|
+
function withJsonlSuffix(file, suffix) {
|
|
260
|
+
return /\.jsonl$/.test(file) ? file.replace(/\.jsonl$/, suffix) : file + suffix;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Run `git <args>` in `root` and return its stdout. The argv is a literal array,
|
|
265
|
+
* never a shell string, and every call turns `core.fsmonitor` off (D-NO-FSMONITOR,
|
|
266
|
+
* documented at listGitTrackedFiles): git runs the command a repository's config
|
|
267
|
+
* names there whenever it reads the index. Each call is bounded by GIT_TIMEOUT_MS
|
|
268
|
+
* and GIT_MAX_BUFFER, and stderr is discarded.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} root - the directory to run in
|
|
271
|
+
* @param {string[]} args - the git subcommand and its arguments
|
|
272
|
+
* @returns {string}
|
|
273
|
+
* @throws when git is missing or `root` is not a working tree, the call times out or
|
|
274
|
+
* overflows its buffer, or git exits non-zero (the error's `status` is its exit code)
|
|
275
|
+
*/
|
|
276
|
+
function git(root, args) {
|
|
277
|
+
return execFileSync('git', ['-c', 'core.fsmonitor=false', ...args], {
|
|
278
|
+
cwd: root,
|
|
279
|
+
timeout: GIT_TIMEOUT_MS,
|
|
280
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
281
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
282
|
+
encoding: 'utf8',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Status helpers
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* True when a ledger row renders: its decisions_status is absent, or is anything
|
|
292
|
+
* outside INACTIVE_STATUSES (an unknown status counts as active).
|
|
293
|
+
*
|
|
294
|
+
* @param {{ decisions_status?: unknown }} row
|
|
295
|
+
* @returns {boolean}
|
|
296
|
+
*/
|
|
297
|
+
function isActive(row) {
|
|
298
|
+
const status = row.decisions_status;
|
|
299
|
+
return !status || !INACTIVE_STATUSES.includes(status);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The active status of an entry of `type`: Accepted for a decision, Active for a
|
|
304
|
+
* pitfall. Any other type is a caller error.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} type
|
|
307
|
+
* @returns {'Accepted'|'Active'}
|
|
308
|
+
*/
|
|
309
|
+
function activeStatusFor(type) {
|
|
310
|
+
if (type === 'decision') return 'Accepted';
|
|
311
|
+
if (type === 'pitfall') return 'Active';
|
|
312
|
+
throw new Error(`activeStatusFor: type must be 'decision' or 'pitfall', got '${type}'`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* True for a v2 row (schema 2).
|
|
317
|
+
*
|
|
318
|
+
* @param {unknown} row
|
|
319
|
+
* @returns {boolean}
|
|
320
|
+
*/
|
|
321
|
+
function isV2(row) {
|
|
322
|
+
return isPlainObject(row) && row.schema === SCHEMA_VERSION;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// JSONL I/O
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
/** One JSONL line as a row, or undefined when it is not exactly one JSON object. */
|
|
330
|
+
function parseRow(text) {
|
|
331
|
+
try {
|
|
332
|
+
const value = JSON.parse(text);
|
|
333
|
+
return isPlainObject(value) ? value : undefined;
|
|
334
|
+
} catch {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The first `size` bytes of the open file `fd` as UTF-8 text, fewer when the file
|
|
341
|
+
* ends sooner: the read never takes more than `size` bytes.
|
|
342
|
+
*
|
|
343
|
+
* @param {number} fd
|
|
344
|
+
* @param {number} size - the byte count fstat reported for `fd`
|
|
345
|
+
* @returns {string}
|
|
346
|
+
*/
|
|
347
|
+
function readOpenedText(fd, size) {
|
|
348
|
+
const buf = Buffer.alloc(size);
|
|
349
|
+
let total = 0;
|
|
350
|
+
while (total < buf.length) {
|
|
351
|
+
const read = fs.readSync(fd, buf, total, buf.length - total, total);
|
|
352
|
+
if (read === 0) break;
|
|
353
|
+
total += read;
|
|
354
|
+
}
|
|
355
|
+
return buf.toString('utf8', 0, total);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The text of `file`, or null when nothing is there, when the file is anything
|
|
360
|
+
* but a regular file — a symbolic link, a directory, a FIFO or a device — or when
|
|
361
|
+
* it is larger than `maxBytes` (D-NO-LINKED-READ, at readJsonl). lstat decides
|
|
362
|
+
* before anything is opened, seeing a link without following it. The open never
|
|
363
|
+
* follows a link (O_NOFOLLOW) and never waits on a FIFO (O_NONBLOCK), and fstat
|
|
364
|
+
* confirms that what it opened is a regular file within the bound: a link that
|
|
365
|
+
* took the file's place after the lstat fails the read rather than being read
|
|
366
|
+
* through, and anything else that did reads as absent. The read never takes more
|
|
367
|
+
* bytes than fstat reported.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} file - an absolute path
|
|
370
|
+
* @param {{ maxBytes?: number }} [opts] - maxBytes: the largest file read (default no cap)
|
|
371
|
+
* @returns {string|null}
|
|
372
|
+
* @throws on any other read error
|
|
373
|
+
*/
|
|
374
|
+
function readTextUnlinked(file, { maxBytes = Infinity } = {}) {
|
|
375
|
+
let fd;
|
|
376
|
+
try {
|
|
377
|
+
const stat = fs.lstatSync(file);
|
|
378
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
379
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
380
|
+
} catch (err) {
|
|
381
|
+
if (err && err.code === 'ENOENT') return null;
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const stat = fs.fstatSync(fd);
|
|
386
|
+
return stat.isFile() && stat.size <= maxBytes ? readOpenedText(fd, stat.size) : null;
|
|
387
|
+
} finally {
|
|
388
|
+
fs.closeSync(fd);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Read a JSONL file strictly: every non-blank line is either one JSON object (a
|
|
394
|
+
* row) or rejected, with its 1-based line number and its text.
|
|
395
|
+
*
|
|
396
|
+
* D-QUARANTINE-MALFORMED: a line that is not one JSON object is never read as a
|
|
397
|
+
* row and never silently dropped. readJsonl returns it among `rejected`; a writer
|
|
398
|
+
* appends it to the file's `.rejected.jsonl` sibling before rewriting the file
|
|
399
|
+
* (readJsonlForWrite), and a read-only path only reports it and writes nothing.
|
|
400
|
+
* Reason: a reader that skips malformed lines lets the next whole-file rewrite
|
|
401
|
+
* delete them without a trace, and a reader that quarantined would make list,
|
|
402
|
+
* show and the HUD write files.
|
|
403
|
+
*
|
|
404
|
+
* D-NO-LINKED-READ: a learning file that is itself a symbolic link reads as
|
|
405
|
+
* missing, and nothing is read through it; the pre-v2 backup skips one the same
|
|
406
|
+
* way (ensurePreV2Backup), and release-claim reads the claim's owner file the same
|
|
407
|
+
* way, and only up to CLAIM_OWNER_MAX_BYTES (readClaimOwner). readJsonl and
|
|
408
|
+
* readClaimOwner read a regular file alone (readTextUnlinked): a directory, a FIFO
|
|
409
|
+
* or a device where the file belongs reads as missing too, and neither waits on
|
|
410
|
+
* one. Reason: a repository can commit any learning file as a link to a file
|
|
411
|
+
* elsewhere on the machine, and a read that followed it would put that file's
|
|
412
|
+
* lines into list and show and, through a rewrite, the quarantine or a render,
|
|
413
|
+
* into the project's learning folder; a read that followed one to a FIFO or to
|
|
414
|
+
* /dev/zero would wait forever or fill memory, holding the learning lock when a
|
|
415
|
+
* writer or release-claim reads. The writers never write through a link either:
|
|
416
|
+
* a rename replaces one, and an append refuses one.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} file
|
|
419
|
+
* @returns {{ rows: object[], rejected: Array<{ line: number, text: string }>, missing: boolean }}
|
|
420
|
+
* `missing` is true when the file does not exist, or is a symbolic link or
|
|
421
|
+
* anything else but a regular file. Any other read error is thrown.
|
|
422
|
+
*/
|
|
423
|
+
function readJsonl(file) {
|
|
424
|
+
const raw = readTextUnlinked(safePath(file));
|
|
425
|
+
if (raw === null) return { rows: [], rejected: [], missing: true };
|
|
426
|
+
const rows = [];
|
|
427
|
+
const rejected = [];
|
|
428
|
+
const lines = raw.split('\n');
|
|
429
|
+
for (let i = 0; i < lines.length; i++) {
|
|
430
|
+
const text = lines[i];
|
|
431
|
+
if (text.trim() === '') continue;
|
|
432
|
+
const row = parseRow(text);
|
|
433
|
+
if (row === undefined) rejected.push({ line: i + 1, text });
|
|
434
|
+
else rows.push(row);
|
|
435
|
+
}
|
|
436
|
+
return { rows, rejected, missing: false };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The quarantine file for `file`: `decisions-log.jsonl` → `decisions-log.rejected.jsonl`.
|
|
441
|
+
*
|
|
442
|
+
* @param {string} file
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
function rejectedPathFor(file) {
|
|
446
|
+
return withJsonlSuffix(file, '.rejected.jsonl');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Append to `file`, creating it when absent. O_NOFOLLOW refuses a symlink planted
|
|
451
|
+
* at `file` (ELOOP) instead of writing through it.
|
|
452
|
+
*/
|
|
453
|
+
function appendNoFollow(file, content) {
|
|
454
|
+
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, 0o666);
|
|
456
|
+
try {
|
|
457
|
+
fs.writeFileSync(fd, content);
|
|
458
|
+
} finally {
|
|
459
|
+
fs.closeSync(fd);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Append each rejected line of `file` to its quarantine file as one record
|
|
465
|
+
* `{ rejected_at, source, line, text }` (D-QUARANTINE-MALFORMED). Append-only;
|
|
466
|
+
* writes nothing when nothing was rejected.
|
|
467
|
+
*
|
|
468
|
+
* @param {string} file - the file the lines were read from
|
|
469
|
+
* @param {Array<{ line: number, text: string }>} rejected
|
|
470
|
+
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
471
|
+
* @returns {number} the number of records appended
|
|
472
|
+
*/
|
|
473
|
+
function quarantineRejected(file, rejected, { now = Date.now() } = {}) {
|
|
474
|
+
if (rejected.length === 0) return 0;
|
|
475
|
+
const rejectedAt = new Date(now).toISOString();
|
|
476
|
+
const source = path.basename(file);
|
|
477
|
+
const content = rejected
|
|
478
|
+
.map(r => JSON.stringify({ rejected_at: rejectedAt, source, line: r.line, text: r.text }))
|
|
479
|
+
.join('\n') + '\n';
|
|
480
|
+
appendNoFollow(rejectedPathFor(file), content);
|
|
481
|
+
return rejected.length;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Read `file` for a rewrite: quarantine its malformed lines first, then return
|
|
486
|
+
* its rows (D-QUARANTINE-MALFORMED). Call it only on a path that rewrites the
|
|
487
|
+
* file — a read-only path uses readJsonl.
|
|
488
|
+
*
|
|
489
|
+
* @param {string} file
|
|
490
|
+
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
491
|
+
* @returns {object[]}
|
|
492
|
+
*/
|
|
493
|
+
function readJsonlForWrite(file, { now = Date.now() } = {}) {
|
|
494
|
+
const { rows, rejected } = readJsonl(file);
|
|
495
|
+
quarantineRejected(file, rejected, { now });
|
|
496
|
+
return rows;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Write `tmp` with O_EXCL (wx flag) so the kernel rejects the open if a file or
|
|
501
|
+
* symlink already exists at that path, preventing TOCTOU symlink-follow attacks.
|
|
502
|
+
* On EEXIST (stale or attacker-placed .tmp) it unlinks and retries once.
|
|
503
|
+
*
|
|
504
|
+
* @param {string} tmp - Path to the temporary file.
|
|
505
|
+
* @param {string} content - Content to write.
|
|
506
|
+
*/
|
|
507
|
+
function writeExclusive(tmp, content) {
|
|
508
|
+
try {
|
|
509
|
+
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if (err.code !== 'EEXIST') throw err;
|
|
512
|
+
// Stale or attacker-placed .tmp — remove it and retry once.
|
|
513
|
+
try { fs.unlinkSync(tmp); } catch { /* race — already removed */ }
|
|
514
|
+
fs.writeFileSync(tmp, content, { flag: 'wx' });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Atomically write a text file via a PID-scoped .tmp sibling and rename, so
|
|
520
|
+
* concurrent writers from different processes never collide on one .tmp path.
|
|
521
|
+
*
|
|
522
|
+
* @param {string} file
|
|
523
|
+
* @param {string} content
|
|
524
|
+
*/
|
|
525
|
+
function writeFileAtomic(file, content) {
|
|
526
|
+
const tmp = file + '.tmp.' + process.pid;
|
|
527
|
+
writeExclusive(tmp, content);
|
|
528
|
+
fs.renameSync(tmp, file);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Atomically write rows as JSONL: one row per line with a trailing newline, or an
|
|
533
|
+
* empty file for no rows.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} file
|
|
536
|
+
* @param {object[]} rows
|
|
537
|
+
*/
|
|
538
|
+
function writeJsonlAtomic(file, rows) {
|
|
539
|
+
const content = rows.length > 0 ? rows.map(r => JSON.stringify(r)).join('\n') + '\n' : '';
|
|
540
|
+
writeFileAtomic(file, content);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Locking
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* True when `<root>/.devflow/learning` exists and is a directory.
|
|
549
|
+
*
|
|
550
|
+
* @param {string} root - project root
|
|
551
|
+
* @returns {boolean}
|
|
552
|
+
*/
|
|
553
|
+
function hasLearningDir(root) {
|
|
554
|
+
try {
|
|
555
|
+
return fs.statSync(getLearningDir(root)).isDirectory();
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The error Result of an op run where `<root>/.devflow/learning/` is absent
|
|
564
|
+
* (D-NO-STRAY-TREE).
|
|
565
|
+
*
|
|
566
|
+
* @param {string} opName - operation name, for the message
|
|
567
|
+
* @param {string} root - project root
|
|
568
|
+
* @returns {{ ok: false, error: { kind: 'no-learning-dir', message: string } }}
|
|
569
|
+
*/
|
|
570
|
+
function noLearningDir(opName, root) {
|
|
571
|
+
return {
|
|
572
|
+
ok: false,
|
|
573
|
+
error: { kind: 'no-learning-dir', message: `${opName}: no .devflow/learning/ under ${root} — run from the project root` },
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** True when `file` is itself a symbolic link; false for anything else, or nothing, there. */
|
|
578
|
+
function isSymbolicLink(file) {
|
|
579
|
+
try {
|
|
580
|
+
return fs.lstatSync(file).isSymbolicLink();
|
|
581
|
+
} catch (err) {
|
|
582
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return false;
|
|
583
|
+
throw err;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The first of `<root>/.devflow` and `<root>/.devflow/learning` that is itself a
|
|
589
|
+
* symbolic link, or null when neither is (D-NO-LINKED-TREE).
|
|
590
|
+
*
|
|
591
|
+
* @param {string} root - project root
|
|
592
|
+
* @returns {string|null}
|
|
593
|
+
*/
|
|
594
|
+
function linkedLearningFolder(root) {
|
|
595
|
+
const learningDir = getLearningDir(root);
|
|
596
|
+
for (const dir of [path.dirname(learningDir), learningDir]) {
|
|
597
|
+
if (isSymbolicLink(dir)) return dir;
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The error Result of an op run where `.devflow` or `.devflow/learning` under its
|
|
604
|
+
* root is a symbolic link (D-NO-LINKED-TREE).
|
|
605
|
+
*
|
|
606
|
+
* @param {string} opName - operation name, for the message
|
|
607
|
+
* @param {string} dir - the folder that is a link
|
|
608
|
+
* @returns {{ ok: false, error: { kind: 'not-a-directory', message: string } }}
|
|
609
|
+
*/
|
|
610
|
+
function linkedFolder(opName, dir) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
error: { kind: 'not-a-directory', message: `${opName}: ${dir} is a symbolic link, not a directory; nothing was changed` },
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** True for a Result: `{ ok: true, … }` or `{ ok: false, error: { … } }`. */
|
|
618
|
+
function isResult(value) {
|
|
619
|
+
return isPlainObject(value) && (value.ok === true || (value.ok === false && isPlainObject(value.error)));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Run `fn` under `.decisions.lock` and return the Result it returns, unchanged.
|
|
624
|
+
* The lock is released on every path, a throw from `fn` included; the throw then
|
|
625
|
+
* reaches the caller.
|
|
626
|
+
*
|
|
627
|
+
* D-ONE-LEARNING-LOCK: learning writers take one lock, `.decisions.lock`, through
|
|
628
|
+
* this wrapper — every write to the log, the ledger, their side files and the
|
|
629
|
+
* rendered files happens under it. Reason: two writers under two locks can each
|
|
630
|
+
* read one file, change it and rename their copy over it, and the second rename
|
|
631
|
+
* silently discards the first one's change.
|
|
632
|
+
*
|
|
633
|
+
* D-NO-STRAY-TREE: a learning writer refuses with `no-learning-dir` when
|
|
634
|
+
* `.devflow/learning/` is absent under its root, and the store never creates that
|
|
635
|
+
* 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 learning
|
|
637
|
+
* tree there and write a ledger that no session ever reads.
|
|
638
|
+
*
|
|
639
|
+
* D-NO-LINKED-TREE: a learning writer refuses with `not-a-directory`, changing
|
|
640
|
+
* nothing, when `.devflow` or `.devflow/learning` under its root is a symbolic
|
|
641
|
+
* link. Reason: a repository can commit either one as a link to a folder elsewhere
|
|
642
|
+
* on the machine, and a writer that followed it would take its lock there and
|
|
643
|
+
* rewrite, quarantine, archive, render or delete files in whatever folder the link
|
|
644
|
+
* names. Refused here, before the lock is taken, so every writer refuses in one
|
|
645
|
+
* place; the claim heartbeat makes the same check, and read-only paths still read.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} opName - operation name, for messages
|
|
648
|
+
* @param {string} root - project root
|
|
649
|
+
* @param {() => { ok: boolean }} fn - the locked body; it must return a Result
|
|
650
|
+
* @param {{ timeoutMs?: number, staleMs?: number }} [opts]
|
|
651
|
+
* @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-learning-dir` or `busy`.
|
|
653
|
+
*/
|
|
654
|
+
function withDecisionsLock(opName, root, fn, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS, staleMs = LOCK_STALE_MS } = {}) {
|
|
655
|
+
const linked = linkedLearningFolder(root);
|
|
656
|
+
if (linked !== null) return linkedFolder(opName, linked);
|
|
657
|
+
if (!hasLearningDir(root)) return noLearningDir(opName, root);
|
|
658
|
+
const lockDir = getDecisionsLockDir(root);
|
|
659
|
+
let acquired;
|
|
660
|
+
try {
|
|
661
|
+
acquired = acquireMkdirLock(lockDir, timeoutMs, staleMs);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
// The learning directory went away between the check and the mkdir.
|
|
664
|
+
if (err && err.code === 'ENOENT') return noLearningDir(opName, root);
|
|
665
|
+
throw err;
|
|
666
|
+
}
|
|
667
|
+
if (!acquired) {
|
|
668
|
+
return { ok: false, error: { kind: 'busy', message: `${opName}: timeout acquiring lock at ${lockDir}` } };
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
const result = fn();
|
|
672
|
+
if (!isResult(result)) {
|
|
673
|
+
throw new TypeError(`${opName}: the locked body must return a Result ({ ok: true, value } or { ok: false, error })`);
|
|
674
|
+
}
|
|
675
|
+
return result;
|
|
676
|
+
} finally {
|
|
677
|
+
releaseLock(lockDir);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// State and the ledger registry
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Read the ledger and the log, read-only: malformed lines are reported, never
|
|
687
|
+
* quarantined (D-QUARANTINE-MALFORMED). An absent file, or one that is a symbolic
|
|
688
|
+
* link or not a regular file (D-NO-LINKED-READ), reads as empty.
|
|
689
|
+
*
|
|
690
|
+
* @param {string} root - project root
|
|
691
|
+
* @returns {{ ledgerRows: object[], logRows: object[], rejected: { ledger: Array<{ line: number, text: string }>, log: Array<{ line: number, text: string }> } }}
|
|
692
|
+
*/
|
|
693
|
+
function readLearningState(root) {
|
|
694
|
+
const ledger = readJsonl(getDecisionsLedgerPath(root));
|
|
695
|
+
const log = readJsonl(getDecisionsLogPath(root));
|
|
696
|
+
return {
|
|
697
|
+
ledgerRows: ledger.rows,
|
|
698
|
+
logRows: log.rows,
|
|
699
|
+
rejected: { ledger: ledger.rejected, log: log.rejected },
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Index ledger rows by anchor and by observation id.
|
|
705
|
+
*
|
|
706
|
+
* D-LEDGER-REGISTRY: the ledger alone records what is promoted. An observation is
|
|
707
|
+
* anchored when any ledger row carries its id, and an anchor is taken when any
|
|
708
|
+
* ledger row carries it, whatever the log row says; lookups go through this
|
|
709
|
+
* registry, never through an anchor_id copied onto a log row. Reason: a guard that
|
|
710
|
+
* read the log row's anchor_id had no writer for most anchored rows, so one
|
|
711
|
+
* observation was promoted twice under two numbers.
|
|
712
|
+
*
|
|
713
|
+
* @param {object[]} ledgerRows
|
|
714
|
+
* @returns {{ byAnchor: Map<string, object>, byObsId: Map<string, object[]> }}
|
|
715
|
+
* byAnchor keeps the first row of a repeated anchor; byObsId lists every row
|
|
716
|
+
* carrying the id, in ledger order.
|
|
717
|
+
*/
|
|
718
|
+
function ledgerRegistry(ledgerRows) {
|
|
719
|
+
const byAnchor = new Map();
|
|
720
|
+
const byObsId = new Map();
|
|
721
|
+
for (const row of ledgerRows) {
|
|
722
|
+
if (isNonEmptyString(row.anchor_id) && !byAnchor.has(row.anchor_id)) byAnchor.set(row.anchor_id, row);
|
|
723
|
+
if (isNonEmptyString(row.id)) {
|
|
724
|
+
const carriers = byObsId.get(row.id);
|
|
725
|
+
if (carriers) carriers.push(row);
|
|
726
|
+
else byObsId.set(row.id, [row]);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return { byAnchor, byObsId };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* A predicate for the log rows some ledger row carries, whatever that row's status
|
|
734
|
+
* (D-LEDGER-REGISTRY). A log row with no id is carried by none.
|
|
735
|
+
*
|
|
736
|
+
* @param {object[]} ledgerRows
|
|
737
|
+
* @returns {(logRow: object) => boolean}
|
|
738
|
+
*/
|
|
739
|
+
function carriedBy(ledgerRows) {
|
|
740
|
+
const { byObsId } = ledgerRegistry(ledgerRows);
|
|
741
|
+
return logRow => isNonEmptyString(logRow.id) && byObsId.has(logRow.id);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
// Observation validation
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
/** Why a key outside the accepted set is refused. */
|
|
749
|
+
function keyRefusal(key, mode) {
|
|
750
|
+
if (PLUMBING_OWNED_KEYS.includes(key)) return 'is set by plumbing, never by the caller';
|
|
751
|
+
if (LEDGER_OWNED_KEYS.includes(key)) return 'is held by the ledger, never by an observation';
|
|
752
|
+
if (mode === 'reinforce' && CONTENT_KEYS.includes(key)) return 'is not taken by a reinforce, which carries the id alone';
|
|
753
|
+
return 'is not a known key';
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* The problem that keeps a value from being one line of text, or null: not a
|
|
758
|
+
* string, blank, or holding a control character. Such a value is judged no further.
|
|
759
|
+
*/
|
|
760
|
+
function lineTextProblem(value) {
|
|
761
|
+
if (typeof value !== 'string') return 'must be a string';
|
|
762
|
+
if (value.trim() === '') return 'must not be blank';
|
|
763
|
+
if (CONTROL_CHAR_RE.test(value)) return 'must be one line with no control characters';
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** The problem with a text value's length in characters, or null. */
|
|
768
|
+
function lengthProblem(value, limit) {
|
|
769
|
+
const length = codePointLength(value);
|
|
770
|
+
return length > limit ? `is ${length} characters, over the limit of ${limit}` : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The first problem with a text value, or null: lineTextProblem's, then its length. */
|
|
774
|
+
function textProblem(value, limit) {
|
|
775
|
+
return lineTextProblem(value) || lengthProblem(value, limit);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Every problem with title, rule or why prose that rots, one per kind found: an
|
|
780
|
+
* anchor the ledger holds, an issue reference, a file-and-line reference.
|
|
781
|
+
*
|
|
782
|
+
* @param {string} value
|
|
783
|
+
* @param {Set<string>} ledgerIds - every anchor in the ledger
|
|
784
|
+
* @returns {string[]} in that order
|
|
785
|
+
*/
|
|
786
|
+
function proseProblems(value, ledgerIds) {
|
|
787
|
+
const problems = [];
|
|
788
|
+
const named = (value.match(ANCHOR_WORD_RE) || []).find(anchor => ledgerIds.has(anchor));
|
|
789
|
+
if (named) problems.push(`names ledger entry ${named}; state the rule in words`);
|
|
790
|
+
if (ISSUE_REF_RE.test(value)) problems.push('carries an issue reference; state what it established instead');
|
|
791
|
+
if (FILE_LINE_REF_RE.test(value)) problems.push('carries a file-and-line reference; name the function or quote the line instead');
|
|
792
|
+
return problems;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Every problem with a title, rule or why: a value that is not one line of text
|
|
797
|
+
* reports that alone; otherwise its length and each of its proseProblems.
|
|
798
|
+
*
|
|
799
|
+
* @param {unknown} value
|
|
800
|
+
* @param {number} limit
|
|
801
|
+
* @param {Set<string>} ledgerIds
|
|
802
|
+
* @returns {string[]}
|
|
803
|
+
*/
|
|
804
|
+
function proseFieldProblems(value, limit, ledgerIds) {
|
|
805
|
+
const notText = lineTextProblem(value);
|
|
806
|
+
if (notText) return [notText];
|
|
807
|
+
const tooLong = lengthProblem(value, limit);
|
|
808
|
+
return [...(tooLong ? [tooLong] : []), ...proseProblems(value, ledgerIds)];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The problem with a glob's shape, or null. */
|
|
812
|
+
function globShapeProblem(glob) {
|
|
813
|
+
if (glob.startsWith('/') || glob.startsWith(':')) return 'must be relative to the repository root, with no pathspec magic';
|
|
814
|
+
if (/[\s`|]/.test(glob)) return 'must not contain whitespace, a backtick or |';
|
|
815
|
+
if (glob.split('/').includes('..')) return 'must not contain a .. segment';
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** The problem with one scope entry, or null. git is asked only about a well-shaped glob. */
|
|
820
|
+
function scopeEntryProblem(entry, scopeMatches) {
|
|
821
|
+
const text = textProblem(entry, FIELD_LIMITS.scopeEntry);
|
|
822
|
+
if (text) return text;
|
|
823
|
+
if (entry.startsWith('area:')) {
|
|
824
|
+
return AREA_TAG_RE.test(entry)
|
|
825
|
+
? null
|
|
826
|
+
: 'is not an area tag: area: then a lowercase letter or digit and up to 39 lowercase letters, digits or hyphens';
|
|
827
|
+
}
|
|
828
|
+
return globShapeProblem(entry) || (scopeMatches(entry) ? null : 'matches no tracked file');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Errors for the scope list. */
|
|
832
|
+
function scopeErrors(scope, scopeMatches) {
|
|
833
|
+
if (!Array.isArray(scope)) return [{ field: 'scope', message: 'must be an array of area tags and globs' }];
|
|
834
|
+
const { scopeMin, scopeMax } = FIELD_LIMITS;
|
|
835
|
+
if (scope.length < scopeMin || scope.length > scopeMax) {
|
|
836
|
+
return [{ field: 'scope', message: `holds ${scope.length} entries; it takes ${scopeMin} to ${scopeMax}` }];
|
|
837
|
+
}
|
|
838
|
+
const errors = [];
|
|
839
|
+
scope.forEach((entry, i) => {
|
|
840
|
+
const problem = scopeEntryProblem(entry, scopeMatches);
|
|
841
|
+
if (problem) errors.push({ field: `scope[${i}]`, message: problem });
|
|
842
|
+
});
|
|
843
|
+
return errors;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Errors for the optional evidence list. */
|
|
847
|
+
function evidenceErrors(evidence) {
|
|
848
|
+
if (evidence === undefined) return [];
|
|
849
|
+
if (!Array.isArray(evidence)) return [{ field: 'evidence', message: 'must be an array of quotes' }];
|
|
850
|
+
if (evidence.length > FIELD_LIMITS.evidenceMax) {
|
|
851
|
+
return [{ field: 'evidence', message: `holds ${evidence.length} items, over the limit of ${FIELD_LIMITS.evidenceMax}` }];
|
|
852
|
+
}
|
|
853
|
+
const errors = [];
|
|
854
|
+
evidence.forEach((item, i) => {
|
|
855
|
+
const problem = textProblem(item, FIELD_LIMITS.evidenceItem);
|
|
856
|
+
if (problem) errors.push({ field: `evidence[${i}]`, message: problem });
|
|
857
|
+
});
|
|
858
|
+
return errors;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Validate one put-observation input and report every problem at once.
|
|
863
|
+
*
|
|
864
|
+
* D-PUT-NOT-MERGE: a create or an update carries the whole content — the id and
|
|
865
|
+
* every CONTENT_KEYS key it needs — and the stored row is exactly that content
|
|
866
|
+
* plus the counters plumbing keeps; an update replaces the content and never
|
|
867
|
+
* merges with the prior row. A key plumbing owns (PLUMBING_OWNED_KEYS), a key the
|
|
868
|
+
* ledger owns (LEDGER_OWNED_KEYS) or any other unknown key is refused, and a
|
|
869
|
+
* reinforce carries the id alone. Reason: a merge keeps whatever the new content
|
|
870
|
+
* no longer says, so a stale clause outlives every rewrite, and an input that sets
|
|
871
|
+
* a counter, a status or an anchor would let the writer forge plumbing state.
|
|
872
|
+
*
|
|
873
|
+
* Field rules: text is one line with no control characters, not blank, and within
|
|
874
|
+
* FIELD_LIMITS. Title, rule and why may not name an anchor the ledger holds, carry
|
|
875
|
+
* an issue reference (`#` and digits after a non-word character other than `&`) or
|
|
876
|
+
* carry a file-and-line reference; provenance and evidence record where a lesson
|
|
877
|
+
* came from and may cite all three. A scope entry is an area tag or a glob that is
|
|
878
|
+
* relative, has no `..` segment, whitespace, backtick or `|`, and matches at least
|
|
879
|
+
* one tracked file. A title, rule or why that is one line of text reports its
|
|
880
|
+
* length and every kind of reference it holds together, so one retry can fix them
|
|
881
|
+
* all; any other field, and text that is not one line, reports its first problem.
|
|
882
|
+
*
|
|
883
|
+
* @param {unknown} input - the parsed stdin object
|
|
884
|
+
* @param {{
|
|
885
|
+
* mode: 'create'|'update'|'reinforce',
|
|
886
|
+
* existing?: object|null,
|
|
887
|
+
* ledgerIds?: Iterable<string>,
|
|
888
|
+
* scopeMatches?: (glob: string) => boolean,
|
|
889
|
+
* }} opts
|
|
890
|
+
* existing — the log row with the input's id, or null; ledgerIds — every anchor
|
|
891
|
+
* in the ledger; scopeMatches — required for create and update.
|
|
892
|
+
* @returns {{ ok: true, value: object } | { ok: false, errors: Array<{ field: string, message: string }> }}
|
|
893
|
+
* value is the content in canonical key order (id, then CONTENT_KEYS), or `{ id }`
|
|
894
|
+
* for a reinforce. Errors come key refusals first, then by field in that order;
|
|
895
|
+
* a title, rule or why gives its length first, then a named anchor, an issue
|
|
896
|
+
* reference and a file-and-line reference.
|
|
897
|
+
*/
|
|
898
|
+
function validateObservationInput(input, { mode, existing = null, ledgerIds = [], scopeMatches } = {}) {
|
|
899
|
+
if (!VALIDATION_MODES.includes(mode)) {
|
|
900
|
+
throw new TypeError(`validateObservationInput: mode must be one of ${VALIDATION_MODES.join(', ')}, got '${mode}'`);
|
|
901
|
+
}
|
|
902
|
+
if (mode !== 'reinforce' && typeof scopeMatches !== 'function') {
|
|
903
|
+
throw new TypeError('validateObservationInput: a create or an update needs scopeMatches');
|
|
904
|
+
}
|
|
905
|
+
if (!isPlainObject(input)) return { ok: false, errors: [{ field: '(input)', message: 'must be one JSON object' }] };
|
|
906
|
+
|
|
907
|
+
const errors = [];
|
|
908
|
+
const accepted = mode === 'reinforce' ? ['id'] : ['id', ...CONTENT_KEYS];
|
|
909
|
+
for (const key of Object.keys(input)) {
|
|
910
|
+
if (!accepted.includes(key)) errors.push({ field: key, message: keyRefusal(key, mode) });
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const present = key => input[key] !== undefined;
|
|
914
|
+
const required = mode === 'reinforce' ? ['id'] : REQUIRED_KEYS;
|
|
915
|
+
const fieldError = (field, problem) => { if (problem) errors.push({ field, message: problem }); };
|
|
916
|
+
const missing = key => (required.includes(key) && !present(key) ? 'is required' : null);
|
|
917
|
+
|
|
918
|
+
const idValid = typeof input.id === 'string' && OBS_ID_RE.test(input.id);
|
|
919
|
+
fieldError('id', missing('id') || (idValid ? null : 'must be obs_ and then 3 to 60 lowercase letters, digits or underscores'));
|
|
920
|
+
if (idValid && mode === 'create' && existing) fieldError('id', 'is already in the log; update it instead');
|
|
921
|
+
if (idValid && mode !== 'create' && !existing) fieldError('id', 'is not in the log');
|
|
922
|
+
|
|
923
|
+
if (mode !== 'reinforce') {
|
|
924
|
+
const typeValid = input.type === 'decision' || input.type === 'pitfall';
|
|
925
|
+
fieldError('type', missing('type') || (typeValid ? null : "must be 'decision' or 'pitfall'"));
|
|
926
|
+
if (typeValid && mode === 'update' && existing && existing.type !== input.type) {
|
|
927
|
+
fieldError('type', `cannot change from '${existing.type}' to '${input.type}'`);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const ledgerIdSet = new Set(ledgerIds);
|
|
931
|
+
for (const field of ['title', 'rule', 'why']) {
|
|
932
|
+
const problems = present(field)
|
|
933
|
+
? proseFieldProblems(input[field], FIELD_LIMITS[field], ledgerIdSet)
|
|
934
|
+
: [missing(field)];
|
|
935
|
+
for (const problem of problems) fieldError(field, problem);
|
|
936
|
+
}
|
|
937
|
+
const scopeMissing = missing('scope');
|
|
938
|
+
if (scopeMissing) fieldError('scope', scopeMissing);
|
|
939
|
+
else errors.push(...scopeErrors(input.scope, scopeMatches));
|
|
940
|
+
fieldError('provenance', missing('provenance') || (present('provenance')
|
|
941
|
+
? textProblem(input.provenance, FIELD_LIMITS.provenance)
|
|
942
|
+
: null));
|
|
943
|
+
errors.push(...evidenceErrors(input.evidence));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
947
|
+
const value = { id: input.id };
|
|
948
|
+
if (mode !== 'reinforce') {
|
|
949
|
+
for (const key of CONTENT_KEYS) {
|
|
950
|
+
if (present(key)) value[key] = copyJson(input[key]);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return { ok: true, value };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* A memoized `(glob) => boolean` answering whether `glob` matches at least one
|
|
958
|
+
* file git tracks under `root` (`git ls-files -- ':(glob)<glob>'`). Each glob is
|
|
959
|
+
* asked once per matcher. Any git failure — not a repository, a timeout — answers
|
|
960
|
+
* false, so a scope that cannot be checked is refused, never accepted.
|
|
961
|
+
*
|
|
962
|
+
* @param {string} root - project root
|
|
963
|
+
* @returns {(glob: string) => boolean}
|
|
964
|
+
*/
|
|
965
|
+
function gitScopeMatcher(root) {
|
|
966
|
+
const answers = new Map();
|
|
967
|
+
const tracks = glob => {
|
|
968
|
+
if (!isNonEmptyString(glob)) return false;
|
|
969
|
+
let out;
|
|
970
|
+
try {
|
|
971
|
+
out = git(root, ['ls-files', '-z', '--', ':(glob)' + glob]);
|
|
972
|
+
} catch {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
return out.split('\0').some(name => name !== '');
|
|
976
|
+
};
|
|
977
|
+
return glob => {
|
|
978
|
+
if (!answers.has(glob)) answers.set(glob, tracks(glob));
|
|
979
|
+
return answers.get(glob);
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
// Projection and counters
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Project a v2 log row into its ledger row.
|
|
989
|
+
*
|
|
990
|
+
* D-LOG-CONTENT-AUTHORITY: the observation log is the one home of an entry's
|
|
991
|
+
* content — its type, title, rule, why, scope and provenance. A ledger row is a
|
|
992
|
+
* projection of its log row, built by this function alone, at promotion and at
|
|
993
|
+
* every re-projection; nothing else writes entry content into the ledger. The
|
|
994
|
+
* ledger owns what is about the entry rather than in it: the anchor number,
|
|
995
|
+
* decisions_status and the LEDGER_OWNED_KEYS (the promotion date, last_verified,
|
|
996
|
+
* last_attempt, the status note, superseded_by, encoded_at and retired_on), which
|
|
997
|
+
* carry over from the prior ledger row unless the caller sets them. Reason: a
|
|
998
|
+
* ledger row copied once at promotion silently lost every later sharpening of its
|
|
999
|
+
* entry, and content kept in two places leaves two authorities that disagree.
|
|
1000
|
+
*
|
|
1001
|
+
* Key order: schema, id, type, anchor_id, decisions_status, title, rule, why,
|
|
1002
|
+
* scope, provenance, then the LEDGER_OWNED_KEYS present. Evidence and the
|
|
1003
|
+
* counters stay in the log; a prior v1 row's pattern, details, amendments and any
|
|
1004
|
+
* other key are dropped.
|
|
1005
|
+
*
|
|
1006
|
+
* @param {object} logRow - a v2 log row
|
|
1007
|
+
* @param {object|null|undefined} priorLedgerRow - the entry's current ledger row, or none at promotion
|
|
1008
|
+
* @param {{ anchorId?: string, status?: string, date?: string, expectType?: string }} [opts]
|
|
1009
|
+
* anchorId and status default to the prior row's; date, when given, replaces it.
|
|
1010
|
+
* @returns {object} a new ledger row
|
|
1011
|
+
* @throws when the log row is not v2, its type differs from expectType, the anchor
|
|
1012
|
+
* is malformed or belongs to the other type, or the status is not an entry status
|
|
1013
|
+
*/
|
|
1014
|
+
function toLedgerRowV2(logRow, priorLedgerRow, { anchorId, status, date, expectType } = {}) {
|
|
1015
|
+
if (!isV2(logRow)) throw new Error(`toLedgerRowV2: log row '${logRow && logRow.id}' is not a v2 row`);
|
|
1016
|
+
const prior = priorLedgerRow || {};
|
|
1017
|
+
const anchor = anchorId !== undefined ? anchorId : prior.anchor_id;
|
|
1018
|
+
if (expectType !== undefined && logRow.type !== expectType) {
|
|
1019
|
+
throw new Error(`toLedgerRowV2: type mismatch for ${anchor} — ledger has '${expectType}', log has '${logRow.type}'`);
|
|
1020
|
+
}
|
|
1021
|
+
if (typeof anchor !== 'string' || !ANCHOR_ID_RE.test(anchor)) {
|
|
1022
|
+
throw new Error(`toLedgerRowV2: '${anchor}' is not an anchor id`);
|
|
1023
|
+
}
|
|
1024
|
+
if (!anchor.startsWith(`${anchorPrefixFor(logRow.type)}-`)) {
|
|
1025
|
+
throw new Error(`toLedgerRowV2: anchor ${anchor} does not belong to a ${logRow.type} row`);
|
|
1026
|
+
}
|
|
1027
|
+
const entryStatus = status !== undefined ? status : prior.decisions_status;
|
|
1028
|
+
if (!ENTRY_STATUSES.includes(entryStatus)) {
|
|
1029
|
+
throw new Error(`toLedgerRowV2: '${entryStatus}' is not an entry status`);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const row = {
|
|
1033
|
+
schema: SCHEMA_VERSION,
|
|
1034
|
+
id: logRow.id,
|
|
1035
|
+
type: logRow.type,
|
|
1036
|
+
anchor_id: anchor,
|
|
1037
|
+
decisions_status: entryStatus,
|
|
1038
|
+
};
|
|
1039
|
+
for (const key of PROJECTED_CONTENT_KEYS) {
|
|
1040
|
+
if (key !== 'type' && logRow[key] !== undefined) row[key] = copyJson(logRow[key]);
|
|
1041
|
+
}
|
|
1042
|
+
for (const key of LEDGER_OWNED_KEYS) {
|
|
1043
|
+
const value = key === 'date' && date !== undefined ? date : prior[key];
|
|
1044
|
+
if (value !== undefined) row[key] = copyJson(value);
|
|
1045
|
+
}
|
|
1046
|
+
return row;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* `row` with the ledger-owned fields in `updates` set; a field set to undefined is
|
|
1051
|
+
* removed. The other keys keep their order, and the ledger-owned keys follow them
|
|
1052
|
+
* in LEDGER_OWNED_KEYS order, the order toLedgerRowV2 writes, so a row two
|
|
1053
|
+
* writers have changed serializes the same as the projection would and a
|
|
1054
|
+
* whole-row comparison still means "nothing changed".
|
|
1055
|
+
*
|
|
1056
|
+
* @param {object} row - a ledger row
|
|
1057
|
+
* @param {Record<string, unknown>} updates - ledger-owned fields only
|
|
1058
|
+
* @returns {object} a new row
|
|
1059
|
+
* @throws {TypeError} when `updates` names a key the ledger does not own
|
|
1060
|
+
*/
|
|
1061
|
+
function withLedgerFields(row, updates) {
|
|
1062
|
+
for (const key of Object.keys(updates)) {
|
|
1063
|
+
if (!LEDGER_OWNED_KEYS.includes(key)) throw new TypeError(`withLedgerFields: '${key}' is not a ledger-owned field`);
|
|
1064
|
+
}
|
|
1065
|
+
const next = {};
|
|
1066
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1067
|
+
if (!LEDGER_OWNED_KEYS.includes(key)) next[key] = copyJson(value);
|
|
1068
|
+
}
|
|
1069
|
+
for (const key of LEDGER_OWNED_KEYS) {
|
|
1070
|
+
const value = Object.prototype.hasOwnProperty.call(updates, key) ? updates[key] : row[key];
|
|
1071
|
+
if (value !== undefined) next[key] = copyJson(value);
|
|
1072
|
+
}
|
|
1073
|
+
return next;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** `value` when it is a positive integer, else undefined. */
|
|
1077
|
+
function positiveInteger(value) {
|
|
1078
|
+
return Number.isInteger(value) && value >= 1 ? value : undefined;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** `value` when it is a string that parses as a date, else undefined. */
|
|
1082
|
+
function timestamp(value) {
|
|
1083
|
+
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value)) ? value : undefined;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* The counters a v2 log row carries, derived from any row: `observations` falls
|
|
1088
|
+
* back to `count`, then 1; `first_seen` falls back to `created`, then `last_seen`,
|
|
1089
|
+
* then now; `last_seen` falls back to the resolved `first_seen`. A value that is
|
|
1090
|
+
* not a positive integer or a parseable date counts as absent.
|
|
1091
|
+
*
|
|
1092
|
+
* @param {object} row
|
|
1093
|
+
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1094
|
+
* @returns {{ observations: number, first_seen: string, last_seen: string }}
|
|
1095
|
+
*/
|
|
1096
|
+
function toV2Counters(row, { now = Date.now() } = {}) {
|
|
1097
|
+
const firstSeen = timestamp(row.first_seen) || timestamp(row.created) || timestamp(row.last_seen)
|
|
1098
|
+
|| new Date(now).toISOString();
|
|
1099
|
+
return {
|
|
1100
|
+
observations: positiveInteger(row.observations) || positiveInteger(row.count) || 1,
|
|
1101
|
+
first_seen: firstSeen,
|
|
1102
|
+
last_seen: timestamp(row.last_seen) || firstSeen,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// History and the pre-v2 backup
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Record an entry's prior content in decisions-history.jsonl and trim that entry
|
|
1112
|
+
* to its last HISTORY_DEPTH versions. A writer under the lock calls it before it
|
|
1113
|
+
* replaces the content; malformed history lines are quarantined first.
|
|
1114
|
+
*
|
|
1115
|
+
* D-CONTENT-HISTORY: before a write replaces an entry's content, the writer
|
|
1116
|
+
* appends the prior log row and ledger rows to decisions-history.jsonl, which
|
|
1117
|
+
* keeps the last HISTORY_DEPTH versions per observation id; a write that leaves
|
|
1118
|
+
* the content as it was appends nothing. Reason: rewrites replace content in place
|
|
1119
|
+
* rather than appending to it, so without a history the first bad rewrite would
|
|
1120
|
+
* lose the wording it replaced.
|
|
1121
|
+
*
|
|
1122
|
+
* @param {string} root - project root
|
|
1123
|
+
* @param {{ id: string, ledger?: object[], log?: object|null }} entry - the prior versions
|
|
1124
|
+
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1125
|
+
* @returns {number} the versions the entry now has (at most HISTORY_DEPTH)
|
|
1126
|
+
*/
|
|
1127
|
+
function appendHistory(root, { id, ledger = [], log = null }, { now = Date.now() } = {}) {
|
|
1128
|
+
if (!isNonEmptyString(id)) throw new TypeError('appendHistory: id must be a non-empty string');
|
|
1129
|
+
if (!Array.isArray(ledger)) throw new TypeError('appendHistory: ledger must be an array of rows');
|
|
1130
|
+
const file = getDecisionsHistoryPath(root);
|
|
1131
|
+
const prior = readJsonlForWrite(file, { now });
|
|
1132
|
+
const versions = prior.filter(r => r.id === id).length;
|
|
1133
|
+
let toDrop = Math.max(0, versions + 1 - HISTORY_DEPTH);
|
|
1134
|
+
const kept = prior.filter(r => {
|
|
1135
|
+
if (r.id !== id || toDrop === 0) return true;
|
|
1136
|
+
toDrop -= 1;
|
|
1137
|
+
return false;
|
|
1138
|
+
});
|
|
1139
|
+
const record = { id, at: new Date(now).toISOString(), ledger: copyJson(ledger), log: copyJson(log) };
|
|
1140
|
+
writeJsonlAtomic(file, [...kept, record]);
|
|
1141
|
+
return Math.min(versions + 1, HISTORY_DEPTH);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* The stored prior versions of observation `id`, oldest first. Read-only.
|
|
1146
|
+
*
|
|
1147
|
+
* @param {string} root - project root
|
|
1148
|
+
* @param {string} id
|
|
1149
|
+
* @returns {object[]} records `{ id, at, ledger, log }`
|
|
1150
|
+
*/
|
|
1151
|
+
function historyVersions(root, id) {
|
|
1152
|
+
return readJsonl(getDecisionsHistoryPath(root)).rows.filter(r => r.id === id);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Copy the v1 files aside before the first v2 write.
|
|
1157
|
+
*
|
|
1158
|
+
* D-V1-BACKUP-ONCE: the first write to a tree that still holds a v1 row copies the
|
|
1159
|
+
* log, the ledger and the archive, as they are on disk, to `*.pre-v2.jsonl` with
|
|
1160
|
+
* an exclusive create, before anything rewrites them; an existing copy is never
|
|
1161
|
+
* overwritten. Reason: v2 writes convert and rewrite v1 rows, and these copies are
|
|
1162
|
+
* the only record of the corpus as it was before the conversion. A file that is a
|
|
1163
|
+
* symbolic link is not copied: the store reads none (D-NO-LINKED-READ, at readJsonl).
|
|
1164
|
+
*
|
|
1165
|
+
* @param {string} root - project root
|
|
1166
|
+
* @param {{ logRows?: object[], ledgerRows?: object[] }} rows - the rows just read
|
|
1167
|
+
* @returns {string[]} the backup paths written by this call (none when every row
|
|
1168
|
+
* is v2, a file is absent or a symbolic link, or its copy already exists)
|
|
1169
|
+
*/
|
|
1170
|
+
function ensurePreV2Backup(root, { logRows = [], ledgerRows = [] } = {}) {
|
|
1171
|
+
if ([...logRows, ...ledgerRows].every(row => isV2(row))) return [];
|
|
1172
|
+
const written = [];
|
|
1173
|
+
for (const file of [getDecisionsLogPath(root), getDecisionsLedgerPath(root), getDecisionsArchivePath(root)]) {
|
|
1174
|
+
if (isSymbolicLink(file)) continue;
|
|
1175
|
+
const copy = withJsonlSuffix(file, '.pre-v2.jsonl');
|
|
1176
|
+
try {
|
|
1177
|
+
fs.copyFileSync(file, copy, fs.constants.COPYFILE_EXCL);
|
|
1178
|
+
written.push(copy);
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
if (err && (err.code === 'EEXIST' || err.code === 'ENOENT')) continue;
|
|
1181
|
+
throw err;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
return written;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// ---------------------------------------------------------------------------
|
|
1188
|
+
// Rotation
|
|
1189
|
+
// ---------------------------------------------------------------------------
|
|
1190
|
+
|
|
1191
|
+
/** Days of inactivity after which an observation no ledger row carries leaves the log (D-ROTATE-UNREFERENCED). */
|
|
1192
|
+
const ROTATE_AGE_DAYS = 30;
|
|
1193
|
+
|
|
1194
|
+
/** What an older install's usage telemetry left in the learning directory: a file and a lock directory. */
|
|
1195
|
+
const USAGE_LEFTOVERS = Object.freeze(['.decisions-usage.json', '.decisions-usage.lock']);
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Epoch ms of a row's last activity — its `last_seen`, else `first_seen`, else
|
|
1199
|
+
* `created` — or null when that value is absent or does not parse.
|
|
1200
|
+
*
|
|
1201
|
+
* @param {object} row
|
|
1202
|
+
* @returns {number|null}
|
|
1203
|
+
*/
|
|
1204
|
+
function lastActivityMs(row) {
|
|
1205
|
+
const value = row.last_seen || row.first_seen || row.created;
|
|
1206
|
+
const at = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
1207
|
+
return Number.isFinite(at) ? at : null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Move the observations no ledger row carries out of the log once they have been
|
|
1212
|
+
* inactive for ROTATE_AGE_DAYS, and delete what the retired usage telemetry left.
|
|
1213
|
+
*
|
|
1214
|
+
* D-ROTATE-UNREFERENCED: rotation archives every log row that no ledger row
|
|
1215
|
+
* carries once its last activity — last_seen, else first_seen, else created — is
|
|
1216
|
+
* at least ROTATE_AGE_DAYS old, whatever its status; a row any ledger row carries
|
|
1217
|
+
* stays, however old and whatever that ledger row's status. An archived row is
|
|
1218
|
+
* appended to the archive unless a byte-identical copy (in its JSON form) is
|
|
1219
|
+
* already there, and the log is rewritten without it. Reason: only the ledger
|
|
1220
|
+
* records what is promoted (D-LEDGER-REGISTRY), so neither a log status nor an
|
|
1221
|
+
* anchor_id copied onto a log row can say what to keep, and a dedup by id dropped
|
|
1222
|
+
* the newer version of a row whose older version was already archived.
|
|
1223
|
+
*
|
|
1224
|
+
* Under the learning lock it first deletes the usage leftovers. When no row is
|
|
1225
|
+
* due it writes nothing else. When rows are due it backs up a v1 tree
|
|
1226
|
+
* (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
|
|
1227
|
+
* (D-QUARANTINE-MALFORMED) before it appends to the archive and rewrites the log;
|
|
1228
|
+
* an interrupted run is retried safely, because the identical copy it appended is
|
|
1229
|
+
* skipped.
|
|
1230
|
+
*
|
|
1231
|
+
* @param {string} root - project root
|
|
1232
|
+
* @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1233
|
+
* @returns {{ ok: true, value: { rotated: number, appended: number } } | { ok: false, error: { kind: string, message: string } }}
|
|
1234
|
+
* rotated: rows removed from the log; appended: rows added to the archive.
|
|
1235
|
+
* Errors are withDecisionsLock's not-a-directory, no-learning-dir and busy.
|
|
1236
|
+
*/
|
|
1237
|
+
function rotateObservations(root, { now = Date.now(), timeoutMs } = {}) {
|
|
1238
|
+
return withDecisionsLock('rotate-observations', root, () => {
|
|
1239
|
+
for (const name of USAGE_LEFTOVERS) {
|
|
1240
|
+
fs.rmSync(path.join(getLearningDir(root), name), { recursive: true, force: true });
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const logPath = getDecisionsLogPath(root);
|
|
1244
|
+
const log = readJsonl(logPath);
|
|
1245
|
+
const ledgerRows = readJsonl(getDecisionsLedgerPath(root)).rows;
|
|
1246
|
+
const isCarried = carriedBy(ledgerRows);
|
|
1247
|
+
const cutoff = now - ROTATE_AGE_DAYS * DAY_MS;
|
|
1248
|
+
const isDue = row => {
|
|
1249
|
+
if (isCarried(row)) return false;
|
|
1250
|
+
const at = lastActivityMs(row);
|
|
1251
|
+
return at !== null && at <= cutoff;
|
|
1252
|
+
};
|
|
1253
|
+
const due = log.rows.filter(isDue);
|
|
1254
|
+
if (due.length === 0) return { ok: true, value: { rotated: 0, appended: 0 } };
|
|
1255
|
+
|
|
1256
|
+
ensurePreV2Backup(root, { logRows: log.rows, ledgerRows });
|
|
1257
|
+
quarantineRejected(logPath, log.rejected, { now });
|
|
1258
|
+
|
|
1259
|
+
const archivePath = getDecisionsArchivePath(root);
|
|
1260
|
+
const archived = new Set(readJsonl(archivePath).rows.map(row => JSON.stringify(row)));
|
|
1261
|
+
const appended = [];
|
|
1262
|
+
for (const row of due) {
|
|
1263
|
+
const line = JSON.stringify(row);
|
|
1264
|
+
if (archived.has(line)) continue;
|
|
1265
|
+
archived.add(line);
|
|
1266
|
+
appended.push(line);
|
|
1267
|
+
}
|
|
1268
|
+
if (appended.length > 0) appendNoFollow(archivePath, appended.join('\n') + '\n');
|
|
1269
|
+
writeJsonlAtomic(logPath, log.rows.filter(row => !isDue(row)));
|
|
1270
|
+
return { ok: true, value: { rotated: due.length, appended: appended.length } };
|
|
1271
|
+
}, { timeoutMs });
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ---------------------------------------------------------------------------
|
|
1275
|
+
// Clearing
|
|
1276
|
+
// ---------------------------------------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Drop the observations no entry uses — `devflow learning --clear`.
|
|
1280
|
+
*
|
|
1281
|
+
* D-CLEAR-UNREFERENCED: clearing removes from the log exactly the rows no ledger
|
|
1282
|
+
* row carries (D-LEDGER-REGISTRY), whatever their age or status, and keeps every
|
|
1283
|
+
* row an entry carries, active or not; it refuses while the ledger holds a
|
|
1284
|
+
* malformed line, and it never writes the ledger, the archive or the rendered
|
|
1285
|
+
* files. Reason: truncating the whole log orphaned every entry — each lost the log
|
|
1286
|
+
* row that is its content authority, and refresh then refused them all — and a
|
|
1287
|
+
* malformed ledger line may be the one carrying a row clearing would drop.
|
|
1288
|
+
*
|
|
1289
|
+
* It runs under the learning lock (D-ONE-LEARNING-LOCK). When a row is dropped it
|
|
1290
|
+
* backs up a v1 tree (D-V1-BACKUP-ONCE) and quarantines the log's malformed lines
|
|
1291
|
+
* (D-QUARANTINE-MALFORMED) before it rewrites the log; with nothing to drop it
|
|
1292
|
+
* writes nothing.
|
|
1293
|
+
*
|
|
1294
|
+
* @param {string} root - project root
|
|
1295
|
+
* @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1296
|
+
* @returns {{ ok: true, value: { cleared: number, kept: number } } | { ok: false, error: { kind: string, message: string } }}
|
|
1297
|
+
* cleared: rows removed from the log; kept: rows left in it. Error kinds:
|
|
1298
|
+
* ledger-malformed, and withDecisionsLock's not-a-directory, no-learning-dir and busy.
|
|
1299
|
+
*/
|
|
1300
|
+
function clearUnreferenced(root, { now = Date.now(), timeoutMs } = {}) {
|
|
1301
|
+
return withDecisionsLock('clear', root, () => {
|
|
1302
|
+
const ledger = readJsonl(getDecisionsLedgerPath(root));
|
|
1303
|
+
if (ledger.rejected.length > 0) {
|
|
1304
|
+
const lines = ledger.rejected.length === 1 ? '1 malformed line' : `${ledger.rejected.length} malformed lines`;
|
|
1305
|
+
return {
|
|
1306
|
+
ok: false,
|
|
1307
|
+
error: {
|
|
1308
|
+
kind: 'ledger-malformed',
|
|
1309
|
+
message: `clear: the ledger has ${lines}, which may carry an observation this would drop; nothing was cleared`,
|
|
1310
|
+
},
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
const logPath = getDecisionsLogPath(root);
|
|
1314
|
+
const log = readJsonl(logPath);
|
|
1315
|
+
const kept = log.rows.filter(carriedBy(ledger.rows));
|
|
1316
|
+
const cleared = log.rows.length - kept.length;
|
|
1317
|
+
if (cleared === 0) return { ok: true, value: { cleared: 0, kept: kept.length } };
|
|
1318
|
+
|
|
1319
|
+
ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
|
|
1320
|
+
quarantineRejected(logPath, log.rejected, { now });
|
|
1321
|
+
writeJsonlAtomic(logPath, kept);
|
|
1322
|
+
return { ok: true, value: { cleared, kept: kept.length } };
|
|
1323
|
+
}, { timeoutMs });
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ---------------------------------------------------------------------------
|
|
1327
|
+
// Resetting
|
|
1328
|
+
// ---------------------------------------------------------------------------
|
|
1329
|
+
|
|
1330
|
+
/** rmdir(2) errors meaning the path is not an empty directory: gone, holding something, or not a directory. */
|
|
1331
|
+
const NOT_AN_EMPTY_DIR = Object.freeze(['ENOENT', 'ENOTEMPTY', 'EEXIST', 'ENOTDIR']);
|
|
1332
|
+
|
|
1333
|
+
/** Remove `dir` when it is an empty directory; anything else at that path stays as it is. */
|
|
1334
|
+
function removeEmptyDir(dir) {
|
|
1335
|
+
try {
|
|
1336
|
+
fs.rmdirSync(dir);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
if (!err || !NOT_AN_EMPTY_DIR.includes(err.code)) throw err;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Remove every learning file — `devflow learning --reset`: the log, the ledger
|
|
1344
|
+
* and their side files, the rendered files, the tuning config, and the queue
|
|
1345
|
+
* with its claim and owner file — and then the learning directory itself.
|
|
1346
|
+
*
|
|
1347
|
+
* D-RESET-UNDER-LOCK: reset empties the learning directory under the learning
|
|
1348
|
+
* lock, sparing only the lock directory, and removes the emptied directory once
|
|
1349
|
+
* the lock is released, and only if nothing has arrived in it. Reason: the lock
|
|
1350
|
+
* is released by its path, so removing it with the directory would let this
|
|
1351
|
+
* run's release delete the lock of a writer that recreated the tree in between;
|
|
1352
|
+
* and what arrives once the lock is free — a captured turn, or the next writer's
|
|
1353
|
+
* lock — belongs to the next run.
|
|
1354
|
+
*
|
|
1355
|
+
* Like every learning writer it refuses without `.devflow/learning/` and creates
|
|
1356
|
+
* nothing (D-NO-STRAY-TREE), refuses a learning directory or a `.devflow` that is a
|
|
1357
|
+
* symbolic link and removes nothing (D-NO-LINKED-TREE), since emptying it would
|
|
1358
|
+
* empty whatever directory the link leads to, and waits at most `timeoutMs` for the
|
|
1359
|
+
* lock, breaking one a crashed run left behind (D-ONE-LEARNING-LOCK). A symbolic
|
|
1360
|
+
* link in the directory is removed, never what it points to.
|
|
1361
|
+
*
|
|
1362
|
+
* @param {string} root - project root
|
|
1363
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
1364
|
+
* @returns {{ ok: true, value: { removed: number } } | { ok: false, error: { kind: string, message: string } }}
|
|
1365
|
+
* removed: the entries removed from the learning directory. Errors are
|
|
1366
|
+
* withDecisionsLock's not-a-directory, no-learning-dir and busy.
|
|
1367
|
+
*/
|
|
1368
|
+
function resetLearning(root, { timeoutMs } = {}) {
|
|
1369
|
+
const learningDir = getLearningDir(root);
|
|
1370
|
+
const lockName = path.basename(getDecisionsLockDir(root));
|
|
1371
|
+
const reset = withDecisionsLock('reset', root, () => {
|
|
1372
|
+
const entries = fs.readdirSync(learningDir).filter(name => name !== lockName);
|
|
1373
|
+
for (const name of entries) fs.rmSync(path.join(learningDir, name), { recursive: true, force: true });
|
|
1374
|
+
return { ok: true, value: { removed: entries.length } };
|
|
1375
|
+
}, { timeoutMs });
|
|
1376
|
+
if (reset.ok) removeEmptyDir(learningDir);
|
|
1377
|
+
return reset;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// ---------------------------------------------------------------------------
|
|
1381
|
+
// The queue claim
|
|
1382
|
+
// ---------------------------------------------------------------------------
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Seconds without a heartbeat after which a claim is stale and the next claim
|
|
1386
|
+
* takes it over (D-OWNED-CLAIM). session-start-context's PROCESSING_STALE_SECS
|
|
1387
|
+
* holds the same value; a lockstep test pins the two together.
|
|
1388
|
+
*/
|
|
1389
|
+
const CLAIM_STALE_SECS = 900;
|
|
1390
|
+
|
|
1391
|
+
/** How long a claim waits for the queue's own lock (ms): the wait of queue-append's overflow truncation. */
|
|
1392
|
+
const QUEUE_LOCK_TIMEOUT_MS = 2000;
|
|
1393
|
+
|
|
1394
|
+
/** Age after which the queue's own lock counts as abandoned (ms): learning-lock's threshold. */
|
|
1395
|
+
const QUEUE_LOCK_STALE_MS = 30000;
|
|
1396
|
+
|
|
1397
|
+
/** A claim token: 16 lowercase hex characters. */
|
|
1398
|
+
const CLAIM_TOKEN_RE = /^[0-9a-f]{16}$/;
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* The largest owner file release-claim reads, in bytes. A token and its newline
|
|
1402
|
+
* take 17; a larger owner file reads as no owner file at all (D-NO-LINKED-READ,
|
|
1403
|
+
* at readJsonl).
|
|
1404
|
+
*/
|
|
1405
|
+
const CLAIM_OWNER_MAX_BYTES = 4096;
|
|
1406
|
+
|
|
1407
|
+
/** link(2) errors of a filesystem without hard links; the claim renames instead. */
|
|
1408
|
+
const NO_HARD_LINK_CODES = Object.freeze(['EPERM', 'ENOTSUP']);
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* A fresh claim token: 8 random bytes as 16 hex characters.
|
|
1412
|
+
*
|
|
1413
|
+
* @returns {string}
|
|
1414
|
+
*/
|
|
1415
|
+
function newClaimToken() {
|
|
1416
|
+
return crypto.randomBytes(8).toString('hex');
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Throw a TypeError unless `token` is a claim token: a malformed one is a caller error. */
|
|
1420
|
+
function assertClaimToken(opName, token) {
|
|
1421
|
+
if (typeof token !== 'string' || !CLAIM_TOKEN_RE.test(token)) {
|
|
1422
|
+
throw new TypeError(`${opName}: a claim token is 16 lowercase hex characters`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* The Stats of `file` when it is a regular file, null when nothing is there, and
|
|
1428
|
+
* false for anything else — a directory or a symlink — which the claim ops
|
|
1429
|
+
* refuse rather than follow or replace.
|
|
1430
|
+
*
|
|
1431
|
+
* @param {string} file
|
|
1432
|
+
* @returns {fs.Stats|null|false}
|
|
1433
|
+
*/
|
|
1434
|
+
function regularFileStat(file) {
|
|
1435
|
+
let stat;
|
|
1436
|
+
try {
|
|
1437
|
+
stat = fs.lstatSync(file);
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
|
|
1440
|
+
throw err;
|
|
1441
|
+
}
|
|
1442
|
+
return stat.isFile() ? stat : false;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** The error Result for a claim path holding something other than a regular file. */
|
|
1446
|
+
function notRegularFile(opName, file) {
|
|
1447
|
+
return { ok: false, error: { kind: 'not-a-file', message: `${opName}: ${file} is not a regular file; remove it by hand` } };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Delete `file`; one that is already gone is fine. */
|
|
1451
|
+
function removeIfPresent(file) {
|
|
1452
|
+
try {
|
|
1453
|
+
fs.unlinkSync(file);
|
|
1454
|
+
} catch (err) {
|
|
1455
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* The token the owner file records, or null when it is absent or holds no token.
|
|
1461
|
+
* An owner file that is a symbolic link, anything else but a regular file, or
|
|
1462
|
+
* larger than CLAIM_OWNER_MAX_BYTES reads as absent (D-NO-LINKED-READ, at readJsonl).
|
|
1463
|
+
*/
|
|
1464
|
+
function readClaimOwner(root) {
|
|
1465
|
+
const text = readTextUnlinked(getLearningClaimOwnerPath(root), { maxBytes: CLAIM_OWNER_MAX_BYTES });
|
|
1466
|
+
if (text === null) return null;
|
|
1467
|
+
const token = text.trim();
|
|
1468
|
+
return CLAIM_TOKEN_RE.test(token) ? token : null;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Move the queue to the claim path under the queue's own lock, the lock
|
|
1473
|
+
* queue-append's overflow truncation takes, so a truncation can never rewrite the
|
|
1474
|
+
* queue from rows already claimed. link(2) refuses an existing claim; on a
|
|
1475
|
+
* filesystem without hard links a rename stands in, which is safe because the
|
|
1476
|
+
* caller found the claim path empty under the learning lock. A row a capture hook
|
|
1477
|
+
* appends meanwhile lands in the claimed batch or in the queue formed after it,
|
|
1478
|
+
* never in neither.
|
|
1479
|
+
*
|
|
1480
|
+
* @param {string} queuePath
|
|
1481
|
+
* @param {string} claimPath
|
|
1482
|
+
* @returns {'moved'|'busy'|'none'} busy when the queue lock is held or a claim
|
|
1483
|
+
* appeared; none when the queue vanished first
|
|
1484
|
+
*/
|
|
1485
|
+
function moveQueueToClaim(queuePath, claimPath) {
|
|
1486
|
+
const queueLock = `${queuePath}.lock`;
|
|
1487
|
+
if (!acquireMkdirLock(queueLock, QUEUE_LOCK_TIMEOUT_MS, QUEUE_LOCK_STALE_MS)) return 'busy';
|
|
1488
|
+
try {
|
|
1489
|
+
try {
|
|
1490
|
+
fs.linkSync(queuePath, claimPath);
|
|
1491
|
+
} catch (err) {
|
|
1492
|
+
if (err && err.code === 'EEXIST') return 'busy';
|
|
1493
|
+
if (err && err.code === 'ENOENT') return 'none';
|
|
1494
|
+
if (!err || !NO_HARD_LINK_CODES.includes(err.code)) throw err;
|
|
1495
|
+
try {
|
|
1496
|
+
fs.renameSync(queuePath, claimPath);
|
|
1497
|
+
} catch (renameErr) {
|
|
1498
|
+
if (renameErr && renameErr.code === 'ENOENT') return 'none';
|
|
1499
|
+
throw renameErr;
|
|
1500
|
+
}
|
|
1501
|
+
return 'moved';
|
|
1502
|
+
}
|
|
1503
|
+
try {
|
|
1504
|
+
removeIfPresent(queuePath);
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
// Undo the link, so the rows stay queued once rather than claimed and queued.
|
|
1507
|
+
// The unlink error is the one to report; a failed undo leaves the claim to
|
|
1508
|
+
// go stale and be taken over.
|
|
1509
|
+
try { fs.unlinkSync(claimPath); } catch { /* reported through err */ }
|
|
1510
|
+
throw err;
|
|
1511
|
+
}
|
|
1512
|
+
return 'moved';
|
|
1513
|
+
} finally {
|
|
1514
|
+
releaseLock(queueLock);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Claim the learning queue for one Learning run.
|
|
1520
|
+
*
|
|
1521
|
+
* D-OWNED-CLAIM: the learning queue is claimed and released only through the
|
|
1522
|
+
* claim-queue and release-claim ops, under the learning lock. A claim moves the
|
|
1523
|
+
* queue to .pending-turns.processing by link(2), under the queue's own lock (a
|
|
1524
|
+
* rename stands in only where the filesystem has no hard links), sets the claim's
|
|
1525
|
+
* mtime to now and records a fresh random token in .pending-turns.owner. A claim
|
|
1526
|
+
* younger than CLAIM_STALE_SECS is busy to every other claimant; an older one is
|
|
1527
|
+
* taken over with a new token, and the waiting queue is left for the next claim.
|
|
1528
|
+
* Release deletes the claim only for the token that owns it, and every json-helper
|
|
1529
|
+
* learning op refreshes an existing claim's mtime before it runs, without ever
|
|
1530
|
+
* creating one. Reason: a check-then-mv claim let two runs claim at once and
|
|
1531
|
+
* clobber a batch, mv kept the queue's old mtime so a fresh claim could look stale
|
|
1532
|
+
* at once, and an unconditional final unlink deleted another run's claim.
|
|
1533
|
+
*
|
|
1534
|
+
* Without .devflow/learning/ it answers none and creates nothing.
|
|
1535
|
+
*
|
|
1536
|
+
* @param {string} root - project root
|
|
1537
|
+
* @param {{ now?: number, token?: string, timeoutMs?: number }} [opts]
|
|
1538
|
+
* now: epoch ms (default Date.now()); token: the token to record (default a fresh one)
|
|
1539
|
+
* @returns {{ ok: true, value: { state: 'claimed', token: string, takeover: boolean } | { state: 'busy' } | { state: 'none' } }
|
|
1540
|
+
* | { ok: false, error: { kind: string, message: string } }}
|
|
1541
|
+
* @throws {TypeError} when `token` is not a claim token
|
|
1542
|
+
*/
|
|
1543
|
+
function claimQueue(root, { now = Date.now(), token = newClaimToken(), timeoutMs } = {}) {
|
|
1544
|
+
assertClaimToken('claimQueue', token);
|
|
1545
|
+
if (!hasLearningDir(root)) return { ok: true, value: { state: 'none' } };
|
|
1546
|
+
return withDecisionsLock('claim-queue', root, () => {
|
|
1547
|
+
const claimPath = getLearningPendingTurnsProcessingPath(root);
|
|
1548
|
+
const queuePath = getLearningPendingTurnsPath(root);
|
|
1549
|
+
const at = new Date(now);
|
|
1550
|
+
|
|
1551
|
+
const claim = regularFileStat(claimPath);
|
|
1552
|
+
if (claim === false) return notRegularFile('claim-queue', claimPath);
|
|
1553
|
+
if (claim !== null) {
|
|
1554
|
+
if (now - claim.mtimeMs < CLAIM_STALE_SECS * 1000) return { ok: true, value: { state: 'busy' } };
|
|
1555
|
+
writeFileAtomic(getLearningClaimOwnerPath(root), `${token}\n`);
|
|
1556
|
+
fs.utimesSync(claimPath, at, at);
|
|
1557
|
+
return { ok: true, value: { state: 'claimed', token, takeover: true } };
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const queue = regularFileStat(queuePath);
|
|
1561
|
+
if (queue === false) return notRegularFile('claim-queue', queuePath);
|
|
1562
|
+
if (queue === null || queue.size === 0) return { ok: true, value: { state: 'none' } };
|
|
1563
|
+
|
|
1564
|
+
const moved = moveQueueToClaim(queuePath, claimPath);
|
|
1565
|
+
if (moved !== 'moved') return { ok: true, value: { state: moved } };
|
|
1566
|
+
fs.utimesSync(claimPath, at, at);
|
|
1567
|
+
writeFileAtomic(getLearningClaimOwnerPath(root), `${token}\n`);
|
|
1568
|
+
return { ok: true, value: { state: 'claimed', token, takeover: false } };
|
|
1569
|
+
}, { timeoutMs });
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Release the claim `token` owns (D-OWNED-CLAIM): delete the claim and the owner
|
|
1574
|
+
* file when the token owns it (released); refuse when another token does
|
|
1575
|
+
* (not-owner); report a claim that is already gone (gone), deleting the owner
|
|
1576
|
+
* file only when it names this token. An owner file that is a symbolic link,
|
|
1577
|
+
* anything else but a regular file, or larger than CLAIM_OWNER_MAX_BYTES names no
|
|
1578
|
+
* token, as though it were absent (D-NO-LINKED-READ, at readJsonl).
|
|
1579
|
+
*
|
|
1580
|
+
* @param {string} root - project root
|
|
1581
|
+
* @param {string} token
|
|
1582
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
1583
|
+
* @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-learning-dir and busy
|
|
1585
|
+
* @throws {TypeError} when `token` is not a claim token
|
|
1586
|
+
*/
|
|
1587
|
+
function releaseClaim(root, token, { timeoutMs } = {}) {
|
|
1588
|
+
assertClaimToken('releaseClaim', token);
|
|
1589
|
+
return withDecisionsLock('release-claim', root, () => {
|
|
1590
|
+
const claimPath = getLearningPendingTurnsProcessingPath(root);
|
|
1591
|
+
const ownerPath = getLearningClaimOwnerPath(root);
|
|
1592
|
+
const owned = readClaimOwner(root) === token;
|
|
1593
|
+
|
|
1594
|
+
const claim = regularFileStat(claimPath);
|
|
1595
|
+
if (claim === false) return notRegularFile('release-claim', claimPath);
|
|
1596
|
+
if (claim === null) {
|
|
1597
|
+
if (owned) removeIfPresent(ownerPath);
|
|
1598
|
+
return { ok: true, value: { state: 'gone' } };
|
|
1599
|
+
}
|
|
1600
|
+
if (!owned) return { ok: true, value: { state: 'not-owner' } };
|
|
1601
|
+
removeIfPresent(claimPath);
|
|
1602
|
+
removeIfPresent(ownerPath);
|
|
1603
|
+
return { ok: true, value: { state: 'released' } };
|
|
1604
|
+
}, { timeoutMs });
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* The claim heartbeat (D-OWNED-CLAIM): set an existing claim's mtime to now. It
|
|
1609
|
+
* 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 learning op runs. A claim in a
|
|
1611
|
+
* learning tree reached through a symbolic link is left alone (D-NO-LINKED-TREE):
|
|
1612
|
+
* `lutimes` follows a linked folder above the claim, and the op that follows
|
|
1613
|
+
* refuses that tree anyway.
|
|
1614
|
+
*
|
|
1615
|
+
* @param {string} root - project root
|
|
1616
|
+
* @param {{ now?: number }} [opts] - now: epoch ms (default Date.now())
|
|
1617
|
+
* @returns {{ ok: true, value: { touched: boolean } } | { ok: false, error: { kind: 'heartbeat-failed', message: string } }}
|
|
1618
|
+
*/
|
|
1619
|
+
function touchClaim(root, { now = Date.now() } = {}) {
|
|
1620
|
+
if (linkedLearningFolder(root) !== null) return { ok: true, value: { touched: false } };
|
|
1621
|
+
const claimPath = getLearningPendingTurnsProcessingPath(root);
|
|
1622
|
+
const at = new Date(now);
|
|
1623
|
+
try {
|
|
1624
|
+
fs.lutimesSync(claimPath, at, at);
|
|
1625
|
+
} catch (err) {
|
|
1626
|
+
if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return { ok: true, value: { touched: false } };
|
|
1627
|
+
return {
|
|
1628
|
+
ok: false,
|
|
1629
|
+
error: { kind: 'heartbeat-failed', message: `heartbeat: could not refresh ${claimPath}: ${err && err.message}` },
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
return { ok: true, value: { touched: true } };
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// ---------------------------------------------------------------------------
|
|
1636
|
+
// Integrity, listing, due selection and show — all read-only
|
|
1637
|
+
// ---------------------------------------------------------------------------
|
|
1638
|
+
|
|
1639
|
+
/** True when a v2 row lists a glob scope entry that matches no tracked file. */
|
|
1640
|
+
function hasUnmatchedScope(row, scopeMatches) {
|
|
1641
|
+
if (!isV2(row) || !Array.isArray(row.scope)) return false;
|
|
1642
|
+
return row.scope.some(entry => !(isNonEmptyString(entry) && (entry.startsWith('area:') || scopeMatches(entry))));
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Integrity problems of the active ledger rows, in anchor order:
|
|
1647
|
+
* duplicate-obs-id another active row carries the same observation id
|
|
1648
|
+
* ledger-without-log no log row carries the row's id
|
|
1649
|
+
* scope-matches-nothing a glob in a v2 row's scope matches no tracked file
|
|
1650
|
+
* (checked only when scopeMatches is given)
|
|
1651
|
+
*
|
|
1652
|
+
* @param {object[]} ledger
|
|
1653
|
+
* @param {object[]} log
|
|
1654
|
+
* @param {{ scopeMatches?: (glob: string) => boolean }} [opts]
|
|
1655
|
+
* @returns {Array<{ anchor_id: string, id: unknown, flags: string[] }>} only rows with a flag
|
|
1656
|
+
*/
|
|
1657
|
+
function integrityFlags(ledger, log, { scopeMatches } = {}) {
|
|
1658
|
+
const active = activeAnchoredRows(ledger);
|
|
1659
|
+
const logIds = new Set(log.map(row => row.id).filter(isNonEmptyString));
|
|
1660
|
+
const carriers = new Map();
|
|
1661
|
+
for (const row of active) {
|
|
1662
|
+
if (isNonEmptyString(row.id)) carriers.set(row.id, (carriers.get(row.id) || 0) + 1);
|
|
1663
|
+
}
|
|
1664
|
+
const flagged = [];
|
|
1665
|
+
for (const row of sortedByAnchor(active)) {
|
|
1666
|
+
const flags = [];
|
|
1667
|
+
if (isNonEmptyString(row.id) && carriers.get(row.id) > 1) flags.push('duplicate-obs-id');
|
|
1668
|
+
if (!isNonEmptyString(row.id) || !logIds.has(row.id)) flags.push('ledger-without-log');
|
|
1669
|
+
if (scopeMatches && hasUnmatchedScope(row, scopeMatches)) flags.push('scope-matches-nothing');
|
|
1670
|
+
if (flags.length > 0) flagged.push({ anchor_id: row.anchor_id, id: row.id, flags });
|
|
1671
|
+
}
|
|
1672
|
+
return flagged;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/** A row's title for a listing: the v2 title or the v1 pattern, on one line, cut to the title limit. */
|
|
1676
|
+
function listingTitle(row) {
|
|
1677
|
+
const raw = isV2(row) ? row.title : row.pattern;
|
|
1678
|
+
return typeof raw === 'string' ? cutTo(singleLine(raw), FIELD_LIMITS.title) : '';
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Why an inactive entry is inactive, in a few words, or '' when it records nothing:
|
|
1683
|
+
* `encoded in <path>`, else `superseded by <anchor>`, else the status note. `list`
|
|
1684
|
+
* and the rendered Inactive table both show it.
|
|
1685
|
+
*
|
|
1686
|
+
* @param {object} row - a ledger row
|
|
1687
|
+
* @returns {string}
|
|
1688
|
+
*/
|
|
1689
|
+
function inactiveNote(row) {
|
|
1690
|
+
if (isPlainObject(row.encoded_at) && isNonEmptyString(row.encoded_at.path)) return `encoded in ${row.encoded_at.path}`;
|
|
1691
|
+
if (isNonEmptyString(row.superseded_by)) return `superseded by ${row.superseded_by}`;
|
|
1692
|
+
if (isNonEmptyString(row.status_note)) return row.status_note;
|
|
1693
|
+
return '';
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/** The first log row carrying each observation id; a row with no id is left out. */
|
|
1697
|
+
function firstLogRowById(log) {
|
|
1698
|
+
const byId = new Map();
|
|
1699
|
+
for (const row of log) {
|
|
1700
|
+
if (isNonEmptyString(row.id) && !byId.has(row.id)) byId.set(row.id, row);
|
|
1701
|
+
}
|
|
1702
|
+
return byId;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/** A log row's observation count (a v1 row's count) and last sighting; each is null when the row records none or there is no row. */
|
|
1706
|
+
function sightingsOf(logRow) {
|
|
1707
|
+
return {
|
|
1708
|
+
observations: (logRow && (positiveInteger(logRow.observations) || positiveInteger(logRow.count))) || null,
|
|
1709
|
+
last_seen: logRow && isNonEmptyString(logRow.last_seen) ? logRow.last_seen : null,
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** One ledger row as a listing line, with the sightings of `logRow`, the log row carrying its id. */
|
|
1714
|
+
function listingRow(row, logRow) {
|
|
1715
|
+
const entry = {
|
|
1716
|
+
anchor_id: row.anchor_id,
|
|
1717
|
+
id: row.id,
|
|
1718
|
+
type: row.type,
|
|
1719
|
+
status: isNonEmptyString(row.decisions_status) ? row.decisions_status : null,
|
|
1720
|
+
title: listingTitle(row),
|
|
1721
|
+
schema: isV2(row) ? 2 : 1,
|
|
1722
|
+
};
|
|
1723
|
+
if (isNonEmptyString(row.last_verified)) entry.last_verified = row.last_verified;
|
|
1724
|
+
return { ...entry, ...sightingsOf(logRow), scope: Array.isArray(row.scope) ? copyJson(row.scope) : null };
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** One unpromoted log row as a listing line. */
|
|
1728
|
+
function observationListingRow(row) {
|
|
1729
|
+
return {
|
|
1730
|
+
id: row.id,
|
|
1731
|
+
type: row.type,
|
|
1732
|
+
title: listingTitle(row),
|
|
1733
|
+
schema: isV2(row) ? 2 : 1,
|
|
1734
|
+
...sightingsOf(row),
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* The data behind `list`: active and inactive entries in anchor order, the
|
|
1740
|
+
* observations no ledger row carries (by id), the integrity flags and the
|
|
1741
|
+
* malformed-line counts. A v1 title is its pattern cut to the title limit. Each
|
|
1742
|
+
* entry carries its ledger row's last_verified (when set) and scope (null when it
|
|
1743
|
+
* has none, as a v1 row does), and the observation count and last sighting of the
|
|
1744
|
+
* log row carrying its id — the first such row — or null for each without one.
|
|
1745
|
+
*
|
|
1746
|
+
* @param {object[]} ledger
|
|
1747
|
+
* @param {object[]} log
|
|
1748
|
+
* @param {{ scopeMatches?: (glob: string) => boolean, rejected?: { ledger?: unknown[], log?: unknown[] } }} [opts]
|
|
1749
|
+
* @returns {{ active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } }}
|
|
1750
|
+
*/
|
|
1751
|
+
function buildListing(ledger, log, { scopeMatches, rejected = {} } = {}) {
|
|
1752
|
+
const anchored = ledger.filter(row => isNonEmptyString(row.anchor_id));
|
|
1753
|
+
const isCarried = carriedBy(ledger);
|
|
1754
|
+
const logById = firstLogRowById(log);
|
|
1755
|
+
const entryRow = row => listingRow(row, logById.get(row.id));
|
|
1756
|
+
return {
|
|
1757
|
+
active: sortedByAnchor(anchored.filter(row => isActive(row))).map(entryRow),
|
|
1758
|
+
inactive: sortedByAnchor(anchored.filter(row => !isActive(row)))
|
|
1759
|
+
.map(row => ({ ...entryRow(row), note: singleLine(inactiveNote(row)) })),
|
|
1760
|
+
observations: log
|
|
1761
|
+
.filter(row => isNonEmptyString(row.id) && !isCarried(row))
|
|
1762
|
+
.sort((a, b) => compareText(a.id, b.id))
|
|
1763
|
+
.map(observationListingRow),
|
|
1764
|
+
integrity: integrityFlags(ledger, log, { scopeMatches }),
|
|
1765
|
+
malformed: { ledger: (rejected.ledger || []).length, log: (rejected.log || []).length },
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* An entry's size for the maintenance budget: the UTF-8 bytes of its ledger row
|
|
1771
|
+
* plus those of its log row, both as compact JSON.
|
|
1772
|
+
*
|
|
1773
|
+
* @param {object} ledgerRow
|
|
1774
|
+
* @param {object|null|undefined} logRow
|
|
1775
|
+
* @returns {number}
|
|
1776
|
+
*/
|
|
1777
|
+
function entrySize(ledgerRow, logRow) {
|
|
1778
|
+
const ledgerBytes = Buffer.byteLength(JSON.stringify(ledgerRow), 'utf8');
|
|
1779
|
+
return logRow ? ledgerBytes + Buffer.byteLength(JSON.stringify(logRow), 'utf8') : ledgerBytes;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Pick the entries maintenance works on next.
|
|
1784
|
+
*
|
|
1785
|
+
* D-DUE-ORDER: maintenance hands out active entries in three classes, in order:
|
|
1786
|
+
* entries with an integrity flag (by anchor), legacy v1 entries (decisions before
|
|
1787
|
+
* pitfalls, then by number), then v2 entries last verified more than
|
|
1788
|
+
* DUE.verifyAgeDays ago (oldest first, never verified counting as oldest). An
|
|
1789
|
+
* entry attempted within DUE.leaseHours is skipped. At most DUE.maxEntries are
|
|
1790
|
+
* handed out, stopping at the first entry that would take the total past
|
|
1791
|
+
* DUE.byteBudget, and always at least one. Reason: a broken entry misleads every
|
|
1792
|
+
* reader until it is fixed, a v1 entry stays outside the v2 ops until it is
|
|
1793
|
+
* rewritten, the lease stops a run that died mid-entry from having the same entry
|
|
1794
|
+
* handed out again at once, and the cap and budget keep one run's reading bounded.
|
|
1795
|
+
*
|
|
1796
|
+
* @param {object[]} ledger
|
|
1797
|
+
* @param {object[]} log
|
|
1798
|
+
* @param {{ now?: number, integrity?: Array<{ anchor_id: string, flags: string[] }>, maxEntries?: number, budgetBytes?: number }} [opts]
|
|
1799
|
+
* now: epoch ms; integrity: integrityFlags' result.
|
|
1800
|
+
* @returns {Array<{ anchor_id: string, reason: string, bytes: number }>} reason is
|
|
1801
|
+
* the integrity flags joined by commas, `legacy-v1` or `verify-age`
|
|
1802
|
+
*/
|
|
1803
|
+
function selectDue(ledger, log, { now = Date.now(), integrity = [], maxEntries = DUE.maxEntries, budgetBytes = DUE.byteBudget } = {}) {
|
|
1804
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) throw new TypeError('selectDue: maxEntries must be a positive integer');
|
|
1805
|
+
if (!Number.isFinite(budgetBytes) || budgetBytes < 1) throw new TypeError('selectDue: budgetBytes must be positive');
|
|
1806
|
+
const leaseMs = DUE.leaseHours * HOUR_MS;
|
|
1807
|
+
const verifyAgeMs = DUE.verifyAgeDays * DAY_MS;
|
|
1808
|
+
|
|
1809
|
+
const logById = firstLogRowById(log);
|
|
1810
|
+
const flagsByAnchor = new Map(integrity.map(entry => [entry.anchor_id, entry.flags]));
|
|
1811
|
+
const leased = row => {
|
|
1812
|
+
const attemptedAt = Date.parse(row.last_attempt);
|
|
1813
|
+
return Number.isFinite(attemptedAt) && now >= attemptedAt && now - attemptedAt < leaseMs;
|
|
1814
|
+
};
|
|
1815
|
+
const verifiedAt = row => {
|
|
1816
|
+
const at = Date.parse(row.last_verified);
|
|
1817
|
+
return Number.isFinite(at) ? at : -Infinity;
|
|
1818
|
+
};
|
|
1819
|
+
|
|
1820
|
+
const candidates = activeAnchoredRows(ledger).filter(row => !leased(row));
|
|
1821
|
+
const flagged = candidates.filter(row => flagsByAnchor.has(row.anchor_id));
|
|
1822
|
+
const unflagged = candidates.filter(row => !flagsByAnchor.has(row.anchor_id));
|
|
1823
|
+
const ordered = [
|
|
1824
|
+
...sortedByAnchor(flagged).map(row => ({ row, reason: flagsByAnchor.get(row.anchor_id).join(',') })),
|
|
1825
|
+
...sortedByAnchor(unflagged.filter(row => !isV2(row))).map(row => ({ row, reason: 'legacy-v1' })),
|
|
1826
|
+
...unflagged
|
|
1827
|
+
.filter(row => isV2(row) && now - verifiedAt(row) > verifyAgeMs)
|
|
1828
|
+
.sort((a, b) => (verifiedAt(a) - verifiedAt(b)) || compareByAnchor(a, b))
|
|
1829
|
+
.map(row => ({ row, reason: 'verify-age' })),
|
|
1830
|
+
];
|
|
1831
|
+
|
|
1832
|
+
const due = [];
|
|
1833
|
+
let total = 0;
|
|
1834
|
+
for (const { row, reason } of ordered) {
|
|
1835
|
+
if (due.length >= maxEntries) break;
|
|
1836
|
+
const bytes = entrySize(row, logById.get(row.id));
|
|
1837
|
+
if (due.length > 0 && total + bytes > budgetBytes) break;
|
|
1838
|
+
due.push({ anchor_id: row.anchor_id, reason, bytes });
|
|
1839
|
+
total += bytes;
|
|
1840
|
+
}
|
|
1841
|
+
return due;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Whitespace-normalized text, or '' for a non-string. */
|
|
1845
|
+
function normalizeWhitespace(value) {
|
|
1846
|
+
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/**
|
|
1850
|
+
* The ledger-only-content flag of one ledger row, as a one-element list, or none.
|
|
1851
|
+
* A v1 row is flagged when its whitespace-normalized details are non-empty and not
|
|
1852
|
+
* contained in its log row's; a v2 row when any projected content field differs
|
|
1853
|
+
* from its log row's.
|
|
1854
|
+
*/
|
|
1855
|
+
function ledgerOnlyContentFlags(ledgerRow, logRow) {
|
|
1856
|
+
let fields;
|
|
1857
|
+
if (isV2(ledgerRow)) {
|
|
1858
|
+
fields = PROJECTED_CONTENT_KEYS.filter(key => !sameJson(ledgerRow[key], logRow ? logRow[key] : undefined));
|
|
1859
|
+
} else {
|
|
1860
|
+
const ledgerDetails = normalizeWhitespace(ledgerRow.details);
|
|
1861
|
+
const logDetails = normalizeWhitespace(logRow ? logRow.details : undefined);
|
|
1862
|
+
fields = ledgerDetails !== '' && !logDetails.includes(ledgerDetails) ? ['details'] : [];
|
|
1863
|
+
}
|
|
1864
|
+
return fields.length > 0 ? [{ anchor_id: ledgerRow.anchor_id, flag: 'ledger-only-content', fields }] : [];
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* The data behind `show <anchor|obs_id>`: every ledger row carrying the entry's
|
|
1869
|
+
* observation id (in anchor order), its log row, its stored history versions,
|
|
1870
|
+
* and a `ledger-only-content` flag for each ledger row holding content its log
|
|
1871
|
+
* row does not.
|
|
1872
|
+
*
|
|
1873
|
+
* @param {string} key - an anchor id or an observation id
|
|
1874
|
+
* @param {object[]} ledger
|
|
1875
|
+
* @param {object[]} log
|
|
1876
|
+
* @param {{ historyVersions?: (id: string) => object[] }} [opts]
|
|
1877
|
+
* @returns {{ ok: true, value: { key: string, ledger: object[], log: object|null, history_versions: object[], flags: object[] } }
|
|
1878
|
+
* | { ok: false, error: { kind: 'invalid-key'|'not-found', message: string } }}
|
|
1879
|
+
*/
|
|
1880
|
+
function showEntry(key, ledger, log, { historyVersions: versionsOf } = {}) {
|
|
1881
|
+
const notFound = { ok: false, error: { kind: 'not-found', message: `show: no entry '${key}' in the ledger or the log` } };
|
|
1882
|
+
let id;
|
|
1883
|
+
let rows;
|
|
1884
|
+
if (typeof key === 'string' && ANCHOR_ID_RE.test(key)) {
|
|
1885
|
+
const anchored = ledger.find(row => row.anchor_id === key);
|
|
1886
|
+
if (!anchored) return notFound;
|
|
1887
|
+
id = isNonEmptyString(anchored.id) ? anchored.id : null;
|
|
1888
|
+
rows = id === null ? [anchored] : ledger.filter(row => row.id === id);
|
|
1889
|
+
} else if (typeof key === 'string' && OBS_ID_RE.test(key)) {
|
|
1890
|
+
id = key;
|
|
1891
|
+
rows = ledger.filter(row => row.id === key);
|
|
1892
|
+
} else {
|
|
1893
|
+
return {
|
|
1894
|
+
ok: false,
|
|
1895
|
+
error: { kind: 'invalid-key', message: `show: ${JSON.stringify(key)} is neither an anchor id nor an observation id` },
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
const logRow = id === null ? null : log.find(row => row.id === id) || null;
|
|
1899
|
+
if (rows.length === 0 && logRow === null) return notFound;
|
|
1900
|
+
const shown = sortedByAnchor(rows);
|
|
1901
|
+
return {
|
|
1902
|
+
ok: true,
|
|
1903
|
+
value: {
|
|
1904
|
+
key,
|
|
1905
|
+
ledger: shown,
|
|
1906
|
+
log: logRow,
|
|
1907
|
+
history_versions: id !== null && versionsOf ? versionsOf(id) : [],
|
|
1908
|
+
flags: shown.flatMap(row => ledgerOnlyContentFlags(row, logRow)),
|
|
1909
|
+
},
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/** The full commit id `ref` resolves to under `root`, or null. */
|
|
1914
|
+
function commitAt(root, ref) {
|
|
1915
|
+
let out;
|
|
1916
|
+
try {
|
|
1917
|
+
out = git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
1918
|
+
} catch {
|
|
1919
|
+
return null;
|
|
1920
|
+
}
|
|
1921
|
+
const commit = out.trim();
|
|
1922
|
+
return COMMIT_ID_RE.test(commit) ? commit : null;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* The ref and commit claims about the code are checked at.
|
|
1927
|
+
*
|
|
1928
|
+
* D-VERIFY-REF: a claim about the code is checked at the default branch as last
|
|
1929
|
+
* fetched — origin/HEAD — and at HEAD only when the repository has no usable
|
|
1930
|
+
* origin/HEAD; never at the working tree or the index. Reason: the ledger serves
|
|
1931
|
+
* every checkout of the repository, so a claim that holds only on one branch or in
|
|
1932
|
+
* uncommitted edits would mislead every other one, and a ref read needs no network
|
|
1933
|
+
* and runs no repository hook.
|
|
1934
|
+
*
|
|
1935
|
+
* @param {string} root - project root
|
|
1936
|
+
* @returns {{ ref: 'origin/HEAD'|'HEAD', commit: string } | null} null outside a
|
|
1937
|
+
* repository or before its first commit
|
|
1938
|
+
*/
|
|
1939
|
+
function resolveVerifyRef(root) {
|
|
1940
|
+
const fetched = commitAt(root, 'refs/remotes/origin/HEAD');
|
|
1941
|
+
if (fetched !== null) return { ref: 'origin/HEAD', commit: fetched };
|
|
1942
|
+
const head = commitAt(root, 'HEAD');
|
|
1943
|
+
return head === null ? null : { ref: 'HEAD', commit: head };
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
// ---------------------------------------------------------------------------
|
|
1947
|
+
// Rendering
|
|
1948
|
+
// ---------------------------------------------------------------------------
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Render decisions.md, pitfalls.md and index.md from `ledgerRows` and write each
|
|
1952
|
+
* atomically, the index last. The caller holds .decisions.lock
|
|
1953
|
+
* (D-ONE-LEARNING-LOCK), so the learning directory exists. render-decisions.cjs
|
|
1954
|
+
* requires this module at load time, so it is required here, on first use. Prints
|
|
1955
|
+
* nothing.
|
|
1956
|
+
*
|
|
1957
|
+
* @param {string} root - project root
|
|
1958
|
+
* @param {object[]} ledgerRows - every ledger row
|
|
1959
|
+
*/
|
|
1960
|
+
function renderAll(root, ledgerRows) {
|
|
1961
|
+
const { renderLearningFiles } = require('./render-decisions.cjs');
|
|
1962
|
+
for (const file of renderLearningFiles(root, ledgerRows)) writeFileAtomic(file.path, file.content);
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// ---------------------------------------------------------------------------
|
|
1966
|
+
// put-observation
|
|
1967
|
+
// ---------------------------------------------------------------------------
|
|
1968
|
+
|
|
1969
|
+
/** The counter keys of a v1 row that the v2 counters replace: count becomes observations, created first_seen. */
|
|
1970
|
+
const LEGACY_COUNTER_KEYS = Object.freeze(['count', 'created']);
|
|
1971
|
+
|
|
1972
|
+
/** A validation field that prints as it is; any other prints as JSON, on one line. */
|
|
1973
|
+
const PLAIN_FIELD_RE = /^[\w.()[\]-]{1,64}$/;
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* The anchored ledger rows carrying observation `id`, in anchor order: the
|
|
1977
|
+
* entries the observation backs (D-LEDGER-REGISTRY).
|
|
1978
|
+
*
|
|
1979
|
+
* @param {{ byObsId: Map<string, object[]> }} registry
|
|
1980
|
+
* @param {string|null} id
|
|
1981
|
+
* @returns {object[]}
|
|
1982
|
+
*/
|
|
1983
|
+
function anchoredCarriers(registry, id) {
|
|
1984
|
+
const carriers = id === null ? [] : registry.byObsId.get(id) || [];
|
|
1985
|
+
return sortedByAnchor(carriers.filter(row => isNonEmptyString(row.anchor_id)));
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/** True when two rows differ in any CONTENT_KEYS value. */
|
|
1989
|
+
function contentDiffers(a, b) {
|
|
1990
|
+
return CONTENT_KEYS.some(key => !sameJson(a[key], b[key]));
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/** The log row a create stores: the content after the schema, then one observation, first and last seen now. */
|
|
1994
|
+
function createdRow(content, now) {
|
|
1995
|
+
const at = new Date(now).toISOString();
|
|
1996
|
+
return { schema: SCHEMA_VERSION, ...content, observations: 1, first_seen: at, last_seen: at };
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/** The log row an update stores: the new content, then `existing`'s counters (a v1 row's converted). */
|
|
2000
|
+
function updatedRow(content, existing, now) {
|
|
2001
|
+
return { schema: SCHEMA_VERSION, ...content, ...toV2Counters(existing, { now }) };
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* `existing` with one more observation, last seen now. Every other key stays,
|
|
2006
|
+
* so a v1 row stays v1; its legacy counters give way to the v2 ones.
|
|
2007
|
+
*/
|
|
2008
|
+
function reinforcedRow(existing, now) {
|
|
2009
|
+
const counters = toV2Counters(existing, { now });
|
|
2010
|
+
const kept = Object.fromEntries(Object.entries(existing).filter(([key]) => !LEGACY_COUNTER_KEYS.includes(key)));
|
|
2011
|
+
return {
|
|
2012
|
+
...kept,
|
|
2013
|
+
observations: counters.observations + 1,
|
|
2014
|
+
first_seen: counters.first_seen,
|
|
2015
|
+
last_seen: new Date(now).toISOString(),
|
|
2016
|
+
};
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* The log row a put stores and the outcome it reports, or null for an update
|
|
2021
|
+
* whose content a v2 row already holds. A v1 row is never unchanged: an update
|
|
2022
|
+
* converts it.
|
|
2023
|
+
*/
|
|
2024
|
+
function plannedLogRow(mode, content, existing, now) {
|
|
2025
|
+
if (mode === 'reinforce') return { outcome: 'reinforced', logRow: reinforcedRow(existing, now) };
|
|
2026
|
+
if (mode === 'create') return { outcome: 'created', logRow: createdRow(content, now) };
|
|
2027
|
+
if (isV2(existing) && !contentDiffers(existing, content)) return null;
|
|
2028
|
+
return { outcome: 'updated', logRow: updatedRow(content, existing, now) };
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/** Why active entry `row` cannot take a log row of `type`, or null when it can. */
|
|
2032
|
+
function reprojectionProblem(row, type) {
|
|
2033
|
+
if (row.type !== type) return `it is a ${row.type} entry and the observation is a ${type}`;
|
|
2034
|
+
if (!ANCHOR_ID_RE.test(row.anchor_id) || !row.anchor_id.startsWith(`${anchorPrefixFor(type)}-`)) {
|
|
2035
|
+
return `its anchor does not name a ${type} entry`;
|
|
2036
|
+
}
|
|
2037
|
+
return null;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Active entry `prior` re-projected from `logRow` (D-PUT-REPROJECTS). A status
|
|
2042
|
+
* outside ENTRY_STATUSES — absent or unknown, which still counts as active —
|
|
2043
|
+
* becomes the type's active status; every other ledger-owned key carries over.
|
|
2044
|
+
*/
|
|
2045
|
+
function reprojectedRow(logRow, prior) {
|
|
2046
|
+
const status = ENTRY_STATUSES.includes(prior.decisions_status) ? undefined : activeStatusFor(logRow.type);
|
|
2047
|
+
return toLedgerRowV2(logRow, prior, { status, expectType: prior.type });
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/** A put's refusal: `message` follows the op name, and `extra` joins the error. */
|
|
2051
|
+
function putRefusal(kind, message, extra = {}) {
|
|
2052
|
+
return { ok: false, error: { kind, message: `put-observation: ${message}`, ...extra } };
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/** How a validation field prints: a plain name as it is, anything else as JSON on one line. */
|
|
2056
|
+
function fieldLabel(field) {
|
|
2057
|
+
return PLAIN_FIELD_RE.test(field) ? field : cutTo(singleLine(JSON.stringify(field)), 80);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/** The refusal of an op's stdin input: every problem, one per line, after the op name. */
|
|
2061
|
+
function invalidInput(opName, problems) {
|
|
2062
|
+
const count = `${problems.length} problem${problems.length === 1 ? '' : 's'}`;
|
|
2063
|
+
const lines = problems.map(problem => ` ${fieldLabel(problem.field)}: ${singleLine(problem.message)}`);
|
|
2064
|
+
const message = [`${opName}: the input has ${count}; nothing was written`, ...lines].join('\n');
|
|
2065
|
+
return { ok: false, error: { kind: 'invalid-input', message, problems } };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** The refusal of a put on an observation whose entries are all inactive. */
|
|
2069
|
+
function restoreFirst(id, carriers) {
|
|
2070
|
+
const entries = carriers.map(row => `${row.anchor_id} ${row.decisions_status}`).join(', ');
|
|
2071
|
+
return putRefusal('restore-first', `'${id}' belongs only to inactive entries (${singleLine(entries)}); restore first`);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Store one observation from the put-observation op: create it, replace its
|
|
2076
|
+
* content, or count one more sighting of it.
|
|
2077
|
+
*
|
|
2078
|
+
* D-PUT-REPROJECTS: when a put stores new content for an observation, every
|
|
2079
|
+
* active ledger row carrying the observation's id is re-projected from the new
|
|
2080
|
+
* log row through toLedgerRowV2, and decisions.md, pitfalls.md and index.md are
|
|
2081
|
+
* re-rendered, all under the .decisions.lock the log was written under; a ledger
|
|
2082
|
+
* row carrying the id with an inactive status is written back unchanged.
|
|
2083
|
+
* Reason: an entry's ledger row and rendered text must follow its log row at
|
|
2084
|
+
* once — a separate refresh step can be skipped or interleaved with another
|
|
2085
|
+
* writer, and leaves every reader on the old wording until it runs — while a
|
|
2086
|
+
* retired entry keeps the wording it was retired with.
|
|
2087
|
+
*
|
|
2088
|
+
* Modes, each taking content under D-PUT-NOT-MERGE:
|
|
2089
|
+
* create an id the log does not hold, stored with one observation, first
|
|
2090
|
+
* and last seen now. An active entry already carrying the id is
|
|
2091
|
+
* re-projected: the repair for an entry that lost its log row.
|
|
2092
|
+
* update the whole new content of an id the log holds, never merged with
|
|
2093
|
+
* the old, of the same type. A v1 row becomes a v2 row, its
|
|
2094
|
+
* counters converted by toV2Counters; what only v1 held (pattern,
|
|
2095
|
+
* details, amendments, evidence over the limits) stays in the
|
|
2096
|
+
* history and the pre-v2 backup. Content equal to a v2 row's is
|
|
2097
|
+
* `unchanged` and writes nothing.
|
|
2098
|
+
* reinforce `{ id }` alone: one more observation, last seen now — no
|
|
2099
|
+
* history, no re-projection and no render. A v1 row stays v1, its
|
|
2100
|
+
* counters converted.
|
|
2101
|
+
*
|
|
2102
|
+
* Every mode refuses, writing nothing: an input validation rejects (every
|
|
2103
|
+
* problem at once), an id the log holds twice, an observation whose entries are
|
|
2104
|
+
* all inactive ("restore first"), and an active entry that cannot take the
|
|
2105
|
+
* observation's type. A put that writes backs up a v1 tree first
|
|
2106
|
+
* (D-V1-BACKUP-ONCE), quarantines the malformed lines of each file it rewrites
|
|
2107
|
+
* (D-QUARANTINE-MALFORMED) and records the content it replaces
|
|
2108
|
+
* (D-CONTENT-HISTORY). Entries are found through the ledger alone
|
|
2109
|
+
* (D-LEDGER-REGISTRY).
|
|
2110
|
+
*
|
|
2111
|
+
* @param {string} root - project root
|
|
2112
|
+
* @param {'create'|'update'|'reinforce'} mode
|
|
2113
|
+
* @param {unknown} input - the parsed stdin object
|
|
2114
|
+
* @param {{ now?: number, timeoutMs?: number, scopeMatches?: (glob: string) => boolean }} [opts]
|
|
2115
|
+
* now: epoch ms (default Date.now()); scopeMatches: default gitScopeMatcher(root)
|
|
2116
|
+
* @returns {{ ok: true, value: { outcome: 'created'|'updated'|'unchanged'|'reinforced', id: string, observations: number, reprojected: string[] } }
|
|
2117
|
+
* | { ok: false, error: { kind: string, message: string, problems?: Array<{ field: string, message: string }> } }}
|
|
2118
|
+
* observations: the count the log row holds afterwards; reprojected: the
|
|
2119
|
+
* anchors re-projected, in anchor order. Error kinds: invalid-input (with
|
|
2120
|
+
* problems), duplicate-log-id, restore-first, cannot-reproject, and
|
|
2121
|
+
* withDecisionsLock's not-a-directory, no-learning-dir and busy.
|
|
2122
|
+
* @throws {TypeError} when `mode` is not a put mode
|
|
2123
|
+
*/
|
|
2124
|
+
function putObservation(root, mode, input, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
|
|
2125
|
+
if (!VALIDATION_MODES.includes(mode)) {
|
|
2126
|
+
throw new TypeError(`putObservation: mode must be one of ${VALIDATION_MODES.join(', ')}, got '${mode}'`);
|
|
2127
|
+
}
|
|
2128
|
+
const matches = scopeMatches || gitScopeMatcher(root);
|
|
2129
|
+
return withDecisionsLock('put-observation', root, () => putUnderLock(root, mode, input, { now, scopeMatches: matches }), { timeoutMs });
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/** putObservation's locked body. */
|
|
2133
|
+
function putUnderLock(root, mode, input, { now, scopeMatches }) {
|
|
2134
|
+
const logPath = getDecisionsLogPath(root);
|
|
2135
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
2136
|
+
const log = readJsonl(logPath);
|
|
2137
|
+
const ledger = readJsonl(ledgerPath);
|
|
2138
|
+
const registry = ledgerRegistry(ledger.rows);
|
|
2139
|
+
|
|
2140
|
+
const id = isPlainObject(input) && typeof input.id === 'string' ? input.id : null;
|
|
2141
|
+
const sameId = id === null ? [] : log.rows.filter(row => row.id === id);
|
|
2142
|
+
const existing = sameId[0] || null;
|
|
2143
|
+
const checked = validateObservationInput(input, { mode, existing, ledgerIds: registry.byAnchor.keys(), scopeMatches });
|
|
2144
|
+
if (!checked.ok) return invalidInput('put-observation', checked.errors);
|
|
2145
|
+
if (sameId.length > 1) {
|
|
2146
|
+
return putRefusal('duplicate-log-id', `the log holds ${sameId.length} rows with id '${id}'; nothing was written`);
|
|
2147
|
+
}
|
|
2148
|
+
const carriers = anchoredCarriers(registry, id);
|
|
2149
|
+
const active = carriers.filter(row => isActive(row));
|
|
2150
|
+
if (carriers.length > 0 && active.length === 0) return restoreFirst(id, carriers);
|
|
2151
|
+
|
|
2152
|
+
const planned = plannedLogRow(mode, checked.value, existing, now);
|
|
2153
|
+
if (planned === null) {
|
|
2154
|
+
const observations = toV2Counters(existing, { now }).observations;
|
|
2155
|
+
return { ok: true, value: { outcome: 'unchanged', id, observations, reprojected: [] } };
|
|
2156
|
+
}
|
|
2157
|
+
const { outcome, logRow } = planned;
|
|
2158
|
+
|
|
2159
|
+
const reprojecting = mode === 'reinforce' ? [] : active;
|
|
2160
|
+
for (const row of reprojecting) {
|
|
2161
|
+
const problem = reprojectionProblem(row, logRow.type);
|
|
2162
|
+
if (problem) {
|
|
2163
|
+
return putRefusal('cannot-reproject', `${singleLine(String(row.anchor_id))} cannot take this observation: ${problem}; nothing was written`);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
const projected = new Map(reprojecting.map(row => [row, reprojectedRow(logRow, row)]));
|
|
2167
|
+
const replacesContent = mode === 'update' || [...projected].some(([prior, row]) => !sameJson(prior, row));
|
|
2168
|
+
|
|
2169
|
+
ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
|
|
2170
|
+
quarantineRejected(logPath, log.rejected, { now });
|
|
2171
|
+
if (replacesContent) appendHistory(root, { id, ledger: registry.byObsId.get(id) || [], log: existing }, { now });
|
|
2172
|
+
writeJsonlAtomic(logPath, existing === null ? [...log.rows, logRow] : log.rows.map(row => (row === existing ? logRow : row)));
|
|
2173
|
+
if (projected.size > 0) {
|
|
2174
|
+
quarantineRejected(ledgerPath, ledger.rejected, { now });
|
|
2175
|
+
const ledgerRows = ledger.rows.map(row => projected.get(row) || row);
|
|
2176
|
+
writeJsonlAtomic(ledgerPath, ledgerRows);
|
|
2177
|
+
renderAll(root, ledgerRows);
|
|
2178
|
+
}
|
|
2179
|
+
return {
|
|
2180
|
+
ok: true,
|
|
2181
|
+
value: { outcome, id, observations: logRow.observations, reprojected: reprojecting.map(row => row.anchor_id) },
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// ---------------------------------------------------------------------------
|
|
2186
|
+
// list, show and claim-due
|
|
2187
|
+
// ---------------------------------------------------------------------------
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* The data behind `list`, read-only: buildListing over the ledger and the log as
|
|
2191
|
+
* they are, every glob scope checked against the files git tracks. Malformed
|
|
2192
|
+
* lines are counted, never quarantined (D-QUARANTINE-MALFORMED). Refuses without
|
|
2193
|
+
* .devflow/learning/ (D-NO-STRAY-TREE).
|
|
2194
|
+
*
|
|
2195
|
+
* @param {string} root - project root
|
|
2196
|
+
* @param {{ scopeMatches?: (glob: string) => boolean }} [opts] - default gitScopeMatcher(root)
|
|
2197
|
+
* @returns {{ ok: true, value: { active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } } }
|
|
2198
|
+
* | { ok: false, error: { kind: 'no-learning-dir', message: string } }}
|
|
2199
|
+
*/
|
|
2200
|
+
function readListing(root, { scopeMatches } = {}) {
|
|
2201
|
+
if (!hasLearningDir(root)) return noLearningDir('list', root);
|
|
2202
|
+
const { ledgerRows, logRows, rejected } = readLearningState(root);
|
|
2203
|
+
return {
|
|
2204
|
+
ok: true,
|
|
2205
|
+
value: buildListing(ledgerRows, logRows, { scopeMatches: scopeMatches || gitScopeMatcher(root), rejected }),
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/** A listing token as it prints: itself when it is one word, else `-`. */
|
|
2210
|
+
function listingToken(value) {
|
|
2211
|
+
return isNonEmptyString(value) && !/\s/.test(value) && !CONTROL_CHAR_RE.test(value) ? value : '-';
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** A listing's scope token: its entries joined by `,`, each as listingToken prints it, or `-` for none. */
|
|
2215
|
+
function scopeToken(scope) {
|
|
2216
|
+
return Array.isArray(scope) && scope.length > 0 ? scope.map(listingToken).join(',') : '-';
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* The text `list` prints. Each section opens with its name and count, and each
|
|
2221
|
+
* item is a line indented two spaces whose tokens are separated by one space,
|
|
2222
|
+
* the title last and taking the rest of the line:
|
|
2223
|
+
*
|
|
2224
|
+
* ACTIVE <n>
|
|
2225
|
+
* <anchor> <obs_id> v<schema> verified <date|never> observed <count|?> last-seen <last_seen|-> scope <scope|-> <title>
|
|
2226
|
+
* INACTIVE <n>
|
|
2227
|
+
* <anchor> <obs_id> v<schema> <status> <title>
|
|
2228
|
+
* note: <note> only when the entry records one
|
|
2229
|
+
* OBSERVATIONS <n> the log rows no ledger row carries
|
|
2230
|
+
* <obs_id> <type> v<schema> observed <count|?> <title>
|
|
2231
|
+
* INTEGRITY <n>
|
|
2232
|
+
* <anchor> <obs_id> <flag>[,<flag>…]
|
|
2233
|
+
* MALFORMED <n> only when lines were skipped
|
|
2234
|
+
* ledger <k> each file with skipped lines
|
|
2235
|
+
* log <k>
|
|
2236
|
+
*
|
|
2237
|
+
* An ACTIVE line's `verified` is the entry's last_verified date, or `never`;
|
|
2238
|
+
* `observed` and `last-seen` are its log row's observation count and last
|
|
2239
|
+
* sighting; `scope` is its scope entries joined by `,`, or `-` when it has none,
|
|
2240
|
+
* as a v1 entry does. A token that is missing or not one word prints as `-` (a
|
|
2241
|
+
* missing count as `?`), and so does an empty title; titles and notes are
|
|
2242
|
+
* already one line (buildListing).
|
|
2243
|
+
*
|
|
2244
|
+
* @param {{ active: object[], inactive: object[], observations: object[], integrity: object[], malformed: { ledger: number, log: number } }} listing - buildListing's result
|
|
2245
|
+
* @returns {string} the lines, with no final newline
|
|
2246
|
+
*/
|
|
2247
|
+
function formatListing(listing) {
|
|
2248
|
+
const title = text => (text === '' ? '-' : text);
|
|
2249
|
+
const verified = row => (isNonEmptyString(row.last_verified) ? listingToken(row.last_verified) : 'never');
|
|
2250
|
+
const section = (name, items, toLines) => [`${name} ${items.length}`, ...items.flatMap(toLines)];
|
|
2251
|
+
const lines = [
|
|
2252
|
+
...section('ACTIVE', listing.active, row => [
|
|
2253
|
+
` ${listingToken(row.anchor_id)} ${listingToken(row.id)} v${row.schema} verified ${verified(row)}`
|
|
2254
|
+
+ ` observed ${row.observations ?? '?'} last-seen ${listingToken(row.last_seen)} scope ${scopeToken(row.scope)}`
|
|
2255
|
+
+ ` ${title(row.title)}`,
|
|
2256
|
+
]),
|
|
2257
|
+
...section('INACTIVE', listing.inactive, row => [
|
|
2258
|
+
` ${listingToken(row.anchor_id)} ${listingToken(row.id)} v${row.schema} ${listingToken(row.status)} ${title(row.title)}`,
|
|
2259
|
+
...(row.note ? [` note: ${row.note}`] : []),
|
|
2260
|
+
]),
|
|
2261
|
+
...section('OBSERVATIONS', listing.observations, row => [
|
|
2262
|
+
` ${listingToken(row.id)} ${listingToken(row.type)} v${row.schema} observed ${row.observations ?? '?'} ${title(row.title)}`,
|
|
2263
|
+
]),
|
|
2264
|
+
...section('INTEGRITY', listing.integrity, entry => [
|
|
2265
|
+
` ${listingToken(entry.anchor_id)} ${listingToken(entry.id)} ${entry.flags.join(',')}`,
|
|
2266
|
+
]),
|
|
2267
|
+
];
|
|
2268
|
+
const skipped = ['ledger', 'log'].filter(file => listing.malformed[file] > 0);
|
|
2269
|
+
if (skipped.length > 0) {
|
|
2270
|
+
lines.push(
|
|
2271
|
+
`MALFORMED ${listing.malformed.ledger + listing.malformed.log}`,
|
|
2272
|
+
...skipped.map(file => ` ${file} ${listing.malformed[file]}`),
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
return lines.join('\n');
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* The data behind `show <anchor|obs_id>`, read-only: showEntry over the ledger,
|
|
2280
|
+
* the log and the history as they are. When lines were skipped as malformed
|
|
2281
|
+
* (D-QUARANTINE-MALFORMED), a found entry gains `malformed: { ledger, log }` and
|
|
2282
|
+
* a not-found message says a skipped line may hold it. Refuses without
|
|
2283
|
+
* .devflow/learning/ (D-NO-STRAY-TREE).
|
|
2284
|
+
*
|
|
2285
|
+
* @param {string} root - project root
|
|
2286
|
+
* @param {string} key - an anchor id or an observation id
|
|
2287
|
+
* @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-learning-dir'|'invalid-key'|'not-found', message: string } }}
|
|
2289
|
+
*/
|
|
2290
|
+
function showByKey(root, key) {
|
|
2291
|
+
if (!hasLearningDir(root)) return noLearningDir('show', root);
|
|
2292
|
+
const { ledgerRows, logRows, rejected } = readLearningState(root);
|
|
2293
|
+
const shown = showEntry(key, ledgerRows, logRows, { historyVersions: id => historyVersions(root, id) });
|
|
2294
|
+
const malformed = { ledger: rejected.ledger.length, log: rejected.log.length };
|
|
2295
|
+
const skipped = malformed.ledger + malformed.log;
|
|
2296
|
+
if (skipped === 0) return shown;
|
|
2297
|
+
if (shown.ok) return { ok: true, value: { ...shown.value, malformed } };
|
|
2298
|
+
if (shown.error.kind !== 'not-found') return shown;
|
|
2299
|
+
const note = `MALFORMED ${skipped} (ledger ${malformed.ledger}, log ${malformed.log}): a skipped line may hold it`;
|
|
2300
|
+
return { ok: false, error: { ...shown.error, message: `${shown.error.message}; ${note}` } };
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
/**
|
|
2304
|
+
* Ask `scopeMatches` about each glob integrityFlags will ask about for these rows
|
|
2305
|
+
* — the glob scopes of the active v2 entries — so that a memoized matcher answers
|
|
2306
|
+
* from memory, with no git call, once the lock is held.
|
|
2307
|
+
*
|
|
2308
|
+
* @param {object[]} ledgerRows
|
|
2309
|
+
* @param {(glob: string) => boolean} scopeMatches
|
|
2310
|
+
*/
|
|
2311
|
+
function warmScopeMatcher(ledgerRows, scopeMatches) {
|
|
2312
|
+
for (const row of activeAnchoredRows(ledgerRows)) {
|
|
2313
|
+
if (!isV2(row) || !Array.isArray(row.scope)) continue;
|
|
2314
|
+
for (const entry of row.scope) {
|
|
2315
|
+
if (isNonEmptyString(entry) && !entry.startsWith('area:')) scopeMatches(entry);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/**
|
|
2321
|
+
* Hand out the entries maintenance works on next, and lease them.
|
|
2322
|
+
*
|
|
2323
|
+
* Under the learning lock it flags integrity problems and selects the due
|
|
2324
|
+
* entries (D-DUE-ORDER), then stamps each entry it hands out with
|
|
2325
|
+
* `last_attempt` = now: claim-due is the one writer of the field the lease
|
|
2326
|
+
* reads. A hand-out backs up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines
|
|
2327
|
+
* the ledger's malformed lines before it rewrites the ledger
|
|
2328
|
+
* (D-QUARANTINE-MALFORMED); with nothing due it writes nothing. It answers the
|
|
2329
|
+
* ref claims are checked at as well (D-VERIFY-REF). The scope checks' git calls
|
|
2330
|
+
* run before the lock is taken, on the ledger as it stood then; a glob that
|
|
2331
|
+
* appears meanwhile is checked under the lock.
|
|
2332
|
+
*
|
|
2333
|
+
* @param {string} root - project root
|
|
2334
|
+
* @param {{ now?: number, timeoutMs?: number, scopeMatches?: (glob: string) => boolean }} [opts]
|
|
2335
|
+
* now: epoch ms (default Date.now()); scopeMatches: default gitScopeMatcher(root)
|
|
2336
|
+
* @returns {{ ok: true, value: { ref: { ref: 'origin/HEAD'|'HEAD', commit: string } | null, due: Array<{ anchor_id: string, reason: string, bytes: number }> } }
|
|
2337
|
+
* | { ok: false, error: { kind: string, message: string } }}
|
|
2338
|
+
* due is selectDue's answer; errors are withDecisionsLock's not-a-directory,
|
|
2339
|
+
* no-learning-dir and busy
|
|
2340
|
+
*/
|
|
2341
|
+
function claimDue(root, { now = Date.now(), timeoutMs, scopeMatches } = {}) {
|
|
2342
|
+
if (!hasLearningDir(root)) return noLearningDir('claim-due', root);
|
|
2343
|
+
const matches = scopeMatches || gitScopeMatcher(root);
|
|
2344
|
+
const ref = resolveVerifyRef(root);
|
|
2345
|
+
warmScopeMatcher(readJsonl(getDecisionsLedgerPath(root)).rows, matches);
|
|
2346
|
+
return withDecisionsLock('claim-due', root, () => {
|
|
2347
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
2348
|
+
const ledger = readJsonl(ledgerPath);
|
|
2349
|
+
const logRows = readJsonl(getDecisionsLogPath(root)).rows;
|
|
2350
|
+
const integrity = integrityFlags(ledger.rows, logRows, { scopeMatches: matches });
|
|
2351
|
+
const due = selectDue(ledger.rows, logRows, { now, integrity });
|
|
2352
|
+
if (due.length > 0) {
|
|
2353
|
+
ensurePreV2Backup(root, { logRows, ledgerRows: ledger.rows });
|
|
2354
|
+
quarantineRejected(ledgerPath, ledger.rejected, { now });
|
|
2355
|
+
const handedOut = new Set(due.map(entry => entry.anchor_id));
|
|
2356
|
+
const attemptedAt = new Date(now).toISOString();
|
|
2357
|
+
writeJsonlAtomic(ledgerPath, ledger.rows.map(row => (
|
|
2358
|
+
handedOut.has(row.anchor_id) && isActive(row) ? { ...row, last_attempt: attemptedAt } : row
|
|
2359
|
+
)));
|
|
2360
|
+
}
|
|
2361
|
+
return { ok: true, value: { ref, due } };
|
|
2362
|
+
}, { timeoutMs });
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// ---------------------------------------------------------------------------
|
|
2366
|
+
// assign-anchor: numbering and the cited-number scan
|
|
2367
|
+
// ---------------------------------------------------------------------------
|
|
2368
|
+
|
|
2369
|
+
/** The most cited numbers assign-anchor skips before it refuses (D-E4-SKIP). */
|
|
2370
|
+
const E4_MAX_SKIPS = 100;
|
|
2371
|
+
|
|
2372
|
+
/** Directory names the cited-number scan never reads, at any depth (D-E4-SKIP). */
|
|
2373
|
+
const CITED_SCAN_EXCLUDED_SEGMENTS = Object.freeze(['.git', 'node_modules', 'target', 'dist']);
|
|
2374
|
+
|
|
2375
|
+
/** The largest file the cited-number scan reads (bytes); a larger one is skipped. */
|
|
2376
|
+
const CITED_SCAN_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
2377
|
+
|
|
2378
|
+
/** The most directory entries the fallback walk examines; the walk stops there. */
|
|
2379
|
+
const CITED_SCAN_MAX_ENTRIES = 200000;
|
|
2380
|
+
|
|
2381
|
+
/** Anchor `n` of `prefix`, its number zero-padded to three digits. */
|
|
2382
|
+
function formatAnchorId(prefix, n) {
|
|
2383
|
+
return `${prefix}-${String(n).padStart(3, '0')}`;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** The highest number any ledger row's anchor of `prefix` carries, whatever its status, or 0. */
|
|
2387
|
+
function highestAnchorNumber(ledgerRows, prefix) {
|
|
2388
|
+
const anchorRe = new RegExp(`^${prefix}-(\\d+)$`);
|
|
2389
|
+
let highest = 0;
|
|
2390
|
+
for (const row of ledgerRows) {
|
|
2391
|
+
const m = isNonEmptyString(row.anchor_id) ? anchorRe.exec(row.anchor_id) : null;
|
|
2392
|
+
if (m) highest = Math.max(highest, parseInt(m[1], 10));
|
|
2393
|
+
}
|
|
2394
|
+
return highest;
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
/**
|
|
2398
|
+
* The next anchor of `type`: one past the highest number any anchored ledger row
|
|
2399
|
+
* of that type carries, inactive rows included, so a retired number is never
|
|
2400
|
+
* reused. Decisions and pitfalls number separately. One pass over the rows.
|
|
2401
|
+
*
|
|
2402
|
+
* @param {object[]} ledgerRows
|
|
2403
|
+
* @param {'decision'|'pitfall'} type
|
|
2404
|
+
* @returns {{ anchorId: string, nextN: string }} nextN is the number, zero-padded to three digits
|
|
2405
|
+
* @throws {TypeError} for any other type
|
|
2406
|
+
*/
|
|
2407
|
+
function nextAnchorFromLedger(ledgerRows, type) {
|
|
2408
|
+
const prefix = anchorPrefixFor(type);
|
|
2409
|
+
if (prefix === null) throw new TypeError(`nextAnchorFromLedger: type must be 'decision' or 'pitfall', got '${type}'`);
|
|
2410
|
+
const anchorId = formatAnchorId(prefix, highestAnchorNumber(ledgerRows, prefix) + 1);
|
|
2411
|
+
return { anchorId, nextN: anchorId.slice(prefix.length + 1) };
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/**
|
|
2415
|
+
* True when a project-relative path is one the cited-number scan never reads: the
|
|
2416
|
+
* learning tree, where every entry cites itself, or anything under an excluded
|
|
2417
|
+
* directory segment.
|
|
2418
|
+
*
|
|
2419
|
+
* @param {string} relPath - relative to the project root, either separator style
|
|
2420
|
+
* @returns {boolean}
|
|
2421
|
+
*/
|
|
2422
|
+
function isCitedScanExcluded(relPath) {
|
|
2423
|
+
const norm = relPath.split(path.sep).join('/');
|
|
2424
|
+
if (norm === '.devflow/learning' || norm.startsWith('.devflow/learning/')) return true;
|
|
2425
|
+
return norm.split('/').some(segment => CITED_SCAN_EXCLUDED_SEGMENTS.includes(segment));
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
/**
|
|
2429
|
+
* The files git tracks under `root`, relative to it.
|
|
2430
|
+
*
|
|
2431
|
+
* D-NO-FSMONITOR: `ls-files` reads the index, and reading the index runs the
|
|
2432
|
+
* command a repository's config names in `core.fsmonitor` — code chosen by the
|
|
2433
|
+
* repository this hook runs inside. The call turns it off for itself
|
|
2434
|
+
* (`-c core.fsmonitor=false`), so the listing stays a pure read. Reason: the
|
|
2435
|
+
* learning ops run inside any repository a session opens, and a read that ran
|
|
2436
|
+
* the repository's command would execute it with the user's privileges.
|
|
2437
|
+
*
|
|
2438
|
+
* @param {string} root - project root
|
|
2439
|
+
* @returns {string[]}
|
|
2440
|
+
* @throws when `root` is not in a git working tree, git is missing, or the call
|
|
2441
|
+
* times out or overflows its buffer; the caller then walks the tree instead
|
|
2442
|
+
*/
|
|
2443
|
+
function listGitTrackedFiles(root) {
|
|
2444
|
+
return git(root, ['ls-files', '-z']).split('\0').filter(Boolean);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
/**
|
|
2448
|
+
* The files under `root` a directory walk finds, relative to it and sorted — the
|
|
2449
|
+
* scan's fallback outside a git working tree. It never descends into an excluded
|
|
2450
|
+
* directory, follows no symbolic link (the directory entry of a link is neither a
|
|
2451
|
+
* file nor a directory) and stops after CITED_SCAN_MAX_ENTRIES entries. A
|
|
2452
|
+
* directory it cannot read is skipped.
|
|
2453
|
+
*
|
|
2454
|
+
* @param {string} root - project root
|
|
2455
|
+
* @returns {string[]}
|
|
2456
|
+
*/
|
|
2457
|
+
function listFsWalkFiles(root) {
|
|
2458
|
+
const results = [];
|
|
2459
|
+
const stack = [''];
|
|
2460
|
+
let budget = CITED_SCAN_MAX_ENTRIES;
|
|
2461
|
+
while (stack.length > 0 && budget > 0) {
|
|
2462
|
+
const relDir = stack.pop();
|
|
2463
|
+
let entries;
|
|
2464
|
+
try {
|
|
2465
|
+
entries = fs.readdirSync(relDir ? path.join(root, relDir) : root, { withFileTypes: true });
|
|
2466
|
+
} catch {
|
|
2467
|
+
continue;
|
|
2468
|
+
}
|
|
2469
|
+
for (const entry of entries) {
|
|
2470
|
+
if (budget === 0) break;
|
|
2471
|
+
budget -= 1;
|
|
2472
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
2473
|
+
if (isCitedScanExcluded(relPath)) continue;
|
|
2474
|
+
if (entry.isDirectory()) stack.push(relPath);
|
|
2475
|
+
else if (entry.isFile()) results.push(relPath);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
return results.sort();
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/**
|
|
2482
|
+
* The text of a file the cited-number scan reads, or null for one it skips: a
|
|
2483
|
+
* file it cannot open or read, anything but a regular file — a symbolic link
|
|
2484
|
+
* included, since it opens with O_NOFOLLOW — a file over
|
|
2485
|
+
* CITED_SCAN_MAX_FILE_BYTES, and a binary file (one holding a NUL byte).
|
|
2486
|
+
* O_NONBLOCK keeps a FIFO from blocking the open, and the read never takes more
|
|
2487
|
+
* bytes than the size checked.
|
|
2488
|
+
*
|
|
2489
|
+
* @param {string} file
|
|
2490
|
+
* @returns {string|null}
|
|
2491
|
+
*/
|
|
2492
|
+
function readScannedText(file) {
|
|
2493
|
+
let fd;
|
|
2494
|
+
try {
|
|
2495
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
2496
|
+
} catch {
|
|
2497
|
+
return null;
|
|
2498
|
+
}
|
|
2499
|
+
try {
|
|
2500
|
+
const stat = fs.fstatSync(fd);
|
|
2501
|
+
if (!stat.isFile() || stat.size > CITED_SCAN_MAX_FILE_BYTES) return null;
|
|
2502
|
+
const text = readOpenedText(fd, stat.size);
|
|
2503
|
+
return text.includes('\u0000') ? null : text;
|
|
2504
|
+
} catch {
|
|
2505
|
+
return null;
|
|
2506
|
+
} finally {
|
|
2507
|
+
fs.closeSync(fd);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* Every anchor the project's files cite as a whole word, each mapped to its first
|
|
2513
|
+
* citation: the scan behind D-E4-SKIP. It reads the files git tracks under `root`
|
|
2514
|
+
* (D-NO-FSMONITOR) or, outside a git working tree, the files a directory walk
|
|
2515
|
+
* finds — never the learning tree, an excluded directory, a symbolic link, a
|
|
2516
|
+
* binary file or a file over the size cap. A file it cannot read is passed over.
|
|
2517
|
+
* It writes nothing and takes no lock.
|
|
2518
|
+
*
|
|
2519
|
+
* @param {string} root - project root
|
|
2520
|
+
* @returns {Map<string, { file: string, line: number }>} the file relative to
|
|
2521
|
+
* `root`, the line 1-based; citations in listing order
|
|
2522
|
+
*/
|
|
2523
|
+
function collectCitedAnchorIds(root) {
|
|
2524
|
+
let files;
|
|
2525
|
+
try {
|
|
2526
|
+
files = listGitTrackedFiles(root);
|
|
2527
|
+
} catch {
|
|
2528
|
+
files = listFsWalkFiles(root);
|
|
2529
|
+
}
|
|
2530
|
+
const cited = new Map();
|
|
2531
|
+
for (const relPath of files) {
|
|
2532
|
+
if (isCitedScanExcluded(relPath)) continue;
|
|
2533
|
+
const text = readScannedText(path.join(root, relPath));
|
|
2534
|
+
if (text === null || !(text.includes('ADR-') || text.includes('PF-'))) continue;
|
|
2535
|
+
const lines = text.split('\n');
|
|
2536
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2537
|
+
for (const m of lines[i].matchAll(ANCHOR_WORD_RE)) {
|
|
2538
|
+
if (!cited.has(m[0])) cited.set(m[0], { file: relPath, line: i + 1 });
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
return cited;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/** Today on the clock `now`, as the ledger's date fields hold it: YYYY-MM-DD, UTC. */
|
|
2546
|
+
function isoDate(now) {
|
|
2547
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/** An assign-anchor refusal: `message` follows the op name. */
|
|
2551
|
+
function assignRefusal(kind, message) {
|
|
2552
|
+
return { ok: false, error: { kind, message: `assign-anchor: ${message}` } };
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
/**
|
|
2556
|
+
* The anchor assign-anchor mints for `type` (D-E4-SKIP): nextAnchorFromLedger's,
|
|
2557
|
+
* or the first number after it that `citedAnchors` does not hold, skipping at most
|
|
2558
|
+
* E4_MAX_SKIPS numbers.
|
|
2559
|
+
*
|
|
2560
|
+
* @param {object[]} ledgerRows
|
|
2561
|
+
* @param {'decision'|'pitfall'} type
|
|
2562
|
+
* @param {ReadonlyMap<string, { file: string, line: number }>} citedAnchors
|
|
2563
|
+
* @returns {{ ok: true, value: { anchor_id: string, skipped: Array<{ anchor_id: string, file: string, line: number }> } }
|
|
2564
|
+
* | { ok: false, error: { kind: 'cited-numbers-exhausted', message: string } }}
|
|
2565
|
+
*/
|
|
2566
|
+
function mintAnchor(ledgerRows, type, citedAnchors) {
|
|
2567
|
+
const prefix = anchorPrefixFor(type);
|
|
2568
|
+
const first = highestAnchorNumber(ledgerRows, prefix) + 1;
|
|
2569
|
+
const skipped = [];
|
|
2570
|
+
for (let n = first; n <= first + E4_MAX_SKIPS; n++) {
|
|
2571
|
+
const anchorId = formatAnchorId(prefix, n);
|
|
2572
|
+
const citation = citedAnchors.get(anchorId);
|
|
2573
|
+
if (!citation) return { ok: true, value: { anchor_id: anchorId, skipped } };
|
|
2574
|
+
skipped.push({ anchor_id: anchorId, file: citation.file, line: citation.line });
|
|
2575
|
+
}
|
|
2576
|
+
const last = formatAnchorId(prefix, first + E4_MAX_SKIPS);
|
|
2577
|
+
return assignRefusal(
|
|
2578
|
+
'cited-numbers-exhausted',
|
|
2579
|
+
`${formatAnchorId(prefix, first)} to ${last} are all cited in tracked files; nothing was written`,
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/**
|
|
2584
|
+
* Promote a v2 observation to a new ledger entry of `type` — the assign-anchor op.
|
|
2585
|
+
*
|
|
2586
|
+
* D-E4-SKIP: assign-anchor scans the project's tracked files once, before it takes
|
|
2587
|
+
* the learning lock, for every anchor they cite as a whole word, and mints the
|
|
2588
|
+
* first number past the type's highest anchored number that no file cites; it
|
|
2589
|
+
* 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 learning tree nor
|
|
2591
|
+
* .git or a vendored or build directory (node_modules, target, dist), nor a
|
|
2592
|
+
* symbolic link, a binary file or a file over 5 MB. Reason: a document can cite a
|
|
2593
|
+
* number the ledger has not minted yet, and minting over it silently binds that
|
|
2594
|
+
* citation to an unrelated entry; refusing stopped the run until a person
|
|
2595
|
+
* renamed the citation, while a skipped number costs only a gap.
|
|
2596
|
+
*
|
|
2597
|
+
* The observation must be one v2 log row of `type` that no ledger row carries,
|
|
2598
|
+
* whatever its status (D-LEDGER-REGISTRY). The new row is its projection
|
|
2599
|
+
* (toLedgerRowV2) with the type's active status and today as both date and
|
|
2600
|
+
* last_verified; it is appended to the ledger and the files are re-rendered, all
|
|
2601
|
+
* under the learning lock. The log is never written. Like every writer it backs
|
|
2602
|
+
* up a v1 tree first (D-V1-BACKUP-ONCE) and quarantines the ledger's malformed
|
|
2603
|
+
* lines before it rewrites the ledger (D-QUARANTINE-MALFORMED); a refusal writes
|
|
2604
|
+
* nothing.
|
|
2605
|
+
*
|
|
2606
|
+
* @param {string} root - project root
|
|
2607
|
+
* @param {'decision'|'pitfall'} type
|
|
2608
|
+
* @param {string} obsId - an observation id
|
|
2609
|
+
* @param {{ now?: number, timeoutMs?: number, citedAnchors?: ReadonlyMap<string, { file: string, line: number }> }} [opts]
|
|
2610
|
+
* now: epoch ms (default Date.now()); citedAnchors: default collectCitedAnchorIds(root)
|
|
2611
|
+
* @returns {{ ok: true, value: { anchor_id: string, skipped: Array<{ anchor_id: string, file: string, line: number }> } }
|
|
2612
|
+
* | { ok: false, error: { kind: string, message: string } }}
|
|
2613
|
+
* Error kinds: not-in-log, duplicate-log-id, already-promoted, v1-observation,
|
|
2614
|
+
* type-mismatch, cited-numbers-exhausted, and withDecisionsLock's
|
|
2615
|
+
* not-a-directory, no-learning-dir and busy.
|
|
2616
|
+
* @throws {TypeError} for a type other than decision or pitfall, or a malformed obsId
|
|
2617
|
+
*/
|
|
2618
|
+
function assignAnchor(root, type, obsId, { now = Date.now(), timeoutMs, citedAnchors } = {}) {
|
|
2619
|
+
if (anchorPrefixFor(type) === null) throw new TypeError(`assignAnchor: type must be 'decision' or 'pitfall', got '${type}'`);
|
|
2620
|
+
if (typeof obsId !== 'string' || !OBS_ID_RE.test(obsId)) throw new TypeError('assignAnchor: obsId must be an observation id');
|
|
2621
|
+
if (!hasLearningDir(root)) return noLearningDir('assign-anchor', root);
|
|
2622
|
+
const cited = citedAnchors || collectCitedAnchorIds(root);
|
|
2623
|
+
return withDecisionsLock('assign-anchor', root, () => assignUnderLock(root, type, obsId, { now, cited }), { timeoutMs });
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
/** assignAnchor's locked body. */
|
|
2627
|
+
function assignUnderLock(root, type, obsId, { now, cited }) {
|
|
2628
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
2629
|
+
const ledger = readJsonl(ledgerPath);
|
|
2630
|
+
const log = readJsonl(getDecisionsLogPath(root));
|
|
2631
|
+
|
|
2632
|
+
const sameId = log.rows.filter(row => row.id === obsId);
|
|
2633
|
+
if (sameId.length === 0) {
|
|
2634
|
+
return assignRefusal('not-in-log', `'${obsId}' is not in the log; store it with put-observation --create first`);
|
|
2635
|
+
}
|
|
2636
|
+
if (sameId.length > 1) {
|
|
2637
|
+
return assignRefusal('duplicate-log-id', `the log holds ${sameId.length} rows with id '${obsId}'; nothing was written`);
|
|
2638
|
+
}
|
|
2639
|
+
const carriers = ledgerRegistry(ledger.rows).byObsId.get(obsId) || [];
|
|
2640
|
+
if (carriers.length > 0) {
|
|
2641
|
+
const entries = sortedByAnchor(carriers).map(row => `${listingToken(row.anchor_id)} ${listingToken(row.decisions_status)}`);
|
|
2642
|
+
return assignRefusal('already-promoted', `'${obsId}' is already promoted (${entries.join(', ')}); nothing was written`);
|
|
2643
|
+
}
|
|
2644
|
+
const [logRow] = sameId;
|
|
2645
|
+
if (!isV2(logRow)) {
|
|
2646
|
+
return assignRefusal('v1-observation', `'${obsId}' is a v1 observation; rewrite it with put-observation --update first`);
|
|
2647
|
+
}
|
|
2648
|
+
if (logRow.type !== type) {
|
|
2649
|
+
return assignRefusal('type-mismatch', `'${obsId}' is a ${listingToken(logRow.type)} observation, not a ${type}; nothing was written`);
|
|
2650
|
+
}
|
|
2651
|
+
const minted = mintAnchor(ledger.rows, type, cited);
|
|
2652
|
+
if (!minted.ok) return minted;
|
|
2653
|
+
|
|
2654
|
+
const today = isoDate(now);
|
|
2655
|
+
const row = toLedgerRowV2(logRow, { last_verified: today }, {
|
|
2656
|
+
anchorId: minted.value.anchor_id,
|
|
2657
|
+
status: activeStatusFor(type),
|
|
2658
|
+
date: today,
|
|
2659
|
+
expectType: type,
|
|
2660
|
+
});
|
|
2661
|
+
ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
|
|
2662
|
+
quarantineRejected(ledgerPath, ledger.rejected, { now });
|
|
2663
|
+
const ledgerRows = [...ledger.rows, row];
|
|
2664
|
+
writeJsonlAtomic(ledgerPath, ledgerRows);
|
|
2665
|
+
renderAll(root, ledgerRows);
|
|
2666
|
+
return minted;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// ---------------------------------------------------------------------------
|
|
2670
|
+
// refresh-anchor
|
|
2671
|
+
// ---------------------------------------------------------------------------
|
|
2672
|
+
|
|
2673
|
+
/**
|
|
2674
|
+
* The one ledger row carrying `anchorId`, or why there is not exactly one: the
|
|
2675
|
+
* anchor is `not in the ledger`, or `held by <n> ledger rows`.
|
|
2676
|
+
*
|
|
2677
|
+
* @param {object[]} ledgerRows
|
|
2678
|
+
* @param {string} anchorId
|
|
2679
|
+
* @returns {{ row: object } | { kind: 'not-found'|'duplicate-anchor', reason: string }}
|
|
2680
|
+
*/
|
|
2681
|
+
function rowCarrying(ledgerRows, anchorId) {
|
|
2682
|
+
const rows = ledgerRows.filter(row => row.anchor_id === anchorId);
|
|
2683
|
+
if (rows.length === 1) return { row: rows[0] };
|
|
2684
|
+
return rows.length === 0
|
|
2685
|
+
? { kind: 'not-found', reason: 'not in the ledger' }
|
|
2686
|
+
: { kind: 'duplicate-anchor', reason: `held by ${rows.length} ledger rows` };
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
/**
|
|
2690
|
+
* Why ledger row `row` is not an active v2 entry, or null: it is inactive, or v1.
|
|
2691
|
+
*
|
|
2692
|
+
* @param {object} row
|
|
2693
|
+
* @returns {string|null}
|
|
2694
|
+
*/
|
|
2695
|
+
function activeV2EntryProblem(row) {
|
|
2696
|
+
if (!isActive(row)) return `${listingToken(row.decisions_status)}; restore it with restore-anchor first`;
|
|
2697
|
+
if (!isV2(row)) return 'a v1 entry; rewrite it with put-observation --update';
|
|
2698
|
+
return null;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
/**
|
|
2702
|
+
* The log row active entry `row` re-projects from, or why it cannot: it has no
|
|
2703
|
+
* observation id, or the log holds no row, two rows or a v1 row with that id, or
|
|
2704
|
+
* one of a type the entry cannot take.
|
|
2705
|
+
*
|
|
2706
|
+
* @param {object} row - an active v2 ledger row
|
|
2707
|
+
* @param {object[]} logRows
|
|
2708
|
+
* @returns {{ logRow: object } | { problem: string }}
|
|
2709
|
+
*/
|
|
2710
|
+
function reprojectionSource(row, logRows) {
|
|
2711
|
+
if (!isNonEmptyString(row.id)) return { problem: 'has no observation id' };
|
|
2712
|
+
const sameId = logRows.filter(logRow => logRow.id === row.id);
|
|
2713
|
+
if (sameId.length === 0) return { problem: `no log row has id '${row.id}'; write it back with put-observation --create` };
|
|
2714
|
+
if (sameId.length > 1) return { problem: `the log holds ${sameId.length} rows with id '${row.id}'` };
|
|
2715
|
+
const [logRow] = sameId;
|
|
2716
|
+
if (!isV2(logRow)) return { problem: 'its log row is v1; rewrite it with put-observation --update' };
|
|
2717
|
+
const problem = reprojectionProblem(row, logRow.type);
|
|
2718
|
+
return problem ? { problem: `cannot take its log row: ${problem}` } : { logRow };
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
/** The refusal of a refresh batch: every refused anchor, one per line, each problem on one line. */
|
|
2722
|
+
function refreshRefusal(problems, total) {
|
|
2723
|
+
const listed = problems.map(({ anchor_id, message }) => ({ anchor_id, message: singleLine(message) }));
|
|
2724
|
+
const head = `refresh-anchor: ${listed.length} of ${total} anchor${total === 1 ? '' : 's'} refused; nothing was written`;
|
|
2725
|
+
const lines = listed.map(({ anchor_id, message }) => ` ${anchor_id}: ${message}`);
|
|
2726
|
+
return { ok: false, error: { kind: 'refused', message: [head, ...lines].join('\n'), problems: listed } };
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
/** True when two ledger rows differ in any projected content field. */
|
|
2730
|
+
function projectedContentDiffers(a, b) {
|
|
2731
|
+
return PROJECTED_CONTENT_KEYS.some(key => !sameJson(a[key], b[key]));
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
/**
|
|
2735
|
+
* Record in history, once per observation, the prior ledger rows and the log row
|
|
2736
|
+
* of each re-projection that changes an entry's content (D-CONTENT-HISTORY).
|
|
2737
|
+
*/
|
|
2738
|
+
function recordRefreshHistory(root, plans, ledgerRows, { now }) {
|
|
2739
|
+
const carriers = ledgerRegistry(ledgerRows).byObsId;
|
|
2740
|
+
const recorded = new Set();
|
|
2741
|
+
for (const { prior, next, logRow } of plans) {
|
|
2742
|
+
if (recorded.has(logRow.id) || !projectedContentDiffers(prior, next)) continue;
|
|
2743
|
+
recorded.add(logRow.id);
|
|
2744
|
+
appendHistory(root, { id: logRow.id, ledger: carriers.get(logRow.id) || [], log: logRow }, { now });
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/**
|
|
2749
|
+
* Re-project active v2 entries from their log rows, or stamp them verified — the
|
|
2750
|
+
* refresh-anchor op.
|
|
2751
|
+
*
|
|
2752
|
+
* Without `verified`, each entry is re-projected from its log row through
|
|
2753
|
+
* toLedgerRowV2 (D-LOG-CONTENT-AUTHORITY): it takes the log row's content
|
|
2754
|
+
* whatever the ledger held, so a rewrite replaces the old text in place, and the
|
|
2755
|
+
* prior ledger rows go to history first whenever an entry's content changes
|
|
2756
|
+
* (D-CONTENT-HISTORY). With `verified`, each entry's last_verified becomes today
|
|
2757
|
+
* and nothing else changes; no log row is needed.
|
|
2758
|
+
*
|
|
2759
|
+
* The batch is all or nothing: an anchor the ledger does not hold or holds twice,
|
|
2760
|
+
* an inactive entry or a v1 one, and — when re-projecting — an entry with no
|
|
2761
|
+
* observation id or whose log row is missing, doubled, v1 or of a type it cannot
|
|
2762
|
+
* take refuses the whole batch, every refused anchor listed, nothing written. A
|
|
2763
|
+
* batch that changes no row writes nothing. Otherwise, under the learning lock,
|
|
2764
|
+
* it backs up a v1 tree (D-V1-BACKUP-ONCE), quarantines the ledger's malformed
|
|
2765
|
+
* lines (D-QUARANTINE-MALFORMED), then writes the ledger once and renders once.
|
|
2766
|
+
*
|
|
2767
|
+
* @param {string} root - project root
|
|
2768
|
+
* @param {string[]} anchorIds - one or more anchor ids; a repeated one counts once
|
|
2769
|
+
* @param {{ verified?: boolean, now?: number, timeoutMs?: number }} [opts]
|
|
2770
|
+
* now: epoch ms (default Date.now())
|
|
2771
|
+
* @returns {{ ok: true, value: { refreshed: Array<{ anchor_id: string, state: 'verified'|'reprojected'|'unchanged' }> } }
|
|
2772
|
+
* | { ok: false, error: { kind: string, message: string, problems?: Array<{ anchor_id: string, message: string }> } }}
|
|
2773
|
+
* refreshed: each anchor once, in the order given. Error kinds: refused (with
|
|
2774
|
+
* problems), and withDecisionsLock's not-a-directory, no-learning-dir and busy.
|
|
2775
|
+
* @throws {TypeError} when anchorIds is empty or holds anything but anchor ids
|
|
2776
|
+
*/
|
|
2777
|
+
function refreshAnchors(root, anchorIds, { verified = false, now = Date.now(), timeoutMs } = {}) {
|
|
2778
|
+
const valid = Array.isArray(anchorIds) && anchorIds.length > 0
|
|
2779
|
+
&& anchorIds.every(id => typeof id === 'string' && ANCHOR_ID_RE.test(id));
|
|
2780
|
+
if (!valid) throw new TypeError('refreshAnchors: anchorIds must hold one or more anchor ids');
|
|
2781
|
+
const anchors = [...new Set(anchorIds)];
|
|
2782
|
+
return withDecisionsLock('refresh-anchor', root, () => refreshUnderLock(root, anchors, { verified, now }), { timeoutMs });
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
/** refreshAnchors' locked body. */
|
|
2786
|
+
function refreshUnderLock(root, anchors, { verified, now }) {
|
|
2787
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
2788
|
+
const ledger = readJsonl(ledgerPath);
|
|
2789
|
+
const log = readJsonl(getDecisionsLogPath(root));
|
|
2790
|
+
const today = isoDate(now);
|
|
2791
|
+
|
|
2792
|
+
const problems = [];
|
|
2793
|
+
const plans = [];
|
|
2794
|
+
for (const anchorId of anchors) {
|
|
2795
|
+
const found = rowCarrying(ledger.rows, anchorId);
|
|
2796
|
+
const entryProblem = found.row ? activeV2EntryProblem(found.row) : found.reason;
|
|
2797
|
+
if (entryProblem) {
|
|
2798
|
+
problems.push({ anchor_id: anchorId, message: entryProblem });
|
|
2799
|
+
continue;
|
|
2800
|
+
}
|
|
2801
|
+
const prior = found.row;
|
|
2802
|
+
if (verified) {
|
|
2803
|
+
plans.push({ anchor_id: anchorId, prior, next: withLedgerFields(prior, { last_verified: today }) });
|
|
2804
|
+
continue;
|
|
2805
|
+
}
|
|
2806
|
+
const source = reprojectionSource(prior, log.rows);
|
|
2807
|
+
if (source.problem) problems.push({ anchor_id: anchorId, message: source.problem });
|
|
2808
|
+
else plans.push({ anchor_id: anchorId, prior, next: reprojectedRow(source.logRow, prior), logRow: source.logRow });
|
|
2809
|
+
}
|
|
2810
|
+
if (problems.length > 0) return refreshRefusal(problems, anchors.length);
|
|
2811
|
+
|
|
2812
|
+
const changed = plans.filter(plan => !sameJson(plan.prior, plan.next));
|
|
2813
|
+
const stateOf = plan => {
|
|
2814
|
+
if (verified) return 'verified';
|
|
2815
|
+
return changed.includes(plan) ? 'reprojected' : 'unchanged';
|
|
2816
|
+
};
|
|
2817
|
+
const refreshed = plans.map(plan => ({ anchor_id: plan.anchor_id, state: stateOf(plan) }));
|
|
2818
|
+
if (changed.length === 0) return { ok: true, value: { refreshed } };
|
|
2819
|
+
|
|
2820
|
+
ensurePreV2Backup(root, { logRows: log.rows, ledgerRows: ledger.rows });
|
|
2821
|
+
quarantineRejected(ledgerPath, ledger.rejected, { now });
|
|
2822
|
+
if (!verified) recordRefreshHistory(root, changed, ledger.rows, { now });
|
|
2823
|
+
const replaced = new Map(changed.map(plan => [plan.prior, plan.next]));
|
|
2824
|
+
const ledgerRows = ledger.rows.map(row => replaced.get(row) || row);
|
|
2825
|
+
writeJsonlAtomic(ledgerPath, ledgerRows);
|
|
2826
|
+
renderAll(root, ledgerRows);
|
|
2827
|
+
return { ok: true, value: { refreshed } };
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// ---------------------------------------------------------------------------
|
|
2831
|
+
// retire-anchor and restore-anchor
|
|
2832
|
+
// ---------------------------------------------------------------------------
|
|
2833
|
+
|
|
2834
|
+
/** The stdin keys each inactive status takes. */
|
|
2835
|
+
const RETIRE_INPUT_KEYS = Object.freeze({
|
|
2836
|
+
Encoded: Object.freeze(['at', 'quote']),
|
|
2837
|
+
Superseded: Object.freeze(['by']),
|
|
2838
|
+
Retired: Object.freeze(['reason']),
|
|
2839
|
+
Deprecated: Object.freeze(['reason']),
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2842
|
+
/** The ledger-owned fields that say why an entry is inactive: a retirement sets one and retired_on; restore clears them. */
|
|
2843
|
+
const RETIREMENT_FIELDS = Object.freeze(['status_note', 'superseded_by', 'encoded_at', 'retired_on']);
|
|
2844
|
+
|
|
2845
|
+
/** `keys` mapped to undefined: the withLedgerFields updates that remove them. */
|
|
2846
|
+
function removing(keys) {
|
|
2847
|
+
return Object.fromEntries(keys.map(key => [key, undefined]));
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
/**
|
|
2851
|
+
* `row` with decisions_status `status` and the ledger-owned fields in `updates`
|
|
2852
|
+
* (withLedgerFields). A status key the row has keeps its place; a row without one
|
|
2853
|
+
* takes it after anchor_id, where the projection puts it.
|
|
2854
|
+
*
|
|
2855
|
+
* @param {object} row - a ledger row that has an anchor_id: its caller found it by anchor
|
|
2856
|
+
* @param {string} status
|
|
2857
|
+
* @param {Record<string, unknown>} updates - ledger-owned fields only
|
|
2858
|
+
* @returns {object} a new row
|
|
2859
|
+
*/
|
|
2860
|
+
function withEntryStatus(row, status, updates) {
|
|
2861
|
+
const next = withLedgerFields(row, updates);
|
|
2862
|
+
if (Object.prototype.hasOwnProperty.call(next, 'decisions_status')) {
|
|
2863
|
+
next.decisions_status = status;
|
|
2864
|
+
return next;
|
|
2865
|
+
}
|
|
2866
|
+
const placed = {};
|
|
2867
|
+
for (const [key, value] of Object.entries(next)) {
|
|
2868
|
+
placed[key] = value;
|
|
2869
|
+
if (key === 'anchor_id') placed.decisions_status = status;
|
|
2870
|
+
}
|
|
2871
|
+
return placed;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/** The problem with an Encoded path, or null: it names a file from the repository root. */
|
|
2875
|
+
function encodedPathProblem(at) {
|
|
2876
|
+
if (at.startsWith('/')) return 'must be relative to the repository root';
|
|
2877
|
+
if (at.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
|
|
2878
|
+
return 'must not hold an empty, . or .. segment';
|
|
2879
|
+
}
|
|
2880
|
+
return null;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/** The problem with an Encoded quote too short once its whitespace collapses, or null. */
|
|
2884
|
+
function quoteLengthProblem(quote) {
|
|
2885
|
+
const length = codePointLength(normalizeWhitespace(quote));
|
|
2886
|
+
return length < FIELD_LIMITS.quoteMin
|
|
2887
|
+
? `is ${length} characters once its whitespace is collapsed, under the minimum of ${FIELD_LIMITS.quoteMin}`
|
|
2888
|
+
: null;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
/** The problem with a Superseded successor's shape, or null. */
|
|
2892
|
+
function successorShapeProblem(anchorId, by) {
|
|
2893
|
+
if (typeof by !== 'string' || !ANCHOR_ID_RE.test(by)) return 'must be an anchor id';
|
|
2894
|
+
if (by === anchorId) return 'names this entry; an entry cannot supersede itself';
|
|
2895
|
+
return null;
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
/**
|
|
2899
|
+
* Every problem with a retire-anchor input for `status`: the keys it does not
|
|
2900
|
+
* take, in input order, then its own fields, each with at most one problem.
|
|
2901
|
+
*
|
|
2902
|
+
* @param {string} anchorId - the entry being retired
|
|
2903
|
+
* @param {string} status - an inactive status
|
|
2904
|
+
* @param {unknown} input - the parsed stdin object
|
|
2905
|
+
* @returns {Array<{ field: string, message: string }>}
|
|
2906
|
+
*/
|
|
2907
|
+
function retireInputProblems(anchorId, status, input) {
|
|
2908
|
+
if (!isPlainObject(input)) return [{ field: '(input)', message: 'must be one JSON object' }];
|
|
2909
|
+
const accepted = RETIRE_INPUT_KEYS[status];
|
|
2910
|
+
const problems = Object.keys(input)
|
|
2911
|
+
.filter(key => !accepted.includes(key))
|
|
2912
|
+
.map(key => ({ field: key, message: `is not taken by ${status}, which takes ${accepted.join(' and ')}` }));
|
|
2913
|
+
const check = (field, problem) => {
|
|
2914
|
+
if (problem) problems.push({ field, message: problem });
|
|
2915
|
+
};
|
|
2916
|
+
const absent = key => (input[key] === undefined ? 'is required' : null);
|
|
2917
|
+
if (status === 'Encoded') {
|
|
2918
|
+
check('at', absent('at') || textProblem(input.at, FIELD_LIMITS.path) || encodedPathProblem(input.at));
|
|
2919
|
+
check('quote', absent('quote') || textProblem(input.quote, FIELD_LIMITS.quoteMax) || quoteLengthProblem(input.quote));
|
|
2920
|
+
} else if (status === 'Superseded') {
|
|
2921
|
+
check('by', absent('by') || successorShapeProblem(anchorId, input.by));
|
|
2922
|
+
} else {
|
|
2923
|
+
check('reason', absent('reason') || textProblem(input.reason, FIELD_LIMITS.note));
|
|
2924
|
+
}
|
|
2925
|
+
return problems;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
/** A retire-anchor refusal: `message` follows the op name. */
|
|
2929
|
+
function retireRefusal(kind, message) {
|
|
2930
|
+
return { ok: false, error: { kind, message: `retire-anchor: ${message}` } };
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/** A restore-anchor refusal: `message` follows the op name. */
|
|
2934
|
+
function restoreRefusal(kind, message) {
|
|
2935
|
+
return { ok: false, error: { kind, message: `restore-anchor: ${message}` } };
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/**
|
|
2939
|
+
* Check that `quote` appears in file `at` as committed at the verify ref, and
|
|
2940
|
+
* answer the encoded_at record retire-anchor keeps for it.
|
|
2941
|
+
*
|
|
2942
|
+
* D-ENCODED-QUOTE: an entry is retired as Encoded only with a path and a quote
|
|
2943
|
+
* from that file, and only when the quote, every run of whitespace collapsed to
|
|
2944
|
+
* one space on both sides, appears in the file as committed at the verify ref
|
|
2945
|
+
* (D-VERIFY-REF), read with `git cat-file blob <commit>:<path>`; the entry keeps
|
|
2946
|
+
* the path, the quote, the ref and the commit as encoded_at. Reason: a citation is
|
|
2947
|
+
* a claim that nothing else checks — a path alone can point anywhere — so only a
|
|
2948
|
+
* quote found at a ref every checkout shares shows that the lesson now lives in
|
|
2949
|
+
* that file, and the commit lets a later run look at what was checked.
|
|
2950
|
+
*
|
|
2951
|
+
* @param {string} root - project root
|
|
2952
|
+
* @param {string} at - a path from the repository root
|
|
2953
|
+
* @param {string} quote
|
|
2954
|
+
* @param {{ verifyRef?: { ref: 'origin/HEAD'|'HEAD', commit: string } | null }} [opts]
|
|
2955
|
+
* verifyRef: default resolveVerifyRef(root); null when there is no commit to check
|
|
2956
|
+
* @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 } }}
|
|
2958
|
+
*/
|
|
2959
|
+
function quoteAtRef(root, at, quote, { verifyRef } = {}) {
|
|
2960
|
+
const checkedAt = verifyRef === undefined ? resolveVerifyRef(root) : verifyRef;
|
|
2961
|
+
if (checkedAt === null) {
|
|
2962
|
+
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');
|
|
2963
|
+
}
|
|
2964
|
+
const where = `${checkedAt.ref} ${checkedAt.commit.slice(0, 12)}`;
|
|
2965
|
+
const shown = singleLine(at);
|
|
2966
|
+
let blob;
|
|
2967
|
+
try {
|
|
2968
|
+
blob = git(root, ['cat-file', 'blob', `${checkedAt.commit}:${at}`]);
|
|
2969
|
+
} catch (err) {
|
|
2970
|
+
if (err && typeof err.status === 'number') {
|
|
2971
|
+
return retireRefusal('not-at-ref', `'${shown}' is not a file at ${where}; nothing was written`);
|
|
2972
|
+
}
|
|
2973
|
+
return retireRefusal('git-failed', `could not read '${shown}' at ${where}: ${singleLine(String(err && err.message))}; nothing was written`);
|
|
2974
|
+
}
|
|
2975
|
+
if (!normalizeWhitespace(blob).includes(normalizeWhitespace(quote))) {
|
|
2976
|
+
return retireRefusal('quote-not-found', `the quote is not in '${shown}' at ${where}; nothing was written`);
|
|
2977
|
+
}
|
|
2978
|
+
return { ok: true, value: { path: at, quote, ref: checkedAt.ref, commit: checkedAt.commit } };
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
/**
|
|
2982
|
+
* Make an active entry inactive — the retire-anchor op — with the stdin its new
|
|
2983
|
+
* status takes, every problem with that input reported at once:
|
|
2984
|
+
* Retired, Deprecated { reason } at most 120 characters, kept as status_note
|
|
2985
|
+
* Superseded { by } an active entry other than this one, of
|
|
2986
|
+
* either type, kept as superseded_by; every
|
|
2987
|
+
* inactive entry this one superseded is
|
|
2988
|
+
* re-pointed to it
|
|
2989
|
+
* Encoded { at, quote } checked at the verify ref and kept as
|
|
2990
|
+
* encoded_at (D-ENCODED-QUOTE)
|
|
2991
|
+
* Each also sets retired_on to today and clears the other notes; the rest of the
|
|
2992
|
+
* row stays as it is, so a v1 entry stays v1. An entry already inactive, an
|
|
2993
|
+
* 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 learning
|
|
2995
|
+
* lock is taken; under it the ledger is written once and the files are rendered,
|
|
2996
|
+
* after a v1 tree is backed up (D-V1-BACKUP-ONCE) and malformed ledger lines are
|
|
2997
|
+
* quarantined (D-QUARANTINE-MALFORMED). A refusal writes nothing.
|
|
2998
|
+
*
|
|
2999
|
+
* @param {string} root - project root
|
|
3000
|
+
* @param {string} anchorId
|
|
3001
|
+
* @param {'Encoded'|'Superseded'|'Retired'|'Deprecated'} status
|
|
3002
|
+
* @param {unknown} input - the parsed stdin object
|
|
3003
|
+
* @param {{ now?: number, timeoutMs?: number, verifyRef?: { ref: 'origin/HEAD'|'HEAD', commit: string } | null }} [opts]
|
|
3004
|
+
* now: epoch ms (default Date.now()); verifyRef: see quoteAtRef
|
|
3005
|
+
* @returns {{ ok: true, value: { anchor_id: string, status: string, repointed: string[] } }
|
|
3006
|
+
* | { ok: false, error: { kind: string, message: string, problems?: Array<{ field: string, message: string }> } }}
|
|
3007
|
+
* repointed: the entries re-pointed to the successor, in anchor order. Error
|
|
3008
|
+
* kinds: invalid-input (with problems), quoteAtRef's, not-found,
|
|
3009
|
+
* duplicate-anchor, already-inactive, successor-not-found,
|
|
3010
|
+
* successor-duplicate-anchor, successor-inactive, and withDecisionsLock's
|
|
3011
|
+
* not-a-directory, no-learning-dir and busy.
|
|
3012
|
+
* @throws {TypeError} for a malformed anchorId or a status that is not inactive
|
|
3013
|
+
*/
|
|
3014
|
+
function retireAnchor(root, anchorId, status, input, { now = Date.now(), timeoutMs, verifyRef } = {}) {
|
|
3015
|
+
if (typeof anchorId !== 'string' || !ANCHOR_ID_RE.test(anchorId)) throw new TypeError('retireAnchor: anchorId must be an anchor id');
|
|
3016
|
+
if (!INACTIVE_STATUSES.includes(status)) {
|
|
3017
|
+
throw new TypeError(`retireAnchor: status must be one of ${INACTIVE_STATUSES.join(', ')}, got '${status}'`);
|
|
3018
|
+
}
|
|
3019
|
+
if (!hasLearningDir(root)) return noLearningDir('retire-anchor', root);
|
|
3020
|
+
const problems = retireInputProblems(anchorId, status, input);
|
|
3021
|
+
if (problems.length > 0) return invalidInput('retire-anchor', problems);
|
|
3022
|
+
let note;
|
|
3023
|
+
if (status === 'Encoded') {
|
|
3024
|
+
const encoded = quoteAtRef(root, input.at, input.quote, { verifyRef });
|
|
3025
|
+
if (!encoded.ok) return encoded;
|
|
3026
|
+
note = { encoded_at: encoded.value };
|
|
3027
|
+
} else if (status === 'Superseded') {
|
|
3028
|
+
note = { superseded_by: input.by };
|
|
3029
|
+
} else {
|
|
3030
|
+
note = { status_note: input.reason };
|
|
3031
|
+
}
|
|
3032
|
+
return withDecisionsLock('retire-anchor', root, () => retireUnderLock(root, anchorId, status, note, { now }), { timeoutMs });
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
/** retireAnchor's locked body. */
|
|
3036
|
+
function retireUnderLock(root, anchorId, status, note, { now }) {
|
|
3037
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
3038
|
+
const ledger = readJsonl(ledgerPath);
|
|
3039
|
+
const found = rowCarrying(ledger.rows, anchorId);
|
|
3040
|
+
if (!found.row) return retireRefusal(found.kind, `${anchorId} is ${found.reason}; nothing was written`);
|
|
3041
|
+
const prior = found.row;
|
|
3042
|
+
if (!isActive(prior)) {
|
|
3043
|
+
return retireRefusal('already-inactive', `${anchorId} is already ${listingToken(prior.decisions_status)}; nothing was written`);
|
|
3044
|
+
}
|
|
3045
|
+
if (status === 'Superseded') {
|
|
3046
|
+
const successor = rowCarrying(ledger.rows, note.superseded_by);
|
|
3047
|
+
if (!successor.row) {
|
|
3048
|
+
return retireRefusal(`successor-${successor.kind}`, `${note.superseded_by}, the successor, is ${successor.reason}; nothing was written`);
|
|
3049
|
+
}
|
|
3050
|
+
if (!isActive(successor.row)) {
|
|
3051
|
+
return retireRefusal(
|
|
3052
|
+
'successor-inactive',
|
|
3053
|
+
`${note.superseded_by}, the successor, is ${listingToken(successor.row.decisions_status)}; nothing was written`,
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
const retired = withEntryStatus(prior, status, { ...removing(RETIREMENT_FIELDS), ...note, retired_on: isoDate(now) });
|
|
3059
|
+
const repointed = status === 'Superseded'
|
|
3060
|
+
? sortedByAnchor(ledger.rows.filter(row => row !== prior && !isActive(row) && row.superseded_by === anchorId))
|
|
3061
|
+
: [];
|
|
3062
|
+
const replaced = new Map([
|
|
3063
|
+
[prior, retired],
|
|
3064
|
+
...repointed.map(row => [row, withLedgerFields(row, { superseded_by: note.superseded_by })]),
|
|
3065
|
+
]);
|
|
3066
|
+
ensurePreV2Backup(root, { logRows: readJsonl(getDecisionsLogPath(root)).rows, ledgerRows: ledger.rows });
|
|
3067
|
+
quarantineRejected(ledgerPath, ledger.rejected, { now });
|
|
3068
|
+
const ledgerRows = ledger.rows.map(row => replaced.get(row) || row);
|
|
3069
|
+
writeJsonlAtomic(ledgerPath, ledgerRows);
|
|
3070
|
+
renderAll(root, ledgerRows);
|
|
3071
|
+
return { ok: true, value: { anchor_id: anchorId, status, repointed: repointed.map(row => row.anchor_id) } };
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
/**
|
|
3075
|
+
* Make an inactive entry active again — the restore-anchor op. The entry takes
|
|
3076
|
+
* its type's active status (Accepted for a decision, Active for a pitfall) and
|
|
3077
|
+
* loses its retirement notes, its last_verified and its last_attempt, so the next
|
|
3078
|
+
* claim-due hands it out ahead of every verified entry (D-DUE-ORDER); its content
|
|
3079
|
+
* and its date stay, and the files are re-rendered. An entry already active, an
|
|
3080
|
+
* 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 learning
|
|
3082
|
+
* lock; a refusal writes nothing.
|
|
3083
|
+
*
|
|
3084
|
+
* @param {string} root - project root
|
|
3085
|
+
* @param {string} anchorId
|
|
3086
|
+
* @param {{ now?: number, timeoutMs?: number }} [opts] - now: epoch ms (default Date.now())
|
|
3087
|
+
* @returns {{ ok: true, value: { anchor_id: string, status: 'Accepted'|'Active' } }
|
|
3088
|
+
* | { ok: false, error: { kind: string, message: string } }}
|
|
3089
|
+
* Error kinds: not-found, duplicate-anchor, already-active, type-mismatch, and
|
|
3090
|
+
* withDecisionsLock's not-a-directory, no-learning-dir and busy.
|
|
3091
|
+
* @throws {TypeError} for a malformed anchorId
|
|
3092
|
+
*/
|
|
3093
|
+
function restoreAnchor(root, anchorId, { now = Date.now(), timeoutMs } = {}) {
|
|
3094
|
+
if (typeof anchorId !== 'string' || !ANCHOR_ID_RE.test(anchorId)) throw new TypeError('restoreAnchor: anchorId must be an anchor id');
|
|
3095
|
+
return withDecisionsLock('restore-anchor', root, () => restoreUnderLock(root, anchorId, { now }), { timeoutMs });
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
/** restoreAnchor's locked body. */
|
|
3099
|
+
function restoreUnderLock(root, anchorId, { now }) {
|
|
3100
|
+
const ledgerPath = getDecisionsLedgerPath(root);
|
|
3101
|
+
const ledger = readJsonl(ledgerPath);
|
|
3102
|
+
const found = rowCarrying(ledger.rows, anchorId);
|
|
3103
|
+
if (!found.row) return restoreRefusal(found.kind, `${anchorId} is ${found.reason}; nothing was written`);
|
|
3104
|
+
const prior = found.row;
|
|
3105
|
+
if (isActive(prior)) return restoreRefusal('already-active', `${anchorId} is already active; nothing was written`);
|
|
3106
|
+
const prefix = anchorPrefixFor(prior.type);
|
|
3107
|
+
if (prefix === null || !anchorId.startsWith(`${prefix}-`)) {
|
|
3108
|
+
return restoreRefusal(
|
|
3109
|
+
'type-mismatch',
|
|
3110
|
+
`${anchorId} holds a row of type ${listingToken(prior.type)}, which its anchor does not name; nothing was written`,
|
|
3111
|
+
);
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
const status = activeStatusFor(prior.type);
|
|
3115
|
+
const restored = withEntryStatus(prior, status, removing([...RETIREMENT_FIELDS, 'last_verified', 'last_attempt']));
|
|
3116
|
+
ensurePreV2Backup(root, { logRows: readJsonl(getDecisionsLogPath(root)).rows, ledgerRows: ledger.rows });
|
|
3117
|
+
quarantineRejected(ledgerPath, ledger.rejected, { now });
|
|
3118
|
+
const ledgerRows = ledger.rows.map(row => (row === prior ? restored : row));
|
|
3119
|
+
writeJsonlAtomic(ledgerPath, ledgerRows);
|
|
3120
|
+
renderAll(root, ledgerRows);
|
|
3121
|
+
return { ok: true, value: { anchor_id: anchorId, status } };
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
module.exports = {
|
|
3125
|
+
// Constants
|
|
3126
|
+
SCHEMA_VERSION,
|
|
3127
|
+
FIELD_LIMITS,
|
|
3128
|
+
ACTIVE_STATUSES,
|
|
3129
|
+
INACTIVE_STATUSES,
|
|
3130
|
+
ENTRY_STATUSES,
|
|
3131
|
+
CONTENT_KEYS,
|
|
3132
|
+
PLUMBING_OWNED_KEYS,
|
|
3133
|
+
LEDGER_OWNED_KEYS,
|
|
3134
|
+
DUE,
|
|
3135
|
+
HISTORY_DEPTH,
|
|
3136
|
+
LOCK_ACQUIRE_TIMEOUT_MS,
|
|
3137
|
+
LOCK_STALE_MS,
|
|
3138
|
+
ANCHOR_ID_RE,
|
|
3139
|
+
OBS_ID_RE,
|
|
3140
|
+
// Status helpers
|
|
3141
|
+
isActive,
|
|
3142
|
+
activeStatusFor,
|
|
3143
|
+
isV2,
|
|
3144
|
+
// Text shared with the renderer
|
|
3145
|
+
singleLine,
|
|
3146
|
+
inactiveNote,
|
|
3147
|
+
// JSONL I/O
|
|
3148
|
+
readJsonl,
|
|
3149
|
+
rejectedPathFor,
|
|
3150
|
+
quarantineRejected,
|
|
3151
|
+
readJsonlForWrite,
|
|
3152
|
+
writeExclusive,
|
|
3153
|
+
writeFileAtomic,
|
|
3154
|
+
writeJsonlAtomic,
|
|
3155
|
+
// Locking
|
|
3156
|
+
hasLearningDir,
|
|
3157
|
+
withDecisionsLock,
|
|
3158
|
+
// State and the ledger registry
|
|
3159
|
+
readLearningState,
|
|
3160
|
+
ledgerRegistry,
|
|
3161
|
+
// Validation
|
|
3162
|
+
validateObservationInput,
|
|
3163
|
+
gitScopeMatcher,
|
|
3164
|
+
// Projection and counters
|
|
3165
|
+
toLedgerRowV2,
|
|
3166
|
+
toV2Counters,
|
|
3167
|
+
// History and the pre-v2 backup
|
|
3168
|
+
appendHistory,
|
|
3169
|
+
historyVersions,
|
|
3170
|
+
ensurePreV2Backup,
|
|
3171
|
+
// Rotation, clearing and resetting
|
|
3172
|
+
rotateObservations,
|
|
3173
|
+
clearUnreferenced,
|
|
3174
|
+
resetLearning,
|
|
3175
|
+
// The queue claim
|
|
3176
|
+
CLAIM_STALE_SECS,
|
|
3177
|
+
CLAIM_TOKEN_RE,
|
|
3178
|
+
CLAIM_OWNER_MAX_BYTES,
|
|
3179
|
+
newClaimToken,
|
|
3180
|
+
claimQueue,
|
|
3181
|
+
releaseClaim,
|
|
3182
|
+
touchClaim,
|
|
3183
|
+
// Integrity, listing, due selection and show
|
|
3184
|
+
integrityFlags,
|
|
3185
|
+
buildListing,
|
|
3186
|
+
entrySize,
|
|
3187
|
+
selectDue,
|
|
3188
|
+
showEntry,
|
|
3189
|
+
resolveVerifyRef,
|
|
3190
|
+
// Entry ops
|
|
3191
|
+
putObservation,
|
|
3192
|
+
readListing,
|
|
3193
|
+
formatListing,
|
|
3194
|
+
showByKey,
|
|
3195
|
+
claimDue,
|
|
3196
|
+
// assign-anchor
|
|
3197
|
+
E4_MAX_SKIPS,
|
|
3198
|
+
nextAnchorFromLedger,
|
|
3199
|
+
collectCitedAnchorIds,
|
|
3200
|
+
assignAnchor,
|
|
3201
|
+
// refresh-anchor
|
|
3202
|
+
refreshAnchors,
|
|
3203
|
+
// retire-anchor and restore-anchor
|
|
3204
|
+
quoteAtRef,
|
|
3205
|
+
retireAnchor,
|
|
3206
|
+
restoreAnchor,
|
|
3207
|
+
};
|