@stravica-ai/digital-operator-pa 0.1.62 → 0.1.64
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/dist/engineering-pa-cli/README.md +7 -5
- package/dist/engineering-pa-cli/src/launch/README.md +2 -0
- package/dist/engineering-pa-cli/src/launch/runner.mjs +4 -3
- package/dist/engineering-pa-cli/src/launch/task-loader.mjs +37 -3
- package/dist/engineering-pa-cli/src/scheduler/cli.mjs +12 -2
- package/dist/engineering-pa-core/docs/tools/scheduler.md +3 -3
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +88 -24
- package/dist/engineering-pa-core/src/knowledge/retrieval/mount-map.mjs +169 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/mounts.mjs +62 -9
- package/dist/engineering-pa-core/src/scheduler/install.mjs +65 -3
- package/dist/engineering-pa-core/src/scheduler/list.mjs +2 -4
- package/dist/engineering-pa-core/src/scheduler/remove.mjs +2 -2
- package/dist/engineering-pa-core/src/scheduler/verify.mjs +65 -11
- package/dist/engineering-pa-library-cli/README.md +35 -8
- package/dist/engineering-pa-library-cli/src/cli.mjs +25 -7
- package/dist/engineering-pa-library-cli/src/envelope.mjs +14 -3
- package/dist/engineering-pa-library-cli/src/verbs/put.mjs +97 -14
- package/package.json +1 -1
|
@@ -137,11 +137,13 @@ roots, else the clone's own. For every mount whose `to` lies under
|
|
|
137
137
|
`operator/knowledge/writing/`. The legacy `<clone>/operator/knowledge/`
|
|
138
138
|
form is still read during the path migration. On a colliding id the home
|
|
139
139
|
copy wins, then the mapped copy (higher registry `precedence` first), then
|
|
140
|
-
the legacy copy. Within one clone that holds both forms of an id, the
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
the legacy copy. Within one clone that holds both forms of an id, the copy
|
|
141
|
+
whose last commit in that clone is newer wins (`git log -1 --format=%ct`,
|
|
142
|
+
read only on such a collision), until the migration removes the legacy
|
|
143
|
+
copies. On a tie, or when either time is unavailable (the clone is not a
|
|
144
|
+
git repo, the file is untracked, git fails), the legacy copy wins. Commit
|
|
145
|
+
times, not file mtimes, because a clone's mtimes are checkout times. A
|
|
146
|
+
clone with no registry entry is read in the legacy form only.
|
|
145
147
|
|
|
146
148
|
Global flag: `--format <table|json|mermaid>` (default: `table`). The
|
|
147
149
|
`json` shape is stable-by-convention — `{ "verb": "<name>", ... }` —
|
|
@@ -82,6 +82,8 @@ The runner spawns the command **directly** — no shell, no expansion, no operat
|
|
|
82
82
|
|
|
83
83
|
If the command contains `'` or `"`, `task-loader` rejects it at load time with exit code 2 (a `--dry-run` also fails). This is a deliberate guard: silently splitting `bash -c "a && b"` on whitespace shreds it to `spawn('bash', ['-c', '"a', '&&', 'b"'])`, bash exits 2 with `unexpected EOF`, and cron mails the operator every interval until someone notices.
|
|
84
84
|
|
|
85
|
+
Shell operators are refused the same way, at load time and in the runner: a command with `;`, `&&`, `||`, `|`, `>`, `<`, `$(`, a backtick or `&` exits 2 with a message naming the schedule id and the offending token. Without a shell the operator would reach the program as a literal argument (`pnpm exec estate-sync run; node ...` handed estate-sync the subcommand `run;`).
|
|
86
|
+
|
|
85
87
|
If you need shell features, you have two options:
|
|
86
88
|
|
|
87
89
|
1. **Wrap the work in a script file** and put the script path in `command`: `command: "/path/to/wrapper.sh"`. The runner spawns the script; the script does whatever it likes.
|
|
@@ -43,6 +43,7 @@ function resolveWorkdir(workdir, repoRoot) {
|
|
|
43
43
|
* expressions. The string is split on whitespace and passed to spawn()
|
|
44
44
|
* WITHOUT a shell — no quoting, globbing, expansion, or operators are
|
|
45
45
|
* honoured. Any single (') or double (") quote character in the command
|
|
46
|
+
* or shell operator (; && || | > < $( ` &)
|
|
46
47
|
* is a configuration error: rejected via assertNoShellQuoting with
|
|
47
48
|
* exitCode 2 (task-loader also asserts this at load time so --dry-run
|
|
48
49
|
* catches it; the check here is the chokepoint guarantee for any caller
|
|
@@ -58,8 +59,8 @@ function resolveWorkdir(workdir, repoRoot) {
|
|
|
58
59
|
* @returns {{ bin: string, args: string[] }}
|
|
59
60
|
* @throws {Error & { exitCode: 2 }} on quoted or empty commands
|
|
60
61
|
*/
|
|
61
|
-
function parseCommand(command) {
|
|
62
|
-
assertNoShellQuoting(command);
|
|
62
|
+
function parseCommand(command, scheduleId) {
|
|
63
|
+
assertNoShellQuoting(command, scheduleId);
|
|
63
64
|
const parts = command.trim().split(/\s+/);
|
|
64
65
|
if (!parts[0]) {
|
|
65
66
|
const e = new Error('command is empty');
|
|
@@ -70,7 +71,7 @@ function parseCommand(command) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
async function defaultSpawner(plan) {
|
|
73
|
-
const { bin, args } = parseCommand(plan.task.command);
|
|
74
|
+
const { bin, args } = parseCommand(plan.task.command, plan.task.id);
|
|
74
75
|
const cwd = resolveWorkdir(plan.task.workingDirectory, plan.repoRoot);
|
|
75
76
|
return new Promise((resolve, reject) => {
|
|
76
77
|
const child = spawn(bin, args, { cwd, stdio: 'inherit' });
|
|
@@ -16,11 +16,17 @@ const REQUIRED_FIELDS = ['id', 'command'];
|
|
|
16
16
|
* re-asserted by runner.parseCommand as a chokepoint guarantee for
|
|
17
17
|
* programmatic callers.
|
|
18
18
|
*
|
|
19
|
+
* Shell operators (`;`, `&&`, `||`, `|`, `>`, `<`, `$(`, backticks, `&`)
|
|
20
|
+
* are refused for the same reason: without a shell they reach the program
|
|
21
|
+
* as literal args (`pnpm exec estate-sync run; node ...` handed
|
|
22
|
+
* estate-sync the subcommand `run;` and failed 34 runs in a row).
|
|
23
|
+
*
|
|
19
24
|
* @param {string} command
|
|
25
|
+
* @param {string} [scheduleId] named in the operator refusal message
|
|
20
26
|
* @throws {Error & { exitCode: 2 }} if command is not a non-empty string,
|
|
21
|
-
* or contains a single (') or double (") quote character
|
|
27
|
+
* or contains a single (') or double (") quote character, or a shell operator
|
|
22
28
|
*/
|
|
23
|
-
export function assertNoShellQuoting(command) {
|
|
29
|
+
export function assertNoShellQuoting(command, scheduleId) {
|
|
24
30
|
if (typeof command !== 'string') {
|
|
25
31
|
const e = new Error('command must be a string');
|
|
26
32
|
e.exitCode = 2;
|
|
@@ -38,6 +44,34 @@ export function assertNoShellQuoting(command) {
|
|
|
38
44
|
e.exitCode = 2;
|
|
39
45
|
throw e;
|
|
40
46
|
}
|
|
47
|
+
const op = findShellOperator(command);
|
|
48
|
+
if (op) {
|
|
49
|
+
const where = scheduleId ? `schedule "${scheduleId}"` : 'task command';
|
|
50
|
+
const e = new Error(
|
|
51
|
+
`${where} contains shell operator ${JSON.stringify(op.operator)} in token ` +
|
|
52
|
+
`${JSON.stringify(op.token)}: launch runs commands without a shell, so the operator ` +
|
|
53
|
+
'would reach the program as a literal argument. Wrap the chain in a script file ' +
|
|
54
|
+
'and invoke the script, or split it into separate schedules.'
|
|
55
|
+
);
|
|
56
|
+
e.exitCode = 2;
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Multi-char operators first so `&&` is reported as `&&`, not `&`.
|
|
62
|
+
const SHELL_OPERATORS = ['$(', '&&', '||', ';', '|', '>', '<', '`', '&'];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} command
|
|
66
|
+
* @returns {{ operator: string, token: string } | null}
|
|
67
|
+
*/
|
|
68
|
+
export function findShellOperator(command) {
|
|
69
|
+
for (const token of command.trim().split(/\s+/)) {
|
|
70
|
+
for (const operator of SHELL_OPERATORS) {
|
|
71
|
+
if (token.includes(operator)) return { operator, token };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
41
75
|
}
|
|
42
76
|
|
|
43
77
|
export async function loadTask(repoRoot, taskId) {
|
|
@@ -74,7 +108,7 @@ export async function loadTask(repoRoot, taskId) {
|
|
|
74
108
|
}
|
|
75
109
|
|
|
76
110
|
try {
|
|
77
|
-
assertNoShellQuoting(data.command);
|
|
111
|
+
assertNoShellQuoting(data.command, data.id ?? taskId);
|
|
78
112
|
} catch (err) {
|
|
79
113
|
const e = new Error(`task "${taskId}": ${err.message}`);
|
|
80
114
|
e.exitCode = err.exitCode ?? 2;
|
|
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
|
|
|
16
16
|
// rewrites this string to a relative path inside the single-bundled
|
|
17
17
|
// tarball.
|
|
18
18
|
import { detect, install, list, remove, verify } from '../../../engineering-pa-core/src/scheduler/index.mjs';
|
|
19
|
+
import { parseTimeoutSeconds } from '../../../engineering-pa-core/src/scheduler/verify.mjs';
|
|
19
20
|
|
|
20
21
|
const USAGE = `scheduler — operator-facing scheduling primitives
|
|
21
22
|
|
|
@@ -32,7 +33,9 @@ Usage:
|
|
|
32
33
|
field wins (OS-native detect is the fallback).
|
|
33
34
|
The label form the list verb prints as a
|
|
34
35
|
secondary line is accepted here directly.
|
|
35
|
-
scheduler verify <schedule.json>
|
|
36
|
+
scheduler verify <schedule.json> [--backend N] [--timeout <seconds>]
|
|
37
|
+
Verify a schedule fires correctly.
|
|
38
|
+
--timeout is in seconds (default 120).
|
|
36
39
|
scheduler -h, --help Show this help
|
|
37
40
|
|
|
38
41
|
Schedule files live at <repoRoot>/schedules/<id>.json. The canonical
|
|
@@ -189,7 +192,14 @@ export async function run(argv) {
|
|
|
189
192
|
const schedulePath = resolve(positionals[0]);
|
|
190
193
|
const opts = {};
|
|
191
194
|
if (values.backend) opts.backend = values.backend;
|
|
192
|
-
if (values.timeout
|
|
195
|
+
if (values.timeout !== undefined) {
|
|
196
|
+
const ms = parseTimeoutSeconds(values.timeout);
|
|
197
|
+
if (ms === null) {
|
|
198
|
+
process.stderr.write(`[scheduler] verify: --timeout requires a positive number of seconds, got: ${values.timeout}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
opts.timeoutMs = ms;
|
|
202
|
+
}
|
|
193
203
|
const result = await verify(schedulePath, opts);
|
|
194
204
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
195
205
|
return result.exitCode ?? (result.ok ? 0 : 2);
|
|
@@ -110,9 +110,9 @@ Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process pro
|
|
|
110
110
|
|
|
111
111
|
The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c '{ date -u; . $HOME/.nvm/nvm.sh && cd <workDir> && <command>; } >> /tmp/<agent>-<id>.log 2>&1'`; the `{ ...; }` braces are load-bearing: without them the `>> log 2>&1` binds only to `<command>` and any earlier link failing (nvm-source, cd) sends its stderr to cron mail, which hosts with no MTA silently discard (w-2026-09-13-dave-042; 0.1.39 fix, smarthome01 crons fired but wrote no log 2026-09-09 -> 13). The leading `date -u` prints an ISO timestamp so a run that dies mid-chain still leaves dated evidence. If no nvm install is found, the preamble is simply omitted; nothing to do for schedules that only need system binaries. `environment.*` values are emitted as `export VAR='val';` statements ahead of the command (not a bare `VAR='val' cd ...` prefix, which in POSIX shell only scopes to the `cd` builtin and never actually reaches the command after `&&`; a real, silent bug the old generator had).
|
|
112
112
|
|
|
113
|
-
`scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron.
|
|
113
|
+
`scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron. The cron verify PATH is cron's base `/usr/bin:/bin`, then `/usr/local/bin`, then the PATH entries of `/etc/environment` when that file exists, because Debian cron reads `/etc/environment` through pam_env (w-2026-10-17-dave-007). A `PATH` in the schedule's own `environment` still replaces it. The result JSON carries the PATH used as `path`.
|
|
114
114
|
|
|
115
|
-
**Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The systemd backend
|
|
115
|
+
**Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The tag must end the line (w-2026-10-17-dave-007), so an id that is a prefix of another (`dave-foo` against `dave-foo-bar`) matches only its own line. When a schedule's `agent` field changes, `install` also removes the line with the same id under the old agent prefix and prints one line naming both tags. The systemd backend escapes `%` as `%%` and `$` as `$$` in `ExecStart` (w-2026-10-17-dave-007), because systemd resolves `%` specifiers and expands `$VAR` and `${VAR}` itself, inside single quotes too, before bash sees the command; bash then receives the command exactly as written.
|
|
116
116
|
|
|
117
117
|
### Verify your schedule before walking away
|
|
118
118
|
|
|
@@ -156,7 +156,7 @@ After installing, run the schedule's command once under the same environment the
|
|
|
156
156
|
node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
-
If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is
|
|
159
|
+
If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 120 seconds; override with `--timeout`, which is in seconds on both `pa scheduler verify` and `verify.mjs` (w-2026-10-17-dave-007; the `pa` CLI used to read it as milliseconds):
|
|
160
160
|
|
|
161
161
|
```bash
|
|
162
162
|
node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// nowhere does not describe the estate — it inflates it, and every orphan
|
|
34
34
|
// statistic computed over it is wrong in both directions.
|
|
35
35
|
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
36
37
|
import { existsSync } from 'node:fs';
|
|
37
38
|
import { readdir } from 'node:fs/promises';
|
|
38
39
|
import { join, normalize, posix, relative } from 'node:path';
|
|
@@ -66,8 +67,9 @@ export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
|
|
|
66
67
|
// composes at `operator/knowledge/` (or `operator/knowledge/wsd/`, and
|
|
67
68
|
// `<clone>/voice/` at `operator/knowledge/writing/`) exactly as the registry
|
|
68
69
|
// says. Across clones a mapped copy beats a legacy one; within one clone the
|
|
69
|
-
//
|
|
70
|
-
//
|
|
70
|
+
// copy with the newer last commit wins during the migration bridge, the
|
|
71
|
+
// legacy `<clone>/operator/knowledge/` copy on a tie or when a commit time is
|
|
72
|
+
// unavailable (`legacySupersedes`); home still beats both. See
|
|
71
73
|
// `scanSourcesFor` and mounts.mjs.
|
|
72
74
|
export const EXTRA_SCAN_ROOTS_ENV = 'LIBRARY_KNOWLEDGE_EXTRA_ROOTS';
|
|
73
75
|
|
|
@@ -193,9 +195,10 @@ export function scanDirsFor(root) {
|
|
|
193
195
|
* exactly the ids this function produced before mounts were read.
|
|
194
196
|
*
|
|
195
197
|
* So the home copy beats both forms, and a mapped copy beats another clone's
|
|
196
|
-
* legacy copy of the same id. Within ONE clone the
|
|
197
|
-
*
|
|
198
|
-
*
|
|
198
|
+
* legacy copy of the same id. Within ONE clone the copy whose last commit
|
|
199
|
+
* is newer wins (legacy on a tie or an unavailable time); the order cannot
|
|
200
|
+
* say that, so the collision step applies it (`legacySupersedes`,
|
|
201
|
+
* `collectScannedFiles`). A clone with no registry entry
|
|
199
202
|
* contributes its legacy form only.
|
|
200
203
|
*
|
|
201
204
|
* @param {string} root the home workspace root
|
|
@@ -258,35 +261,92 @@ export function scanIdFor(scan, abs) {
|
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
// slice 3 removes: the within-tier bridge rule. The scan order cannot carry
|
|
261
|
-
// it, because "
|
|
262
|
-
// are not one order: with copies in A legacy, A mapped and B mapped
|
|
263
|
-
// higher precedence) the two rules cycle. So the order picks the
|
|
264
|
-
// TIER (first occurrence: home, mapped by precedence, legacy), and
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
264
|
+
// it, because "one form first within a clone" and "mapped first across
|
|
265
|
+
// clones" are not one order: with copies in A legacy, A mapped and B mapped
|
|
266
|
+
// (A of higher precedence) the two rules cycle. So the order picks the
|
|
267
|
+
// winning TIER (first occurrence: home, mapped by precedence, legacy), and
|
|
268
|
+
// this rule then decides between that clone's own two copies: the copy whose
|
|
269
|
+
// last commit in the clone is newer wins. On a tie, or when either commit
|
|
270
|
+
// time is unavailable (not a git clone, untracked file, git error), the
|
|
271
|
+
// legacy copy wins, which was the slice 1 rule: every writer took the legacy
|
|
272
|
+
// form between the 2026-08-21 cutover and slice 2, which moved the writers
|
|
273
|
+
// to the mounted form. Commit times, not mtimes: a clone's mtimes are
|
|
274
|
+
// checkout times, while commit times are the same on every host.
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Unix seconds of the last commit that touched `abs` in the clone at
|
|
278
|
+
* `root`, or null when there is none to read: `root` is not a git clone,
|
|
279
|
+
* the file is untracked, or git fails.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} root clone root
|
|
282
|
+
* @param {string} abs absolute path of a file under `root`
|
|
283
|
+
* @returns {number|null}
|
|
284
|
+
*/
|
|
285
|
+
export function lastCommitTime(root, abs) {
|
|
286
|
+
// `git -C` would climb into an enclosing repo; only the clone's own
|
|
287
|
+
// history counts.
|
|
288
|
+
if (!existsSync(join(root, '.git'))) return null;
|
|
289
|
+
try {
|
|
290
|
+
const out = execFileSync(
|
|
291
|
+
'git',
|
|
292
|
+
['-C', root, 'log', '-1', '--format=%ct', '--', toPosix(relative(root, abs))],
|
|
293
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
294
|
+
).trim();
|
|
295
|
+
return /^\d+$/.test(out) ? Number(out) : null;
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `lastCommitTime` memoised per path, for the life of one scan.
|
|
303
|
+
*
|
|
304
|
+
* @returns {(root: string, abs: string) => number|null}
|
|
305
|
+
*/
|
|
306
|
+
export function memoCommitTime(lookup = lastCommitTime) {
|
|
307
|
+
const memo = new Map();
|
|
308
|
+
return (root, abs) => {
|
|
309
|
+
if (!memo.has(abs)) memo.set(abs, lookup(root, abs));
|
|
310
|
+
return memo.get(abs);
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
268
314
|
/**
|
|
269
315
|
* True when `candidate` (met later in scan order) should replace `winner`,
|
|
270
|
-
* the scan that first claimed the same id.
|
|
316
|
+
* the scan that first claimed the same id. Only a clone's legacy copy can
|
|
317
|
+
* replace that same clone's mapped copy, and it does unless the mapped copy's
|
|
318
|
+
* last commit is strictly newer. Commit times are read only here, so only on
|
|
319
|
+
* a within-clone collision.
|
|
271
320
|
*
|
|
272
321
|
* @param {ReturnType<typeof scanSourcesFor>[number]} winner
|
|
322
|
+
* @param {string} winnerAbs the file `winner` holds for the id
|
|
273
323
|
* @param {ReturnType<typeof scanSourcesFor>[number]} candidate
|
|
324
|
+
* @param {string} candidateAbs the file `candidate` holds for the id
|
|
325
|
+
* @param {(root: string, abs: string) => number|null} commitTime
|
|
274
326
|
*/
|
|
275
|
-
export function legacySupersedes(winner, candidate) {
|
|
276
|
-
|
|
277
|
-
|
|
327
|
+
export function legacySupersedes(winner, winnerAbs, candidate, candidateAbs, commitTime) {
|
|
328
|
+
if (winner.form !== 'mapped' || candidate.form !== 'legacy'
|
|
329
|
+
|| winner.sourceRoot !== candidate.sourceRoot) return false;
|
|
330
|
+
const mappedAt = commitTime(winner.sourceRoot, winnerAbs);
|
|
331
|
+
const legacyAt = commitTime(candidate.sourceRoot, candidateAbs);
|
|
332
|
+
if (mappedAt === null || legacyAt === null) return true;
|
|
333
|
+
return legacyAt >= mappedAt;
|
|
278
334
|
}
|
|
279
335
|
|
|
280
336
|
/**
|
|
281
337
|
* Walk every scan and keep one file per workspace id: the first occurrence,
|
|
282
|
-
* except that
|
|
338
|
+
* except that within one clone the copy with the newer last commit wins
|
|
283
339
|
* (`legacySupersedes`). A replaced entry keeps its position, so the entry
|
|
284
340
|
* order is still scan order of first occurrence.
|
|
285
341
|
*
|
|
286
342
|
* @param {ReturnType<typeof scanSourcesFor>} scans
|
|
343
|
+
* @param {object} [opts]
|
|
344
|
+
* @param {(root: string, abs: string) => number|null} [opts.commitTime]
|
|
345
|
+
* override the memoised git lookup (tests, timing)
|
|
287
346
|
* @returns {Promise<{ abs: string, id: string, scan: object }[]>}
|
|
288
347
|
*/
|
|
289
|
-
export async function collectScannedFiles(scans) {
|
|
348
|
+
export async function collectScannedFiles(scans, opts = {}) {
|
|
349
|
+
const commitTime = opts.commitTime ?? memoCommitTime();
|
|
290
350
|
const entries = [];
|
|
291
351
|
const byId = new Map();
|
|
292
352
|
for (const scan of scans) {
|
|
@@ -297,7 +357,7 @@ export async function collectScannedFiles(scans) {
|
|
|
297
357
|
const entry = { abs, id, scan };
|
|
298
358
|
byId.set(id, entry);
|
|
299
359
|
entries.push(entry);
|
|
300
|
-
} else if (legacySupersedes(existing.scan, scan)) {
|
|
360
|
+
} else if (legacySupersedes(existing.scan, existing.abs, scan, abs, commitTime)) {
|
|
301
361
|
existing.abs = abs;
|
|
302
362
|
existing.scan = scan;
|
|
303
363
|
}
|
|
@@ -308,14 +368,18 @@ export async function collectScannedFiles(scans) {
|
|
|
308
368
|
|
|
309
369
|
/**
|
|
310
370
|
* The on-disk file that backs a workspace id, under the same precedence the
|
|
311
|
-
* builders apply (including `legacySupersedes`), or null. Lets body readers
|
|
312
|
-
* open the copy the graph actually indexed.
|
|
371
|
+
* builders apply (including `legacySupersedes`), or null. Lets body readers
|
|
372
|
+
* (`pa knowledge search --full`) open the copy the graph actually indexed.
|
|
313
373
|
*
|
|
314
374
|
* @param {ReturnType<typeof scanSourcesFor>} scans
|
|
315
375
|
* @param {string} id
|
|
376
|
+
* @param {object} [opts]
|
|
377
|
+
* @param {(root: string, abs: string) => number|null} [opts.commitTime]
|
|
378
|
+
* override the memoised git lookup (tests, timing)
|
|
316
379
|
* @returns {string|null}
|
|
317
380
|
*/
|
|
318
|
-
export function locateScannedFile(scans, id) {
|
|
381
|
+
export function locateScannedFile(scans, id, opts = {}) {
|
|
382
|
+
const commitTime = opts.commitTime ?? memoCommitTime();
|
|
319
383
|
const posixId = toPosix(id);
|
|
320
384
|
const fileIn = (scan) => {
|
|
321
385
|
if (!posixId.startsWith(scan.idPrefix)) return null;
|
|
@@ -328,9 +392,9 @@ export function locateScannedFile(scans, id) {
|
|
|
328
392
|
const found = fileIn(scan);
|
|
329
393
|
if (!found) continue;
|
|
330
394
|
for (const later of scans.slice(i + 1)) {
|
|
331
|
-
if (
|
|
395
|
+
if (later.form !== 'legacy' || later.sourceRoot !== scan.sourceRoot) continue;
|
|
332
396
|
const legacy = fileIn(later);
|
|
333
|
-
if (legacy) return legacy;
|
|
397
|
+
if (legacy && legacySupersedes(scan, found, later, legacy, commitTime)) return legacy;
|
|
334
398
|
}
|
|
335
399
|
return found;
|
|
336
400
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Vendored from Stravica/remote-library, src/mounts/mount-map.ts, compiled at 2add6c77a58664981771e71e0cdf0db8def64f66.
|
|
2
|
+
// Do not edit here; change the source and re-vendor.
|
|
3
|
+
// Same fixture in both repos (test/knowledge/fixtures/mount-map-cases.json) proves it is one map.
|
|
4
|
+
// Shared mount map (w-2026-10-16-dave-019, library-sync slice 2).
|
|
5
|
+
//
|
|
6
|
+
// One answer, used by every writer and gate, to "which tier path does a
|
|
7
|
+
// registry mount read for this workspace path, and back". The registry is
|
|
8
|
+
// `registry/repos.json` from the company tier: `repos.<repoKey>.mounts[]`,
|
|
9
|
+
// each `{ from, to }` where `from` is the path inside the tier repo and `to`
|
|
10
|
+
// is the workspace path it composes at.
|
|
11
|
+
//
|
|
12
|
+
// PURE by contract: this file has no imports at all (not even node:path) and
|
|
13
|
+
// works on POSIX path strings only, so its compiled `dist/mounts/mount-map.js`
|
|
14
|
+
// can be vendored unchanged into other repos. Keep it that way.
|
|
15
|
+
export const KNOWLEDGE_TO_PREFIX = "operator/knowledge/";
|
|
16
|
+
function stripDotSlash(p) {
|
|
17
|
+
let out = p;
|
|
18
|
+
while (out.startsWith("./"))
|
|
19
|
+
out = out.slice(2);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function hasUnsafeSegment(p) {
|
|
23
|
+
return p.split("/").some((seg) => seg === "..");
|
|
24
|
+
}
|
|
25
|
+
// Normalise a mount side to `a/b/` form. Null when it cannot be a safe
|
|
26
|
+
// relative directory (empty, absolute, or holding a `..` segment).
|
|
27
|
+
function normaliseMountSide(raw) {
|
|
28
|
+
if (typeof raw !== "string")
|
|
29
|
+
return null;
|
|
30
|
+
let p = stripDotSlash(raw.trim());
|
|
31
|
+
if (p === "" || p.startsWith("/") || p.includes("\\"))
|
|
32
|
+
return null;
|
|
33
|
+
if (hasUnsafeSegment(p))
|
|
34
|
+
return null;
|
|
35
|
+
if (!p.endsWith("/"))
|
|
36
|
+
p += "/";
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
// Normalise an input file path. Null when it is not a safe relative file
|
|
40
|
+
// path: empty, absolute, a directory (trailing `/`), or holding an empty,
|
|
41
|
+
// `.` or `..` segment.
|
|
42
|
+
function normaliseFilePath(raw) {
|
|
43
|
+
if (typeof raw !== "string")
|
|
44
|
+
return null;
|
|
45
|
+
const p = stripDotSlash(raw);
|
|
46
|
+
if (p === "" || p.startsWith("/") || p.endsWith("/") || p.includes("\\"))
|
|
47
|
+
return null;
|
|
48
|
+
if (p.includes("\0"))
|
|
49
|
+
return null;
|
|
50
|
+
for (const seg of p.split("/")) {
|
|
51
|
+
if (seg === "" || seg === "." || seg === "..")
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
function isObject(v) {
|
|
57
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
58
|
+
}
|
|
59
|
+
// Longest-prefix mount for `path` on the given side, with the remainder.
|
|
60
|
+
function longestMatch(mounts, path, side) {
|
|
61
|
+
let best = null;
|
|
62
|
+
for (const m of mounts) {
|
|
63
|
+
if (!path.startsWith(m[side]))
|
|
64
|
+
continue;
|
|
65
|
+
if (best === null || m[side].length > best[side].length)
|
|
66
|
+
best = m;
|
|
67
|
+
}
|
|
68
|
+
if (best === null)
|
|
69
|
+
return null;
|
|
70
|
+
const rest = path.slice(best[side].length);
|
|
71
|
+
if (rest === "")
|
|
72
|
+
return null;
|
|
73
|
+
return { mount: best, rest };
|
|
74
|
+
}
|
|
75
|
+
export function createMountMap(registry, opts) {
|
|
76
|
+
const toPrefix = opts?.toPrefix ?? KNOWLEDGE_TO_PREFIX;
|
|
77
|
+
const byRepo = new Map();
|
|
78
|
+
const repoEntries = isObject(registry) && isObject(registry.repos) ? registry.repos : {};
|
|
79
|
+
for (const [repoKey, entry] of Object.entries(repoEntries)) {
|
|
80
|
+
const kept = [];
|
|
81
|
+
const rawMounts = isObject(entry) && Array.isArray(entry.mounts) ? entry.mounts : [];
|
|
82
|
+
for (const raw of rawMounts) {
|
|
83
|
+
if (!isObject(raw))
|
|
84
|
+
continue;
|
|
85
|
+
const from = normaliseMountSide(raw.from);
|
|
86
|
+
const to = normaliseMountSide(raw.to);
|
|
87
|
+
if (from === null || to === null)
|
|
88
|
+
continue;
|
|
89
|
+
if (!to.startsWith(toPrefix))
|
|
90
|
+
continue;
|
|
91
|
+
kept.push({ from, to });
|
|
92
|
+
}
|
|
93
|
+
byRepo.set(repoKey, kept);
|
|
94
|
+
}
|
|
95
|
+
const mountsFor = (repo) => (byRepo.get(repo) ?? []).map((m) => ({ from: m.from, to: m.to }));
|
|
96
|
+
const toLibraryPath = (repo, workspacePath) => {
|
|
97
|
+
const p = normaliseFilePath(workspacePath);
|
|
98
|
+
if (p === null)
|
|
99
|
+
return null;
|
|
100
|
+
const hit = longestMatch(byRepo.get(repo) ?? [], p, "to");
|
|
101
|
+
return hit === null ? null : hit.mount.from + hit.rest;
|
|
102
|
+
};
|
|
103
|
+
const toWorkspacePath = (repo, libraryPath) => {
|
|
104
|
+
const p = normaliseFilePath(libraryPath);
|
|
105
|
+
if (p === null)
|
|
106
|
+
return null;
|
|
107
|
+
const hit = longestMatch(byRepo.get(repo) ?? [], p, "from");
|
|
108
|
+
return hit === null ? null : hit.mount.to + hit.rest;
|
|
109
|
+
};
|
|
110
|
+
const resolveRepoKey = (tier) => {
|
|
111
|
+
if (typeof tier !== "string" || tier === "")
|
|
112
|
+
return null;
|
|
113
|
+
if (byRepo.has(tier))
|
|
114
|
+
return tier;
|
|
115
|
+
const suffix = `-${tier}`;
|
|
116
|
+
const hits = [...byRepo.keys()].filter((k) => k.endsWith(suffix));
|
|
117
|
+
return hits.length === 1 ? hits[0] : null;
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
repos: () => [...byRepo.keys()],
|
|
121
|
+
mountsFor,
|
|
122
|
+
toLibraryPath,
|
|
123
|
+
toWorkspacePath,
|
|
124
|
+
isReadable: (repo, libraryPath) => toWorkspacePath(repo, libraryPath) !== null,
|
|
125
|
+
resolveRepoKey,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export function resolveWrite(map, workspacePath, assertedTier) {
|
|
129
|
+
const triedFor = (repo) => map.mountsFor(repo).map((m) => ({ repo, from: m.from, to: m.to }));
|
|
130
|
+
if (assertedTier !== undefined && assertedTier !== "") {
|
|
131
|
+
const repo = map.resolveRepoKey(assertedTier);
|
|
132
|
+
if (repo === null)
|
|
133
|
+
return { ok: false, reason: "unknown-tier", tried: [] };
|
|
134
|
+
const libraryPath = map.toLibraryPath(repo, workspacePath);
|
|
135
|
+
if (libraryPath === null)
|
|
136
|
+
return { ok: false, reason: "unmapped", tried: triedFor(repo) };
|
|
137
|
+
return { ok: true, repo, libraryPath };
|
|
138
|
+
}
|
|
139
|
+
const p = normaliseFilePath(workspacePath);
|
|
140
|
+
const candidates = [];
|
|
141
|
+
const tried = [];
|
|
142
|
+
for (const repo of map.repos()) {
|
|
143
|
+
tried.push(...triedFor(repo));
|
|
144
|
+
if (p === null)
|
|
145
|
+
continue;
|
|
146
|
+
const libraryPath = map.toLibraryPath(repo, p);
|
|
147
|
+
if (libraryPath === null)
|
|
148
|
+
continue;
|
|
149
|
+
const mount = map
|
|
150
|
+
.mountsFor(repo)
|
|
151
|
+
.filter((m) => p.startsWith(m.to))
|
|
152
|
+
.reduce((a, b) => (b.to.length > a.to.length ? b : a));
|
|
153
|
+
candidates.push({ repo, mount, libraryPath });
|
|
154
|
+
}
|
|
155
|
+
if (candidates.length === 0)
|
|
156
|
+
return { ok: false, reason: "unmapped", tried };
|
|
157
|
+
const longest = Math.max(...candidates.map((c) => c.mount.to.length));
|
|
158
|
+
const best = candidates.filter((c) => c.mount.to.length === longest);
|
|
159
|
+
if (best.length === 1)
|
|
160
|
+
return { ok: true, repo: best[0].repo, libraryPath: best[0].libraryPath };
|
|
161
|
+
const paths = new Set(best.map((c) => c.libraryPath));
|
|
162
|
+
if (paths.size === 1)
|
|
163
|
+
return { ok: true, repo: null, libraryPath: best[0].libraryPath };
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: "ambiguous",
|
|
167
|
+
tried: best.map((c) => ({ repo: c.repo, from: c.mount.from, to: c.mount.to })),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
// `registry/`, the registry source of truth);
|
|
23
23
|
// 2. else the clone's own `registry/repos.json`;
|
|
24
24
|
// 3. else no registry: the clone is scanned in the legacy form only.
|
|
25
|
+
//
|
|
26
|
+
// `loadWriteRegistry` gives `library put` its registry by the same company
|
|
27
|
+
// rule, falling back to the workspace copy (slice 2).
|
|
25
28
|
|
|
26
29
|
import { execFileSync } from 'node:child_process';
|
|
27
30
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -147,15 +150,7 @@ function mountsRegistry(entry) {
|
|
|
147
150
|
export function resolveTierMounts(extraRoots, { remoteOf = gitRemoteOf } = {}) {
|
|
148
151
|
const own = new Map();
|
|
149
152
|
for (const root of extraRoots) own.set(root, readRegistry(root));
|
|
150
|
-
|
|
151
|
-
// The company clone is the registry's own source: its entry mounts
|
|
152
|
-
// `registry/`. Prefer its copy over each clone's (possibly drifted) one.
|
|
153
|
-
let company = null;
|
|
154
|
-
for (const root of extraRoots) {
|
|
155
|
-
const registry = own.get(root);
|
|
156
|
-
const self = resolveRepoEntry(registry, root, { remoteOf });
|
|
157
|
-
if (self && mountsRegistry(self.entry)) { company = registry; break; }
|
|
158
|
-
}
|
|
153
|
+
const company = findCompanyRegistry(extraRoots, { remoteOf, read: (root) => own.get(root) })?.registry ?? null;
|
|
159
154
|
|
|
160
155
|
return extraRoots.map((root) => {
|
|
161
156
|
let hit = null;
|
|
@@ -182,3 +177,61 @@ export function resolveTierMounts(extraRoots, { remoteOf = gitRemoteOf } = {}) {
|
|
|
182
177
|
};
|
|
183
178
|
});
|
|
184
179
|
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The company clone among `roots`: the clone whose own registry entry mounts
|
|
183
|
+
* `registry/`, the registry's source of truth. Its copy is preferred over
|
|
184
|
+
* every other (possibly drifted) one.
|
|
185
|
+
*
|
|
186
|
+
* @param {string[]} roots
|
|
187
|
+
* @param {object} [opts]
|
|
188
|
+
* @param {(root: string) => string|null} [opts.remoteOf]
|
|
189
|
+
* @param {(root: string) => object|null} [opts.read] registry reader seam
|
|
190
|
+
* @returns {{ root: string, registry: object } | null}
|
|
191
|
+
*/
|
|
192
|
+
export function findCompanyRegistry(roots, { remoteOf = gitRemoteOf, read = readRegistry } = {}) {
|
|
193
|
+
for (const root of roots) {
|
|
194
|
+
const registry = read(root);
|
|
195
|
+
const self = resolveRepoEntry(registry, root, { remoteOf });
|
|
196
|
+
if (self && mountsRegistry(self.entry)) return { root, registry };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The registry `library put` maps knowledge paths through
|
|
203
|
+
* (w-2026-10-16-dave-019, slice 2). Source order:
|
|
204
|
+
* 1. the company clone among the extra roots (LIBRARY_KNOWLEDGE_EXTRA_ROOTS);
|
|
205
|
+
* 2. else the workspace copy, `<repoRoot>/operator/registry/repos.json`;
|
|
206
|
+
* 3. else none.
|
|
207
|
+
* `looked` names every place tried, so a refusal can print it.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} opts
|
|
210
|
+
* @param {string[]} opts.extraRoots
|
|
211
|
+
* @param {string} opts.repoRoot
|
|
212
|
+
* @param {(root: string) => string|null} [opts.remoteOf]
|
|
213
|
+
* @returns {{ registry: object|null, source: 'company'|'workspace'|null, path: string|null, looked: string[] }}
|
|
214
|
+
*/
|
|
215
|
+
export function loadWriteRegistry({ extraRoots, repoRoot, remoteOf = gitRemoteOf }) {
|
|
216
|
+
const looked = [];
|
|
217
|
+
if (extraRoots.length) {
|
|
218
|
+
looked.push(`company clone among ${extraRoots.length} extra root(s) (${extraRoots.join(':')})`);
|
|
219
|
+
const company = findCompanyRegistry(extraRoots, { remoteOf });
|
|
220
|
+
if (company) {
|
|
221
|
+
return {
|
|
222
|
+
registry: company.registry,
|
|
223
|
+
source: 'company',
|
|
224
|
+
path: join(company.root, ...REGISTRY_RELATIVE_PATH.split('/')),
|
|
225
|
+
looked,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
looked.push('company clone (LIBRARY_KNOWLEDGE_EXTRA_ROOTS unset)');
|
|
230
|
+
}
|
|
231
|
+
const operatorDir = join(repoRoot, 'operator');
|
|
232
|
+
const workspacePath = join(operatorDir, ...REGISTRY_RELATIVE_PATH.split('/'));
|
|
233
|
+
looked.push(workspacePath);
|
|
234
|
+
const workspace = readRegistry(operatorDir);
|
|
235
|
+
if (workspace) return { registry: workspace, source: 'workspace', path: workspacePath, looked };
|
|
236
|
+
return { registry: null, source: null, path: null, looked };
|
|
237
|
+
}
|
|
@@ -494,6 +494,44 @@ export function buildCronLine(sched, workDir, env, opts = {}) {
|
|
|
494
494
|
return `${sched.schedule} ${cronField} # ${tag}`;
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
/**
|
|
498
|
+
* True when a crontab line carries `tag` (`# <agent>-<id>`) as its trailing
|
|
499
|
+
* comment. Anchored to the end of the line so `# dave-foo` does not also
|
|
500
|
+
* match `# dave-foo-bar` (w-2026-10-17-dave-007 item 2).
|
|
501
|
+
* @param {string} line
|
|
502
|
+
* @param {string} tag
|
|
503
|
+
* @returns {boolean}
|
|
504
|
+
*/
|
|
505
|
+
export function cronLineHasTag(line, tag) {
|
|
506
|
+
return line.trimEnd().endsWith(tag);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* True when a crontab line ends in any `# <agent>-<suffix>` tag for `agent`.
|
|
511
|
+
* @param {string} line
|
|
512
|
+
* @param {string} agent
|
|
513
|
+
* @returns {boolean}
|
|
514
|
+
*/
|
|
515
|
+
export function cronLineHasAgentTag(line, agent) {
|
|
516
|
+
return /# (\S+)$/.exec(line.trimEnd())?.[1].startsWith(`${agent}-`) ?? false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The tag of a crontab line installed for the same schedule id under a
|
|
521
|
+
* different agent (`# sam-<id>` when installing `# dave-<id>`), or null.
|
|
522
|
+
* The other agent prefix must be a single hyphen-free word so `# dave-foo-bar`
|
|
523
|
+
* is never read as agent `dave-foo` with id `bar` (w-2026-10-17-dave-007 item 5).
|
|
524
|
+
* @param {string} line
|
|
525
|
+
* @param {string} agent
|
|
526
|
+
* @param {string} id
|
|
527
|
+
* @returns {string | null}
|
|
528
|
+
*/
|
|
529
|
+
export function otherAgentCronTag(line, agent, id) {
|
|
530
|
+
const m = /# ([A-Za-z0-9_]+)-(\S+)$/.exec(line.trimEnd());
|
|
531
|
+
if (!m || m[2] !== id || m[1] === agent) return null;
|
|
532
|
+
return `${m[1]}-${m[2]}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
497
535
|
function installCron(sched, workDir) {
|
|
498
536
|
const agent = schedAgent(sched);
|
|
499
537
|
const env = resolveEnvironment(sched);
|
|
@@ -508,10 +546,17 @@ function installCron(sched, workDir) {
|
|
|
508
546
|
// no crontab yet
|
|
509
547
|
}
|
|
510
548
|
|
|
511
|
-
// Remove any existing entry for this schedule
|
|
549
|
+
// Remove any existing entry for this schedule, including one left under
|
|
550
|
+
// another agent prefix when the schedule's `agent` field changed.
|
|
512
551
|
const filtered = existing
|
|
513
552
|
.split('\n')
|
|
514
|
-
.filter(line =>
|
|
553
|
+
.filter((line) => {
|
|
554
|
+
if (cronLineHasTag(line, tag)) return false;
|
|
555
|
+
const other = otherAgentCronTag(line, agent, sched.id);
|
|
556
|
+
if (!other) return true;
|
|
557
|
+
console.log(`Removed cron line tagged ${other}; replaced by ${agent}-${sched.id} (agent changed)`);
|
|
558
|
+
return false;
|
|
559
|
+
})
|
|
515
560
|
.join('\n')
|
|
516
561
|
.replace(/^\n+|\n+$/g, '');
|
|
517
562
|
|
|
@@ -843,6 +888,23 @@ function installLaunchd(sched, workDir, cron) {
|
|
|
843
888
|
|
|
844
889
|
// ── Backend: systemd ─────────────────────────────────────────────────
|
|
845
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Escape a command for systemd's ExecStart parser. systemd resolves `%`
|
|
893
|
+
* specifiers when the unit is loaded and expands `$VAR` / `${VAR}` itself,
|
|
894
|
+
* inside single quotes too, before bash ever sees the line. `%%` is a
|
|
895
|
+
* literal percent and `$$` a literal dollar (systemd.unit(5) Specifiers,
|
|
896
|
+
* systemd.service(5) Command lines). The single quote is escaped for the
|
|
897
|
+
* `bash -c '...'` wrapper.
|
|
898
|
+
* @param {string} command
|
|
899
|
+
* @returns {string}
|
|
900
|
+
*/
|
|
901
|
+
export function escapeSystemdExecCommand(command) {
|
|
902
|
+
return command
|
|
903
|
+
.replace(/%/g, '%%')
|
|
904
|
+
.replace(/\$/g, '$$$$')
|
|
905
|
+
.replace(/'/g, `'\\''`);
|
|
906
|
+
}
|
|
907
|
+
|
|
846
908
|
/**
|
|
847
909
|
* Build the .service unit contents.
|
|
848
910
|
* @param {object} sched
|
|
@@ -862,7 +924,7 @@ Description=${description}
|
|
|
862
924
|
[Service]
|
|
863
925
|
Type=oneshot
|
|
864
926
|
WorkingDirectory=${workDir}
|
|
865
|
-
${envBlock}ExecStart=/usr/bin/env bash -c '${sched.command
|
|
927
|
+
${envBlock}ExecStart=/usr/bin/env bash -c '${escapeSystemdExecCommand(sched.command)}'
|
|
866
928
|
|
|
867
929
|
[Install]
|
|
868
930
|
WantedBy=default.target
|
|
@@ -24,7 +24,7 @@ import { resolveHome } from '../home/resolve.mjs';
|
|
|
24
24
|
import { execSync } from 'node:child_process';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { detect } from './detect.mjs';
|
|
27
|
-
import { launchdLabelPrefix } from './install.mjs';
|
|
27
|
+
import { launchdLabelPrefix, cronLineHasAgentTag } from './install.mjs';
|
|
28
28
|
import { resolveAgent } from './resolve-agent.mjs';
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -70,12 +70,10 @@ export function list(backend, opts = {}) {
|
|
|
70
70
|
function listCron(agent) {
|
|
71
71
|
const lines = [`${headerName(agent)} schedules (cron):`, '─'.repeat(37)];
|
|
72
72
|
let found = false;
|
|
73
|
-
const tag = `# ${agent}-`;
|
|
74
|
-
|
|
75
73
|
try {
|
|
76
74
|
const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
|
|
77
75
|
for (const line of crontab.split('\n')) {
|
|
78
|
-
if (line
|
|
76
|
+
if (cronLineHasAgentTag(line, agent)) {
|
|
79
77
|
lines.push(` ${line}`);
|
|
80
78
|
found = true;
|
|
81
79
|
}
|
|
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
23
23
|
import { resolveHome } from '../home/resolve.mjs';
|
|
24
24
|
import { execSync } from 'node:child_process';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
|
-
import { launchdLabelPrefix } from './install.mjs';
|
|
26
|
+
import { launchdLabelPrefix, cronLineHasTag } from './install.mjs';
|
|
27
27
|
import { findRepoRootSync, walkUpForMarker } from '../instance-root/find.mjs';
|
|
28
28
|
import { resolveAgent } from './resolve-agent.mjs';
|
|
29
29
|
import { detect } from './detect.mjs';
|
|
@@ -44,7 +44,7 @@ function removeCron(schedId, agent) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const lines = existing.split('\n');
|
|
47
|
-
const filtered = lines.filter(line => !line
|
|
47
|
+
const filtered = lines.filter(line => !cronLineHasTag(line, tag)).join('\n').replace(/^\n+|\n+$/g, '');
|
|
48
48
|
|
|
49
49
|
if (filtered === existing.replace(/^\n+|\n+$/g, '')) {
|
|
50
50
|
console.log(`No cron entry found for ${agent}-${schedId}`);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The verify deliberately does not require the schedule to be installed already —
|
|
13
13
|
* it derives the env the same way install.mjs does and runs the command once. A
|
|
14
|
-
* timeout (default
|
|
14
|
+
* timeout (default 120s) bounds the run; verify is meant for "does this schedule
|
|
15
15
|
* even start?" not "does it complete its full work?".
|
|
16
16
|
*
|
|
17
17
|
* Backend defaults to the auto-detected backend for the host (launchd on macOS,
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* 4 timeout — command did not finish within the timeout
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { readFileSync, realpathSync, existsSync } from 'node:fs';
|
|
29
29
|
import { spawn } from 'node:child_process';
|
|
30
30
|
import { resolve, dirname, join } from 'node:path';
|
|
31
31
|
import { resolveHome } from '../home/resolve.mjs';
|
|
@@ -68,6 +68,54 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** Default `scheduler verify --timeout`, in seconds (both CLI paths). */
|
|
72
|
+
export const DEFAULT_VERIFY_TIMEOUT_SECONDS = 120;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a `--timeout` value given in seconds into milliseconds. Both the
|
|
76
|
+
* `pa scheduler verify` CLI and this module's own CLI read seconds
|
|
77
|
+
* (w-2026-10-17-dave-007 item 3).
|
|
78
|
+
* @param {string | number} value
|
|
79
|
+
* @returns {number | null} milliseconds, or null when not a positive number
|
|
80
|
+
*/
|
|
81
|
+
export function parseTimeoutSeconds(value) {
|
|
82
|
+
const n = Number(value);
|
|
83
|
+
if (value === '' || value == null || !Number.isFinite(n) || n <= 0) return null;
|
|
84
|
+
return Math.round(n * 1000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the PATH entries from an `/etc/environment`-format file (pam_env
|
|
89
|
+
* hands this to cron jobs on Debian). Returns [] when the file is absent
|
|
90
|
+
* or carries no PATH line.
|
|
91
|
+
* @param {string} file
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function readEtcEnvironmentPath(file) {
|
|
95
|
+
if (!existsSync(file)) return [];
|
|
96
|
+
let pathValue = null;
|
|
97
|
+
for (const raw of readFileSync(file, 'utf8').split('\n')) {
|
|
98
|
+
const m = /^\s*(?:export\s+)?PATH\s*=\s*(.*?)\s*$/.exec(raw);
|
|
99
|
+
if (m) pathValue = m[1].replace(/^(["'])(.*)\1$/, '$2');
|
|
100
|
+
}
|
|
101
|
+
return pathValue ? pathValue.split(':').filter(Boolean) : [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The PATH verify uses for the cron backend: cron's minimal base, then
|
|
106
|
+
* `/usr/local/bin`, then the PATH entries of `/etc/environment` when it
|
|
107
|
+
* exists, without duplicates (w-2026-10-17-dave-007 item 4). Real cron on
|
|
108
|
+
* Debian gets `/etc/environment` through pam_env, so the bare base alone
|
|
109
|
+
* was stricter than the runtime.
|
|
110
|
+
* @param {string} basePath
|
|
111
|
+
* @param {string} [etcEnvironmentFile]
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function composeCronVerifyPath(basePath, etcEnvironmentFile = '/etc/environment') {
|
|
115
|
+
const entries = [...basePath.split(':'), '/usr/local/bin', ...readEtcEnvironmentPath(etcEnvironmentFile)];
|
|
116
|
+
return [...new Set(entries.filter(Boolean))].join(':');
|
|
117
|
+
}
|
|
118
|
+
|
|
71
119
|
/**
|
|
72
120
|
* Compute the env the named backend would install for this schedule.
|
|
73
121
|
*
|
|
@@ -88,9 +136,11 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
|
|
|
88
136
|
*
|
|
89
137
|
* @param {object} sched
|
|
90
138
|
* @param {string} backend
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} [opts.etcEnvironmentFile] - cron only; defaults to /etc/environment
|
|
91
141
|
* @returns {Record<string,string>}
|
|
92
142
|
*/
|
|
93
|
-
export function computeBackendEnv(sched, backend) {
|
|
143
|
+
export function computeBackendEnv(sched, backend, opts = {}) {
|
|
94
144
|
const explicit = resolveEnvironment(sched);
|
|
95
145
|
if (backend === 'launchd') {
|
|
96
146
|
// launchd builds the agent env from EnvironmentVariables alone. Verify
|
|
@@ -98,7 +148,9 @@ export function computeBackendEnv(sched, backend) {
|
|
|
98
148
|
return withLaunchdDefaults(explicit);
|
|
99
149
|
}
|
|
100
150
|
if (backend === 'cron') {
|
|
101
|
-
|
|
151
|
+
const base = minimalCronEnv();
|
|
152
|
+
base.PATH = composeCronVerifyPath(base.PATH, opts.etcEnvironmentFile);
|
|
153
|
+
return { ...base, ...explicit };
|
|
102
154
|
}
|
|
103
155
|
// systemd/schtasks: "parent env then explicit overrides". Replicate by
|
|
104
156
|
// layering explicit over process.env, dropping any keys the parent has
|
|
@@ -266,7 +318,9 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
266
318
|
* @param {string} scheduleFile - Path to the schedule JSON
|
|
267
319
|
* @param {object} [opts]
|
|
268
320
|
* @param {string} [opts.backend] - Override backend (default: auto-detect)
|
|
269
|
-
* @param {number} [opts.timeoutMs] - Timeout in ms (default:
|
|
321
|
+
* @param {number} [opts.timeoutMs] - Timeout in ms (default: 120000)
|
|
322
|
+
* @param {string} [opts.etcEnvironmentFile] - Test-only override for the
|
|
323
|
+
* cron backend's `/etc/environment` path.
|
|
270
324
|
* @param {boolean} [opts.hasNvm] - Test-only override; defaults to a real
|
|
271
325
|
* filesystem check of $HOME/.nvm/nvm.sh, matching install-time detection.
|
|
272
326
|
* @param {string} [opts.cronLogPath] - Test-only override for the cron
|
|
@@ -274,7 +328,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
|
|
|
274
328
|
* `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
|
|
275
329
|
* schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
|
|
276
330
|
* clobbers/interleaves with real cron output).
|
|
277
|
-
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, workDir: string, run: object, argv: string[] | null }>}
|
|
331
|
+
* @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, path: string | null, workDir: string, run: object, argv: string[] | null }>}
|
|
278
332
|
*/
|
|
279
333
|
export async function verify(scheduleFile, opts = {}) {
|
|
280
334
|
const sched = readSchedule(scheduleFile);
|
|
@@ -286,10 +340,10 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
286
340
|
}
|
|
287
341
|
|
|
288
342
|
const backend = opts.backend || sched.backend || detectDefaultBackend();
|
|
289
|
-
const timeoutMs = opts.timeoutMs ??
|
|
343
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_SECONDS * 1000;
|
|
290
344
|
|
|
291
345
|
const workDir = resolveWorkDir(sched.workingDirectory);
|
|
292
|
-
const env = computeBackendEnv(sched, backend);
|
|
346
|
+
const env = computeBackendEnv(sched, backend, { etcEnvironmentFile: opts.etcEnvironmentFile });
|
|
293
347
|
|
|
294
348
|
let run;
|
|
295
349
|
let argv = null;
|
|
@@ -320,7 +374,7 @@ export async function verify(scheduleFile, opts = {}) {
|
|
|
320
374
|
exitCode = 2;
|
|
321
375
|
}
|
|
322
376
|
|
|
323
|
-
return { result, exitCode, sched, backend, env, workDir, run, argv };
|
|
377
|
+
return { result, exitCode, sched, backend, env, path: env.PATH ?? null, workDir, run, argv };
|
|
324
378
|
}
|
|
325
379
|
|
|
326
380
|
// ── CLI entry point ──────────────────────────────────────────────────
|
|
@@ -339,7 +393,7 @@ Arguments:
|
|
|
339
393
|
systemd, schtasks). Defaults to auto-detected backend.
|
|
340
394
|
|
|
341
395
|
Flags:
|
|
342
|
-
--timeout <seconds> Kill the verify run after this many seconds (default:
|
|
396
|
+
--timeout <seconds> Kill the verify run after this many seconds (default: 120).
|
|
343
397
|
-h, --help Print this message and exit 0.
|
|
344
398
|
|
|
345
399
|
Exit codes:
|
|
@@ -375,7 +429,7 @@ if (isDirectlyInvoked()) {
|
|
|
375
429
|
// Parse: positional <schedule.json>, optional positional <backend>, optional --timeout N.
|
|
376
430
|
let scheduleFile = null;
|
|
377
431
|
let backend = null;
|
|
378
|
-
let timeoutSeconds =
|
|
432
|
+
let timeoutSeconds = DEFAULT_VERIFY_TIMEOUT_SECONDS;
|
|
379
433
|
|
|
380
434
|
for (let i = 0; i < args.length; i++) {
|
|
381
435
|
const a = args[i];
|
|
@@ -14,27 +14,54 @@ pre-remote library sweep.
|
|
|
14
14
|
|
|
15
15
|
## Verbs
|
|
16
16
|
|
|
17
|
-
- `library put <path> [--kind <kind>] [--tier auto|<tier>]`
|
|
17
|
+
- `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]`
|
|
18
18
|
Streams a single event to `POST /events`.
|
|
19
19
|
**`--kind` is required** for any path outside `agents/*/staging/`; those
|
|
20
20
|
staging paths default to `knowledge.observation.staged` via path inference.
|
|
21
|
-
A canonical doc write is `library put <
|
|
21
|
+
A canonical doc write is `library put <path> --kind knowledge.doc.upsert`.
|
|
22
22
|
Known kinds (ingest will 422 anything else): `knowledge.observation.staged`,
|
|
23
23
|
`knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
|
|
24
24
|
`profile.slice.upsert`, `preferences.upsert`, `role.output`,
|
|
25
25
|
`system.correction`, `synthesis.upsert`, `synthesis.merge`,
|
|
26
26
|
`synthesis.retire`, `system.seed`.
|
|
27
|
-
**Path resolution.**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
**Path resolution.** `<path>` is resolved against the current directory, so
|
|
28
|
+
a relative path from any cwd and an absolute path encode the same
|
|
29
|
+
repo-relative path. From the repo root this is byte-identical to the old
|
|
30
|
+
"as given" encoding.
|
|
31
|
+
**Knowledge docs go to the tier path a mount reads.** For
|
|
32
|
+
`knowledge.doc.upsert` and `knowledge.doc.delete` the workspace path
|
|
33
|
+
(`operator/knowledge/...`) is mapped through the registry knowledge mounts
|
|
34
|
+
(`repos.<repo>.mounts[]` with `to` under `operator/knowledge/`) by the
|
|
35
|
+
shared mount map vendored from remote-library
|
|
36
|
+
(`packages/core/src/knowledge/retrieval/mount-map.mjs`). The longest
|
|
37
|
+
matching mount `to` wins:
|
|
38
|
+
| Workspace path | Sent as | Tier |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `operator/knowledge/engineering/x.md` | `knowledge/engineering/x.md` | `auto` (hq and company agree) |
|
|
41
|
+
| `operator/knowledge/wsd/memory/y.md` | `knowledge/memory/y.md` | `stravica-library-wsd` |
|
|
42
|
+
| `operator/knowledge/writing/z.md` | `voice/z.md` | `stravica-operator-barry` |
|
|
43
|
+
A unique tier is stamped exactly as an explicit `--tier` is (envelope `tier`
|
|
44
|
+
and `payload.source`); `provenance.sourcePath` keeps the workspace path.
|
|
45
|
+
With an explicit `--tier` (registry key or short name such as `hq`), only
|
|
46
|
+
that tier's mounts are tried, and the registry key is stamped. Put refuses
|
|
47
|
+
with exit 2 and one line naming the mounts tried when no knowledge mount
|
|
48
|
+
reads the path, when the candidate tiers disagree, when `--tier` names an
|
|
49
|
+
unknown tier or one that cannot read the path, or when no registry is found.
|
|
50
|
+
The ingest refuses the same shape with 422 `unmapped-path`.
|
|
51
|
+
Paths the profile tier map owns (`operator/profile/**`) keep their profile
|
|
52
|
+
routing and are sent unmapped.
|
|
53
|
+
**Registry source.** The company clone among `LIBRARY_KNOWLEDGE_EXTRA_ROOTS`
|
|
54
|
+
(the clone whose own registry entry mounts `registry/`), else the workspace
|
|
55
|
+
copy `<repoRoot>/operator/registry/repos.json`, else none (refuse).
|
|
33
56
|
`--tier auto` sends the spec §2.3 sentinel; the server's routing engine
|
|
34
57
|
resolves the actual tier. An explicit `--tier <repo>` also stamps
|
|
35
58
|
`payload.source = <repo>` on the kinds whose ingest schema accepts it
|
|
36
59
|
(`knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
|
|
37
60
|
`profile.slice.upsert`).
|
|
61
|
+
`--verbatim-path` (slice 3 removes) sends a `knowledge.doc.delete` at the
|
|
62
|
+
workspace path unmapped, to delete the legacy `operator/knowledge/...`
|
|
63
|
+
copies. It needs `--kind knowledge.doc.delete` and an explicit `--tier`;
|
|
64
|
+
any other use is a usage error (exit 1).
|
|
38
65
|
|
|
39
66
|
- `library flush`
|
|
40
67
|
Sweeps `<repoRoot>/agents/<paName>/staging/*` and streams one event per file.
|
|
@@ -78,7 +78,7 @@ help ('library put --help' etc.) is served the same way.
|
|
|
78
78
|
* useful at the point of friction, not a prose essay.
|
|
79
79
|
*/
|
|
80
80
|
const VERB_USAGE = {
|
|
81
|
-
put: `library put <path> [--kind <kind>] [--tier auto|<tier>]
|
|
81
|
+
put: `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]
|
|
82
82
|
|
|
83
83
|
Streams a single event to POST /events (REMOTE mode only).
|
|
84
84
|
|
|
@@ -91,12 +91,23 @@ const VERB_USAGE = {
|
|
|
91
91
|
synthesis.upsert, synthesis.merge, synthesis.retire,
|
|
92
92
|
system.seed.
|
|
93
93
|
--tier <name> 'auto' (default) lets the server route; an explicit tier
|
|
94
|
-
(repo name
|
|
94
|
+
(repo name, or a short name such as 'hq') stamps
|
|
95
|
+
payload.source for routing-rule kinds.
|
|
96
|
+
--verbatim-path With --kind knowledge.doc.delete and an explicit --tier
|
|
97
|
+
only: send the workspace path unmapped, to delete a legacy
|
|
98
|
+
operator/knowledge/... copy (slice 3 removes).
|
|
95
99
|
|
|
96
|
-
Path resolution:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
Path resolution: <path> is resolved against the current directory, so the
|
|
101
|
+
payload path is repo-relative from any cwd.
|
|
102
|
+
|
|
103
|
+
Knowledge docs (knowledge.doc.upsert, knowledge.doc.delete) are sent at the
|
|
104
|
+
tier path a registry knowledge mount reads, for example
|
|
105
|
+
operator/knowledge/x.md -> knowledge/x.md (tier auto),
|
|
106
|
+
operator/knowledge/wsd/x.md -> knowledge/x.md (stravica-library-wsd),
|
|
107
|
+
operator/knowledge/writing/x.md -> voice/x.md (stravica-operator-barry).
|
|
108
|
+
No mount reads the path, the tiers disagree, or no registry: exit 2 with
|
|
109
|
+
the mounts tried. Registry: the company clone among
|
|
110
|
+
LIBRARY_KNOWLEDGE_EXTRA_ROOTS, else operator/registry/repos.json.
|
|
100
111
|
`,
|
|
101
112
|
flush: `library flush
|
|
102
113
|
|
|
@@ -276,9 +287,16 @@ function parsePut(argv) {
|
|
|
276
287
|
options: {
|
|
277
288
|
kind: { type: 'string' },
|
|
278
289
|
tier: { type: 'string', default: 'auto' },
|
|
290
|
+
// slice 3 removes: deletes the legacy operator/knowledge/... copies.
|
|
291
|
+
'verbatim-path': { type: 'boolean', default: false },
|
|
279
292
|
},
|
|
280
293
|
});
|
|
281
|
-
return {
|
|
294
|
+
return {
|
|
295
|
+
path: positionals[0],
|
|
296
|
+
kind: values.kind,
|
|
297
|
+
tier: values.tier,
|
|
298
|
+
verbatimPath: values['verbatim-path'],
|
|
299
|
+
};
|
|
282
300
|
}
|
|
283
301
|
function parseFlush(argv) {
|
|
284
302
|
parseArgs({ args: argv, strict: true, allowPositionals: true, options: {} });
|
|
@@ -143,6 +143,12 @@ export function guessKindFromPath(sourcePath) {
|
|
|
143
143
|
* `--tier` is asserted explicitly (not the `auto` sentinel), the tier
|
|
144
144
|
* value is used as the source stamp for these kinds automatically, so
|
|
145
145
|
* callers rarely pass this directly.
|
|
146
|
+
* @param {string} [args.payloadPath] the tier path to send as
|
|
147
|
+
* `payload.path` when it differs from the repo-relative source path
|
|
148
|
+
* (`library put` maps knowledge docs through the registry mounts,
|
|
149
|
+
* w-2026-10-16-dave-019 slice 2). `provenance.sourcePath` keeps the
|
|
150
|
+
* repo-relative source path. Absent: `payload.path` is the source path,
|
|
151
|
+
* unchanged.
|
|
146
152
|
* @param {() => Date} [args.clock] test seam
|
|
147
153
|
* @returns {object} EventInput ready for POST /events
|
|
148
154
|
*/
|
|
@@ -155,6 +161,7 @@ export function buildEnvelope({
|
|
|
155
161
|
tier = 'auto',
|
|
156
162
|
sourceKind = 'manual',
|
|
157
163
|
source,
|
|
164
|
+
payloadPath,
|
|
158
165
|
clock = () => new Date(),
|
|
159
166
|
}) {
|
|
160
167
|
// Repo-relative resolution + refusal for anything that would encode as
|
|
@@ -173,6 +180,10 @@ export function buildEnvelope({
|
|
|
173
180
|
if (relPath !== undefined) {
|
|
174
181
|
assertRepoRelativeSafe(relPath, sourcePath, repoRoot);
|
|
175
182
|
}
|
|
183
|
+
if (payloadPath !== undefined) {
|
|
184
|
+
assertRepoRelativeSafe(payloadPath, sourcePath, repoRoot);
|
|
185
|
+
}
|
|
186
|
+
const wirePath = payloadPath ?? relPath;
|
|
176
187
|
|
|
177
188
|
const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
|
|
178
189
|
if (!resolvedKind) {
|
|
@@ -218,7 +229,7 @@ export function buildEnvelope({
|
|
|
218
229
|
let payload;
|
|
219
230
|
if (resolvedKind === 'knowledge.doc.delete') {
|
|
220
231
|
payload = {
|
|
221
|
-
path:
|
|
232
|
+
path: wirePath ?? null,
|
|
222
233
|
...(stampSource ? { source: effectiveSource } : {}),
|
|
223
234
|
};
|
|
224
235
|
} else if (resolvedKind === 'profile.slice.upsert') {
|
|
@@ -249,7 +260,7 @@ export function buildEnvelope({
|
|
|
249
260
|
} else if (KINDS_ACCEPTING_SOURCE.has(resolvedKind)) {
|
|
250
261
|
// knowledge.doc.upsert, profile.upsert.
|
|
251
262
|
payload = {
|
|
252
|
-
path:
|
|
263
|
+
path: wirePath ?? null,
|
|
253
264
|
content,
|
|
254
265
|
...(stampSource ? { source: effectiveSource } : {}),
|
|
255
266
|
};
|
|
@@ -257,7 +268,7 @@ export function buildEnvelope({
|
|
|
257
268
|
// Every other kind keeps the pre-cure {path, content} shape
|
|
258
269
|
// byte-identical.
|
|
259
270
|
payload = {
|
|
260
|
-
path:
|
|
271
|
+
path: wirePath ?? null,
|
|
261
272
|
content,
|
|
262
273
|
};
|
|
263
274
|
}
|
|
@@ -1,40 +1,72 @@
|
|
|
1
|
-
// verbs/put.mjs
|
|
1
|
+
// verbs/put.mjs: `library put <path> [--kind ...] [--tier auto|<tier>] [--verbatim-path]`
|
|
2
2
|
//
|
|
3
3
|
// Streams a single event to POST /events. Reads the file's UTF-8 contents,
|
|
4
4
|
// builds an envelope (spec §3.1), and posts with retries via http.mjs.
|
|
5
5
|
// A `deduped` server response counts as SUCCESS (spec §4.2: retrying a
|
|
6
6
|
// timed-out send must see success).
|
|
7
|
+
//
|
|
8
|
+
// Knowledge docs (`knowledge.doc.upsert`, `knowledge.doc.delete`) are sent at
|
|
9
|
+
// the TIER path a registry mount reads, never at the workspace path
|
|
10
|
+
// (w-2026-10-16-dave-019, slice 2). The input resolves against cwd to a
|
|
11
|
+
// repo-relative workspace path, the shared mount map (vendored from
|
|
12
|
+
// remote-library) maps it, and put refuses with exit 2 when no knowledge
|
|
13
|
+
// mount reads it. The ingest refuses the same shape with 422 `unmapped-path`.
|
|
7
14
|
|
|
8
15
|
import { readFile } from 'node:fs/promises';
|
|
9
|
-
import { basename } from 'node:path';
|
|
16
|
+
import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { resolveExtraScanRoots } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
|
|
19
|
+
import { loadWriteRegistry } from '../../../engineering-pa-core/src/knowledge/retrieval/mounts.mjs';
|
|
20
|
+
import { createMountMap, resolveWrite } from '../../../engineering-pa-core/src/knowledge/retrieval/mount-map.mjs';
|
|
10
21
|
|
|
11
|
-
import { buildEnvelope, subkeyIdFromRemoteKey } from '../envelope.mjs';
|
|
22
|
+
import { buildEnvelope, guessKindFromPath, subkeyIdFromRemoteKey } from '../envelope.mjs';
|
|
12
23
|
import { postEvents } from '../http.mjs';
|
|
13
24
|
import { parseIngestAck } from '../ack.mjs';
|
|
14
25
|
import { assertTierMatchesPath, deriveTierFromPath } from '../tier-map.mjs';
|
|
15
26
|
|
|
27
|
+
/** Kinds whose payload path must be one a registry knowledge mount reads. */
|
|
28
|
+
export const MAPPED_KINDS = new Set(['knowledge.doc.upsert', 'knowledge.doc.delete']);
|
|
29
|
+
|
|
16
30
|
/**
|
|
17
31
|
* @param {object} ctx
|
|
18
32
|
* @param {object} ctx.config from loadLibraryConfig (mode/remoteEndpoint/remoteKey/preferences)
|
|
19
|
-
* @param {object} ctx.args { path, kind?, tier? }
|
|
33
|
+
* @param {object} ctx.args { path, kind?, tier?, verbatimPath? }
|
|
20
34
|
* @param {typeof fetch} [ctx.fetchImpl]
|
|
21
35
|
* @param {(...m:unknown[])=>void} [ctx.log]
|
|
36
|
+
* @param {string} [ctx.cwd] test seam; defaults to process.cwd()
|
|
37
|
+
* @param {() => { registry: object|null, looked: string[] }} [ctx.loadRegistry]
|
|
38
|
+
* test seam for the mount registry; defaults to loadWriteRegistry over
|
|
39
|
+
* LIBRARY_KNOWLEDGE_EXTRA_ROOTS and the workspace copy
|
|
22
40
|
* @returns {Promise<number>} exit code
|
|
23
41
|
*/
|
|
24
|
-
export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
25
|
-
const { path, kind, tier = 'auto' } = args;
|
|
42
|
+
export async function runPut({ config, args, fetchImpl, log = defaultLog, cwd = process.cwd(), loadRegistry }) {
|
|
43
|
+
const { path, kind, tier = 'auto', verbatimPath = false } = args;
|
|
26
44
|
if (!path) {
|
|
27
|
-
log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>]');
|
|
45
|
+
log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]');
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `--verbatim-path` (slice 3 removes): send a delete at the workspace path
|
|
50
|
+
// unmapped, so slice 3 can delete the legacy `operator/knowledge/...`
|
|
51
|
+
// copies. Deletes only, and only with an explicit tier.
|
|
52
|
+
if (verbatimPath && (kind !== 'knowledge.doc.delete' || tier === 'auto')) {
|
|
53
|
+
log('usage: --verbatim-path is valid only with --kind knowledge.doc.delete and an explicit --tier <tier>');
|
|
28
54
|
return 1;
|
|
29
55
|
}
|
|
30
56
|
|
|
57
|
+
// Every kind resolves the input against cwd, so the payload path is
|
|
58
|
+
// repo-relative whether the caller passed a repo-root-relative path, a
|
|
59
|
+
// path relative to some other cwd, or an absolute path. From the repo
|
|
60
|
+
// root this encodes byte-identically to the old "as given" behaviour.
|
|
61
|
+
const absPath = resolve(cwd, path);
|
|
62
|
+
|
|
31
63
|
// Refuse a caller-forced tier that disagrees with the path-derived
|
|
32
64
|
// default (w-2026-09-03-dave-017). `auto` never triggers the refusal
|
|
33
65
|
// (auto means "let derivation choose"); an explicit tier does. The
|
|
34
66
|
// refusal is a clean exit 2 with a single-line reason so an operator
|
|
35
67
|
// sees why the CLI declined to encode the event rather than silently
|
|
36
68
|
// stamping the wrong tier.
|
|
37
|
-
const tierRefusal = assertTierMatchesPath(
|
|
69
|
+
const tierRefusal = assertTierMatchesPath(absPath, config.repoRoot, tier);
|
|
38
70
|
if (tierRefusal) {
|
|
39
71
|
log(`[library put] refused: ${tierRefusal}`);
|
|
40
72
|
return 2;
|
|
@@ -43,9 +75,37 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
43
75
|
// When `--tier` is absent the CLI derives it from the path map. Paths
|
|
44
76
|
// outside the map keep today's `auto` sentinel so unrelated payload
|
|
45
77
|
// kinds still content-route the way they always did.
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
78
|
+
const mapTier = deriveTierFromPath(absPath, config.repoRoot);
|
|
79
|
+
let derivedTier = tier === 'auto' ? (mapTier ?? 'auto') : tier;
|
|
80
|
+
|
|
81
|
+
// Knowledge docs go to the tier path a registry mount reads. Paths the
|
|
82
|
+
// profile tier map owns (operator/profile/**) keep their profile routing
|
|
83
|
+
// and path unchanged; profile routing is out of scope for slice 2. A path
|
|
84
|
+
// outside the repo skips mapping so buildEnvelope refuses it as before.
|
|
85
|
+
const workspacePath = relative(config.repoRoot, absPath).split(sep).join('/');
|
|
86
|
+
const insideRepo = workspacePath !== '' && !workspacePath.startsWith('../') && !isAbsolute(workspacePath);
|
|
87
|
+
const effectiveKind = kind ?? guessKindFromPath(workspacePath);
|
|
88
|
+
let payloadPath;
|
|
89
|
+
if (MAPPED_KINDS.has(effectiveKind) && insideRepo && !mapTier && !verbatimPath) {
|
|
90
|
+
const loaded = (loadRegistry ?? (() => loadWriteRegistry({
|
|
91
|
+
extraRoots: resolveExtraScanRoots(process.env),
|
|
92
|
+
repoRoot: config.repoRoot,
|
|
93
|
+
})))();
|
|
94
|
+
if (!loaded?.registry) {
|
|
95
|
+
log(`[library put] refused: no mount registry (looked in: ${(loaded?.looked ?? []).join(', ')})`);
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
const asserted = tier === 'auto' ? undefined : tier;
|
|
99
|
+
const res = resolveWrite(createMountMap(loaded.registry), workspacePath, asserted);
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
log(`[library put] refused: ${describeRefusal(res, workspacePath, asserted)}`);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
payloadPath = res.libraryPath;
|
|
105
|
+
// A unique tier stamps itself exactly as an explicit --tier does; agreeing
|
|
106
|
+
// tiers (hq and company both read `knowledge/`) leave the server to route.
|
|
107
|
+
derivedTier = res.repo ?? 'auto';
|
|
108
|
+
}
|
|
49
109
|
|
|
50
110
|
// Delete events carry no content by spec (§3.1 payload schema); the
|
|
51
111
|
// operator can and typically will have removed the file locally before
|
|
@@ -54,17 +114,18 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
54
114
|
// delete. Every other kind still reads the file to populate
|
|
55
115
|
// payload.content. Fix Wave E (w-2026-08-24-dave-029) partner cure to
|
|
56
116
|
// the envelope kind-aware branch.
|
|
57
|
-
const content = kind === 'knowledge.doc.delete' ? undefined : await readFile(
|
|
117
|
+
const content = kind === 'knowledge.doc.delete' ? undefined : await readFile(absPath, 'utf8');
|
|
58
118
|
|
|
59
119
|
const producer = producerFromPreferences(config);
|
|
60
120
|
const envelope = buildEnvelope({
|
|
61
121
|
repoRoot: config.repoRoot,
|
|
62
|
-
sourcePath:
|
|
122
|
+
sourcePath: absPath,
|
|
63
123
|
content,
|
|
64
124
|
producer,
|
|
65
125
|
kind,
|
|
66
126
|
tier: derivedTier,
|
|
67
127
|
sourceKind: 'manual',
|
|
128
|
+
...(payloadPath !== undefined ? { payloadPath } : {}),
|
|
68
129
|
});
|
|
69
130
|
|
|
70
131
|
const res = await postEvents({
|
|
@@ -77,13 +138,35 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
77
138
|
// RB-5 fix (2026-08-20 rehearsal). See ack.mjs for the wire contract.
|
|
78
139
|
const ack = parseIngestAck(res);
|
|
79
140
|
if (ack.kind === 'accepted' || ack.kind === 'deduped') {
|
|
80
|
-
|
|
141
|
+
const pathNote = payloadPath !== undefined ? `, path: ${payloadPath}` : '';
|
|
142
|
+
log(`[library put] ${ack.kind} ${basename(path)} -> ${ack.eventId} (tier: ${derivedTier}${pathNote})`);
|
|
81
143
|
return 0;
|
|
82
144
|
}
|
|
83
145
|
log(`[library put] rejected ${basename(path)}: ${ack.reason}`);
|
|
84
146
|
return 2;
|
|
85
147
|
}
|
|
86
148
|
|
|
149
|
+
/**
|
|
150
|
+
* One-line reason for a failed resolveWrite, naming the mounts tried.
|
|
151
|
+
*
|
|
152
|
+
* @param {{ reason: string, tried: { repo: string, from: string, to: string }[] }} res
|
|
153
|
+
* @param {string} workspacePath
|
|
154
|
+
* @param {string|undefined} asserted
|
|
155
|
+
*/
|
|
156
|
+
export function describeRefusal(res, workspacePath, asserted) {
|
|
157
|
+
const tried = res.tried.length
|
|
158
|
+
? res.tried.map((t) => `${t.repo} ${t.to} -> ${t.from}`).join(', ')
|
|
159
|
+
: 'no knowledge mounts';
|
|
160
|
+
if (res.reason === 'unknown-tier') {
|
|
161
|
+
return `${workspacePath}: tier ${asserted} is not in the mount registry`;
|
|
162
|
+
}
|
|
163
|
+
if (res.reason === 'ambiguous') {
|
|
164
|
+
return `${workspacePath} is read by knowledge mounts of several tiers that disagree on the tier path; ` +
|
|
165
|
+
`pass --tier to choose; tried: ${tried}`;
|
|
166
|
+
}
|
|
167
|
+
return `${workspacePath} is read by no knowledge mount of ${asserted ?? 'any tier'}; tried: ${tried}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
87
170
|
export function producerFromPreferences(config) {
|
|
88
171
|
const paName = config.preferences?.paName;
|
|
89
172
|
const operator = (config.preferences?.operator?.name ?? '').toString().toLowerCase() || 'unknown';
|
package/package.json
CHANGED