@stravica-ai/digital-operator-pa 0.1.64 → 0.1.65
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-library-cli/README.md +179 -0
- package/dist/engineering-pa-library-cli/src/cli.mjs +185 -3
- package/dist/engineering-pa-library-cli/src/envelope.mjs +6 -1
- package/dist/engineering-pa-library-cli/src/migrate/apply.mjs +479 -0
- package/dist/engineering-pa-library-cli/src/migrate/kit.mjs +108 -0
- package/dist/engineering-pa-library-cli/src/migrate/plan.mjs +593 -0
- package/dist/engineering-pa-library-cli/src/migrate/verb.mjs +222 -0
- package/package.json +1 -1
|
@@ -115,6 +115,19 @@ pre-remote library sweep.
|
|
|
115
115
|
Runs the same in SIMPLE and REMOTE mode (GitHub-side maintenance verb,
|
|
116
116
|
no ingest touch). Config in the `library.upMerge` stanza; see below.
|
|
117
117
|
|
|
118
|
+
- `library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]... [--ref <ref>] [--no-fetch] [--out <dir>] [--baseline <csv>] [--repo-root <dir>] [--json]`
|
|
119
|
+
Plans the legacy-path migration: lists every knowledge doc a tier clone
|
|
120
|
+
holds at the workspace path (`operator/knowledge/...`), where no registry
|
|
121
|
+
mount reads it, and says what moving it to the mounted path takes.
|
|
122
|
+
Read-only (no vault, no ingest, no network beyond `git fetch` of the
|
|
123
|
+
clones), so it runs in SIMPLE and REMOTE mode. See
|
|
124
|
+
[Legacy-path migration](#legacy-path-migration-library-migrate-mounts).
|
|
125
|
+
|
|
126
|
+
- `library migrate-mounts --apply --tier <name> --out <dir> [--clone <path>]... [--batch-size <n>] [--batches 1|all] [--merged-dir <dir>] [--wait-ms <n>] [--poll-ms <n>]`
|
|
127
|
+
Moves one tier's legacy rows to the mounted path in verified batches
|
|
128
|
+
through the ingest, and stops on the first divergence. REMOTE mode only.
|
|
129
|
+
See [Apply](#apply---apply).
|
|
130
|
+
|
|
118
131
|
### Scope rule (verbatim)
|
|
119
132
|
|
|
120
133
|
```
|
|
@@ -136,6 +149,172 @@ Every verb accepts `--help` and `-h` and prints that verb's usage before any
|
|
|
136
149
|
config or vault resolution runs, so verb-level help has the same vault-free
|
|
137
150
|
guarantee as the top-level `library --help`.
|
|
138
151
|
|
|
152
|
+
## Legacy-path migration (`library migrate-mounts`)
|
|
153
|
+
|
|
154
|
+
Writers before 0.1.64 sent knowledge docs to the tier repos at the workspace
|
|
155
|
+
path, so a tier can hold `operator/knowledge/R` (the legacy form, read only
|
|
156
|
+
through the reader's bridge) beside or instead of the mounted form
|
|
157
|
+
(w-2026-10-16-dave-019). `library migrate-mounts` plans the move back.
|
|
158
|
+
|
|
159
|
+
**Clones and registry.** Every clone under the pull-clones root, or the
|
|
160
|
+
`--clone` list, each resolved to its registry key (origin remote, else
|
|
161
|
+
directory name); `--tier` limits the run (a registry key or short name such
|
|
162
|
+
as `hq`). The registry is the company clone among the planned clones, else
|
|
163
|
+
the one `library put` uses (the company clone among
|
|
164
|
+
`LIBRARY_KNOWLEDGE_EXTRA_ROOTS`, else `operator/registry/repos.json`); none
|
|
165
|
+
exits 2 naming every place looked. Each clone is planned at `origin/main`
|
|
166
|
+
(else `HEAD`, or `--ref`) after a `git fetch origin` unless `--no-fetch`;
|
|
167
|
+
the sha is recorded.
|
|
168
|
+
|
|
169
|
+
**Target (tier-preserving).** With R the path after `operator/knowledge/`,
|
|
170
|
+
the tier's one knowledge mount gives the target `<from>R` and the workspace
|
|
171
|
+
id `<to>R`, which the shared mount map's `resolveWrite` must confirm:
|
|
172
|
+
|
|
173
|
+
| Tier | Legacy row | Target | Workspace id |
|
|
174
|
+
|---|---|---|---|
|
|
175
|
+
| hq | `operator/knowledge/x.md` | `knowledge/x.md` | `operator/knowledge/x.md` |
|
|
176
|
+
| hq | `operator/knowledge/writing/x.md` | `knowledge/writing/x.md` (stays in hq) | `operator/knowledge/writing/x.md` |
|
|
177
|
+
| wsd | `operator/knowledge/x.md` | `knowledge/x.md` | `operator/knowledge/wsd/x.md` (the id moves) |
|
|
178
|
+
| operator-barry-private | any | none: no knowledge mount | `owner-decision` |
|
|
179
|
+
| wsd | `operator/knowledge/wsd/R` | `knowledge/R` (same tier, ordinary actions) | `operator/knowledge/wsd/R` (the id stays) |
|
|
180
|
+
| any other | `operator/knowledge/wsd/R` | wsd `knowledge/R` (`cross-tier`) | `operator/knowledge/wsd/R` |
|
|
181
|
+
|
|
182
|
+
A tier with more than one knowledge mount, or a `resolveWrite`
|
|
183
|
+
disagreement, gives `unmappable`.
|
|
184
|
+
|
|
185
|
+
**Cross-tier rows (Dave ruling 2026-10-19).** A legacy row under
|
|
186
|
+
`operator/knowledge/wsd/` is a wsd workspace path, so its target is the one
|
|
187
|
+
`resolveWrite` gives in `stravica-library-wsd` (`knowledge/R`), never the
|
|
188
|
+
tier-preserving nest at `knowledge/wsd/R` (workspace id
|
|
189
|
+
`operator/knowledge/wsd/wsd/R`). Whether it is cross-tier is decided by tier
|
|
190
|
+
identity, not by the prefix: a row is `cross-tier` only when its own tier
|
|
191
|
+
differs from the tier its target belongs to. In the wsd tier such a row is an
|
|
192
|
+
ordinary same-tier row (`re-put`, `delete`, `take-legacy`, `take-mounted`
|
|
193
|
+
or `merge`) with the legacy path as its workspace id. In any other tier it
|
|
194
|
+
gets the action `cross-tier`, and `--apply` never writes it (the planned
|
|
195
|
+
tier's writer may hold no wsd subkey, and the move is the wsd owner's from a
|
|
196
|
+
wsd host). `<dir>/cross-tier.csv` lists every one with
|
|
197
|
+
`sourceTier,legacyPath,legacyBlob,targetTier,targetPath,targetBlob,workspaceId,crossKind,targetSha,reason`;
|
|
198
|
+
`crossKind` is `move` (no wsd copy), `identical`, `merge` (a wsd copy that
|
|
199
|
+
differs; the merge rule applies) or `unknown` (no wsd clone among the
|
|
200
|
+
clones). They still count as legacy rows for `--check`, and a baseline
|
|
201
|
+
`correct` that differs on one is printed as resolved by the ruling, not as
|
|
202
|
+
a disagreement.
|
|
203
|
+
|
|
204
|
+
Open step (not built): once a cross-tier row lands in its target tier (the
|
|
205
|
+
wsd owner applies the move or merge from a wsd host), the source tier's
|
|
206
|
+
legacy copy still needs a `knowledge.doc.delete` in the source tier.
|
|
207
|
+
`--apply` does not do it yet, so `--check` keeps naming the row until that
|
|
208
|
+
delete lands.
|
|
209
|
+
|
|
210
|
+
**Actions**, from the blob shas at the planned sha, never from commit time:
|
|
211
|
+
|
|
212
|
+
| Action | When | Body the apply posts |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| `re-put` | the mounted path is absent | the legacy body |
|
|
215
|
+
| `delete` | both paths hold the same blob | none (legacy copy deleted) |
|
|
216
|
+
| `take-legacy` | the mounted blob is in the legacy path's history | the legacy body |
|
|
217
|
+
| `take-mounted` | the legacy blob is in the mounted path's history | none (legacy copy deleted) |
|
|
218
|
+
| `merge` | anything else | a merged body, written first |
|
|
219
|
+
| `cross-tier` | the path is under `operator/knowledge/wsd/` in a tier other than wsd | none: never applied, listed in `cross-tier.csv` |
|
|
220
|
+
|
|
221
|
+
**Plan CSV.** `--dry-run --out <dir>` writes `<dir>/plan-<tier>.csv`
|
|
222
|
+
(`<tier>` is the registry key without `stravica-`, for example
|
|
223
|
+
`library-hq`) with the columns
|
|
224
|
+
`tier,current,correct,action,writer,indexed,home,reason,bothExist,legacyBlob,mountedBlob,workspaceId,body,baseline`.
|
|
225
|
+
`writer` is `librarian` when the legacy body's frontmatter has
|
|
226
|
+
`distilledBy:`, else `put`. `indexed` (the legacy id is a node in
|
|
227
|
+
`operator/knowledge/.graph.json`) and `home` (`absent`, `same-as-mounted`,
|
|
228
|
+
`same-as-mispathed` or `differs`, for the home copy at the workspace id)
|
|
229
|
+
need `--repo-root`; otherwise they read `unknown`. With `--baseline <csv>`
|
|
230
|
+
each row is `in-baseline` or `new-since-baseline`, baseline rows no longer
|
|
231
|
+
present count as gone, and a common row whose `correct` differs is printed
|
|
232
|
+
on stderr. One summary line per tier:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
library-hq @f3f313e: 548 legacy rows: re-put 520, delete 1, take-legacy 0, take-mounted 0, merge 27, owner-decision 0, unmappable 0; new since baseline 32, gone 0
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Without `--out` the CSV (one header for every tier) goes to stdout and the
|
|
239
|
+
summary lines to stderr.
|
|
240
|
+
|
|
241
|
+
**Merge kit.** For every `merge`, `take-legacy` and `take-mounted` row,
|
|
242
|
+
`<dir>/merge/<tier>/<R>.legacy`, `.mounted`, `.diff` (unified, mounted copy
|
|
243
|
+
a/ to legacy copy b/) and `.home` when the home copy matches neither, plus
|
|
244
|
+
`<dir>/merge/README.md`. Merged bodies go to `<merged-dir>/<tier>/<R>`:
|
|
245
|
+
keep the Librarian additions from the legacy copy and the newer operator
|
|
246
|
+
edits from the mounted copy, and resolve conflicts on purpose. Nothing is
|
|
247
|
+
written outside `--out`.
|
|
248
|
+
|
|
249
|
+
**Check.** `--check` exits 0 when every selected tier holds no legacy row
|
|
250
|
+
other than `owner-decision` rows (each named on stderr) and 2 otherwise.
|
|
251
|
+
`--json` prints `{ tiers: [{ repo, ref, sha, legacyCount, byAction,
|
|
252
|
+
ownerDecision: [path], crossTier: [...] }] }`.
|
|
253
|
+
|
|
254
|
+
### Apply (`--apply`)
|
|
255
|
+
|
|
256
|
+
`library migrate-mounts --apply --tier <name> --out <dir>` moves ONE tier's
|
|
257
|
+
rows to the mounted path through the ingest, as ordinary events. REMOTE mode
|
|
258
|
+
only (SIMPLE exits 1, like every write verb). It needs the company clone
|
|
259
|
+
among the clones (the reader check reads its registry), refuses a tier with
|
|
260
|
+
no knowledge mount (exit 2) and a clone with uncommitted changes, and always
|
|
261
|
+
fetches (`--no-fetch` is refused).
|
|
262
|
+
|
|
263
|
+
Each invocation re-plans the tier live and takes the eligible rows in plan
|
|
264
|
+
order: `re-put`, `delete`, `take-legacy`, `take-mounted`, and `merge` rows
|
|
265
|
+
whose merged body exists at `<merged-dir>/<tier>/<R>`. Every other row
|
|
266
|
+
(`owner-decision`, `unmappable`, `cross-tier`, a `merge` with no merged
|
|
267
|
+
body) is skipped and listed. The first 25 (`--batch-size`, 1 to 100) form
|
|
268
|
+
the batch; `--batches all` repeats until nothing is eligible or a divergence
|
|
269
|
+
stops it. Then, per batch:
|
|
270
|
+
|
|
271
|
+
1. **Compare-and-swap.** Fetch and re-read both blobs of every batch row;
|
|
272
|
+
any change since the plan is `state-changed`.
|
|
273
|
+
2. **Upserts.** `knowledge.doc.upsert` with `payload.path` the target,
|
|
274
|
+
`tier` the registry key, `provenance.sourcePath` the workspace id, and
|
|
275
|
+
the legacy body (`re-put`, `take-legacy`) or the merged body (`merge`).
|
|
276
|
+
An ack other than accepted or deduped is `rejected:<reason>`.
|
|
277
|
+
3. **Wait** until each target blob equals the posted body's blob
|
|
278
|
+
(`--wait-ms`, default 300000; `--poll-ms`, default 10000): a timeout is
|
|
279
|
+
`not-applied`, another blob is `content-mismatch`.
|
|
280
|
+
4. **Deletes.** `knowledge.doc.delete` of the legacy path verbatim, only for
|
|
281
|
+
rows whose mounted copy verified (phase 3, or phase 1 for `delete` and
|
|
282
|
+
`take-mounted`).
|
|
283
|
+
5. **Wait** until each legacy path is absent (same bound).
|
|
284
|
+
6. **Reader check.** Fast-forward the clone, then the reader over an empty
|
|
285
|
+
home (`scanSourcesFor`, `locateScannedFile`) must resolve each workspace
|
|
286
|
+
id to `<clone>/<target>` with the posted (or planned mounted) body's
|
|
287
|
+
sha256; otherwise `reader-mismatch`.
|
|
288
|
+
|
|
289
|
+
The first divergence stops the invocation: no further post of any kind, and
|
|
290
|
+
a legacy copy is never deleted unless its mounted copy verified. Upserted
|
|
291
|
+
docs whose legacy copy is still there are safe (the reader's bridge serves
|
|
292
|
+
the newer mounted copy) and the next run re-plans them. A divergence writes
|
|
293
|
+
`<out>/divergence-<batchId>.json` and exits 2.
|
|
294
|
+
|
|
295
|
+
Event ids are deterministic: a uuid v5 over `migrate-mounts`, the repo key,
|
|
296
|
+
the event action (`upsert` or `delete`), the path, the body's blob sha and
|
|
297
|
+
the last commit that touched the path at the planned sha. A rerun after a
|
|
298
|
+
crash, with the path untouched since, dedups at the ingest, and a second
|
|
299
|
+
full run posts nothing; once the path has changed (the worker wrote another
|
|
300
|
+
body, or a stale writer brought a legacy copy back) the corrective post
|
|
301
|
+
gets a new id rather than being deduped forever.
|
|
302
|
+
|
|
303
|
+
Each batch writes `<out>/batches/<repoKey>-<UTC stamp>.json`: `batchId`,
|
|
304
|
+
`tier`, `ref`, `tierShaBefore`, `tierShaAfter`, `startedAt`, `finishedAt`,
|
|
305
|
+
`rows[]` (`action`, `legacyPath`, `mountedPath`, `workspaceId`,
|
|
306
|
+
`preLegacyBlob`, `preMountedBlob`, `postedBlob`, `mergedBodySha256`,
|
|
307
|
+
`upsertEventId`, `deleteEventId`, `verified: { mounted, legacyGone,
|
|
308
|
+
reader }`, `divergence`), `skipped[]`, `crossTier[]` and `rollback`. There
|
|
309
|
+
is no rollback mode: every pre-state copy is `git show <tierShaBefore>:<path>`
|
|
310
|
+
in the tier; to restore a mounted copy, put that body at its workspace path
|
|
311
|
+
with `library put`; legacy copies are not restored. One line per batch on
|
|
312
|
+
stdout:
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
library-hq batch stravica-library-hq-20261019T120000000Z: posted 22 upserts, 25 deletes; verified 25 of 25; skipped 30; divergence none
|
|
316
|
+
```
|
|
317
|
+
|
|
139
318
|
## Configuration
|
|
140
319
|
|
|
141
320
|
- `operator/config/preferences.json` (base) and `preferences.local.json`
|
|
@@ -15,7 +15,7 @@ import { readFileSync } from 'node:fs';
|
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
|
|
18
|
-
import { loadLibraryConfig, LibraryConfigError } from './config.mjs';
|
|
18
|
+
import { loadLibraryConfig, LibraryConfigError, readMergedPreferences, resolveDefaultRepoRoot } from './config.mjs';
|
|
19
19
|
import { runPut } from './verbs/put.mjs';
|
|
20
20
|
import { runFlush } from './verbs/flush.mjs';
|
|
21
21
|
import { runGet } from './verbs/get.mjs';
|
|
@@ -23,6 +23,7 @@ import { runSearch } from './verbs/search.mjs';
|
|
|
23
23
|
import { runStatus } from './verbs/status.mjs';
|
|
24
24
|
import { runRefreshClones } from './verbs/refresh-clones.mjs';
|
|
25
25
|
import { runUpMerge } from './verbs/up-merge.mjs';
|
|
26
|
+
import { runMigrateMounts } from './migrate/verb.mjs';
|
|
26
27
|
|
|
27
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
|
|
@@ -54,6 +55,10 @@ Usage:
|
|
|
54
55
|
library status [--json]
|
|
55
56
|
library refresh-clones [--json] [--timeout-ms <n>] [--tier <name>]...
|
|
56
57
|
library up-merge [--repo <owner/name>]... [--pr <number>] [--dry-run] [--once] [--verbose] [--json]
|
|
58
|
+
library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]... [--ref <ref>]
|
|
59
|
+
[--no-fetch] [--out <dir>] [--baseline <csv>] [--repo-root <dir>] [--json]
|
|
60
|
+
library migrate-mounts --apply --tier <name> --out <dir> [--clone <path>]... [--batch-size <n>]
|
|
61
|
+
[--batches 1|all] [--merged-dir <dir>] [--wait-ms <n>] [--poll-ms <n>]
|
|
57
62
|
library <verb> -h | --help
|
|
58
63
|
library -h | --help
|
|
59
64
|
library -v | --version
|
|
@@ -61,8 +66,10 @@ Usage:
|
|
|
61
66
|
Reads config from operator/config/preferences.json (+ preferences.local.json overlay).
|
|
62
67
|
Verbs are only active in REMOTE mode. SIMPLE mode 'library status' prints a
|
|
63
68
|
one-line mode summary and exits 0; 'library up-merge' is a GitHub-side
|
|
64
|
-
maintenance verb and runs in both SIMPLE and REMOTE mode;
|
|
65
|
-
|
|
69
|
+
maintenance verb and runs in both SIMPLE and REMOTE mode; 'library
|
|
70
|
+
migrate-mounts --dry-run|--check' is read-only (no vault, no ingest) and runs
|
|
71
|
+
in both modes ('--apply' is REMOTE only); every other verb exits 1 in SIMPLE mode with a clean
|
|
72
|
+
not-applicable message.
|
|
66
73
|
|
|
67
74
|
--help and --version never touch config or the vault, so they always work
|
|
68
75
|
from a directory with no operator config and no vault installed. Per-verb
|
|
@@ -166,6 +173,70 @@ const VERB_USAGE = {
|
|
|
166
173
|
left open with a one-time comment (idempotent via an HTML marker) and no
|
|
167
174
|
merge is ever attempted. Runs in both SIMPLE and REMOTE mode (GitHub-side
|
|
168
175
|
maintenance verb, no ingest touch). Config in the 'library.upMerge' stanza.
|
|
176
|
+
`,
|
|
177
|
+
'migrate-mounts': `library migrate-mounts --dry-run|--check [--tier <name>]... [--clone <path>]...
|
|
178
|
+
[--ref <ref>] [--no-fetch] [--out <dir>] [--baseline <csv>]
|
|
179
|
+
[--repo-root <dir>] [--json]
|
|
180
|
+
library migrate-mounts --apply --tier <name> --out <dir> [--clone <path>]...
|
|
181
|
+
[--batch-size <n>] [--batches 1|all] [--merged-dir <dir>]
|
|
182
|
+
[--wait-ms <n>] [--poll-ms <n>] [--ref <ref>]
|
|
183
|
+
|
|
184
|
+
Plans the legacy-path migration (w-2026-10-16-dave-019): every file under
|
|
185
|
+
operator/knowledge/ in a tier clone is a legacy row that no registry mount
|
|
186
|
+
reads. Read-only: no vault, no ingest, no network beyond 'git fetch' of the
|
|
187
|
+
clones; runs in SIMPLE and REMOTE mode.
|
|
188
|
+
|
|
189
|
+
Cross-tier (Dave ruling 2026-10-19): a row under operator/knowledge/wsd/
|
|
190
|
+
is a wsd workspace path, target wsd knowledge/R. Outside the wsd tier it
|
|
191
|
+
is cross-tier: never applied, listed with both blobs in
|
|
192
|
+
<out>/cross-tier.csv. In the wsd tier it takes the ordinary actions, with
|
|
193
|
+
the legacy path as its workspace id.
|
|
194
|
+
|
|
195
|
+
Target (tier-preserving): R is the path after operator/knowledge/; the
|
|
196
|
+
tier's one knowledge mount gives the target <from>R and the workspace id
|
|
197
|
+
<to>R (hq knowledge/R; wsd knowledge/R with id operator/knowledge/wsd/R).
|
|
198
|
+
No knowledge mount: owner-decision. More than one: unmappable.
|
|
199
|
+
Actions, by blob: mounted path absent re-put; same blob delete; mounted
|
|
200
|
+
blob in the legacy path's history take-legacy; legacy blob in the mounted
|
|
201
|
+
path's history take-mounted; anything else merge. Never by commit time.
|
|
202
|
+
|
|
203
|
+
--dry-run Write <out>/plan-<tier>.csv and the merge kit
|
|
204
|
+
(<out>/merge/), and print one summary line per tier.
|
|
205
|
+
Without --out the CSV goes to stdout, summaries to stderr.
|
|
206
|
+
--check Exit 0 when every selected tier holds no legacy row other
|
|
207
|
+
than owner-decision rows (each named on stderr), else 2.
|
|
208
|
+
--tier <name> Repeatable; a registry key or short name (hq, library-wsd).
|
|
209
|
+
--clone <path> Repeatable; the clones to plan. Default: every clone under
|
|
210
|
+
the pull-clones root.
|
|
211
|
+
--ref <ref> Default origin/main when it resolves, else HEAD.
|
|
212
|
+
--no-fetch Skip 'git fetch origin' in each clone.
|
|
213
|
+
--baseline <csv> Mark rows in-baseline or new-since-baseline; count rows
|
|
214
|
+
gone and print any 'correct' disagreement on stderr.
|
|
215
|
+
--repo-root <dir> Fill 'indexed' (operator/knowledge/.graph.json) and 'home'
|
|
216
|
+
(the home copy at the workspace id); else 'unknown'.
|
|
217
|
+
--json Print { tiers: [{ repo, ref, sha, legacyCount, byAction,
|
|
218
|
+
ownerDecision, crossTier }] } instead of the summary lines.
|
|
219
|
+
|
|
220
|
+
--apply REMOTE mode only. Re-plans ONE tier live (with fetch) and
|
|
221
|
+
moves one batch of its eligible rows (re-put, delete,
|
|
222
|
+
take-legacy, take-mounted, merge with a merged body) to
|
|
223
|
+
the mounted path through the ingest: compare-and-swap,
|
|
224
|
+
upserts, wait for each target blob, delete the legacy
|
|
225
|
+
path only behind a verified mounted copy, wait for it to
|
|
226
|
+
go, then the reader must resolve each workspace id to the
|
|
227
|
+
mounted file with the posted body's sha256. The first
|
|
228
|
+
divergence stops the run (exit 2, <out>/divergence-<id>.json).
|
|
229
|
+
Event ids are deterministic (uuid v5), so a rerun dedups.
|
|
230
|
+
Writes <out>/batches/<repo>-<UTC stamp>.json (rows, skipped,
|
|
231
|
+
rollback note) and <out>/cross-tier.csv. Needs the company
|
|
232
|
+
clone among the clones; refuses a tier with no knowledge
|
|
233
|
+
mount (exit 2) and a dirty clone.
|
|
234
|
+
--batch-size <n> Rows per batch, 1 to 100. Default 25.
|
|
235
|
+
--batches 1|all One batch (default) or repeat until nothing is eligible.
|
|
236
|
+
--merged-dir <d> Merged bodies at <d>/<tier>/<R>; a merge row without one
|
|
237
|
+
is skipped.
|
|
238
|
+
--wait-ms <n> Bound per wait phase. Default 300000.
|
|
239
|
+
--poll-ms <n> Poll interval in a wait phase. Default 10000.
|
|
169
240
|
`,
|
|
170
241
|
};
|
|
171
242
|
|
|
@@ -204,6 +275,57 @@ export async function main(argv, seams = {}) {
|
|
|
204
275
|
return 0;
|
|
205
276
|
}
|
|
206
277
|
|
|
278
|
+
// `library migrate-mounts --dry-run|--check` is read-only: it needs the
|
|
279
|
+
// preferences (pull-clone root) and the repo root (registry fallback) but
|
|
280
|
+
// never the vault or the ingest, so it is routed before loadLibraryConfig
|
|
281
|
+
// (which resolves a vault-ref remoteKey in REMOTE mode) and therefore
|
|
282
|
+
// before the SIMPLE-mode refusal, as up-merge is.
|
|
283
|
+
if (verb === 'migrate-mounts') {
|
|
284
|
+
let args;
|
|
285
|
+
try {
|
|
286
|
+
args = parseMigrateMounts(rest);
|
|
287
|
+
} catch (e) {
|
|
288
|
+
err(`[library migrate-mounts] ${e.message ?? e}`);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const repoRoot = seams.repoRoot ?? resolveDefaultRepoRoot();
|
|
293
|
+
const preferences = readMergedPreferences({
|
|
294
|
+
base: join(repoRoot, 'operator', 'config', 'preferences.json'),
|
|
295
|
+
local: join(repoRoot, 'operator', 'config', 'preferences.local.json'),
|
|
296
|
+
});
|
|
297
|
+
// --apply writes through the ingest: REMOTE mode only, like every
|
|
298
|
+
// other write verb, with the same SIMPLE-mode refusal.
|
|
299
|
+
let config;
|
|
300
|
+
if (args.mode === 'apply') {
|
|
301
|
+
config = loadLibraryConfig({ repoRoot: seams.repoRoot });
|
|
302
|
+
if (config.mode === 'simple') {
|
|
303
|
+
err('[library migrate-mounts] --apply is not applicable in simple mode (library.mode="simple"); '
|
|
304
|
+
+ 'it writes through the ingest. --dry-run and --check run in both modes.');
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return await runMigrateMounts({
|
|
309
|
+
args,
|
|
310
|
+
repoRoot,
|
|
311
|
+
preferences,
|
|
312
|
+
env: seams.env ?? process.env,
|
|
313
|
+
log,
|
|
314
|
+
err,
|
|
315
|
+
git: seams.migrateGit,
|
|
316
|
+
remoteOf: seams.remoteOf,
|
|
317
|
+
config,
|
|
318
|
+
fetchImpl: seams.fetchImpl,
|
|
319
|
+
clock: seams.migrateClock,
|
|
320
|
+
sleep: seams.migrateSleep,
|
|
321
|
+
hooks: seams.migrateHooks,
|
|
322
|
+
});
|
|
323
|
+
} catch (e) {
|
|
324
|
+
err(`[library migrate-mounts] ${e.message ?? e}`);
|
|
325
|
+
return typeof e.exitCode === 'number' ? e.exitCode : 2;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
207
329
|
let config;
|
|
208
330
|
try {
|
|
209
331
|
config = loadLibraryConfig({ repoRoot: seams.repoRoot });
|
|
@@ -298,6 +420,66 @@ function parsePut(argv) {
|
|
|
298
420
|
verbatimPath: values['verbatim-path'],
|
|
299
421
|
};
|
|
300
422
|
}
|
|
423
|
+
function parseMigrateMounts(argv) {
|
|
424
|
+
const { values } = parseArgs({
|
|
425
|
+
args: argv,
|
|
426
|
+
strict: true,
|
|
427
|
+
allowPositionals: false,
|
|
428
|
+
options: {
|
|
429
|
+
'dry-run': { type: 'boolean', default: false },
|
|
430
|
+
check: { type: 'boolean', default: false },
|
|
431
|
+
apply: { type: 'boolean', default: false },
|
|
432
|
+
tier: { type: 'string', multiple: true },
|
|
433
|
+
clone: { type: 'string', multiple: true },
|
|
434
|
+
ref: { type: 'string' },
|
|
435
|
+
'no-fetch': { type: 'boolean', default: false },
|
|
436
|
+
out: { type: 'string' },
|
|
437
|
+
baseline: { type: 'string' },
|
|
438
|
+
'repo-root': { type: 'string' },
|
|
439
|
+
json: { type: 'boolean', default: false },
|
|
440
|
+
'batch-size': { type: 'string' },
|
|
441
|
+
batches: { type: 'string' },
|
|
442
|
+
'merged-dir': { type: 'string' },
|
|
443
|
+
'wait-ms': { type: 'string' },
|
|
444
|
+
'poll-ms': { type: 'string' },
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
const modes = ['dry-run', 'check', 'apply'].filter((m) => values[m]);
|
|
448
|
+
if (modes.length !== 1) {
|
|
449
|
+
throw new Error('exactly one of --dry-run, --check or --apply is required');
|
|
450
|
+
}
|
|
451
|
+
const intFlag = (name, min, max) => {
|
|
452
|
+
const raw = values[name];
|
|
453
|
+
if (raw === undefined) return undefined;
|
|
454
|
+
const n = Number(raw);
|
|
455
|
+
if (!/^\d+$/.test(raw) || n < min || n > max) throw new Error(`--${name} must be an integer from ${min} to ${max}`);
|
|
456
|
+
return n;
|
|
457
|
+
};
|
|
458
|
+
const applyOnly = ['batch-size', 'batches', 'merged-dir', 'wait-ms', 'poll-ms'].filter((k) => values[k] !== undefined);
|
|
459
|
+
if (modes[0] !== 'apply' && applyOnly.length) throw new Error(`--${applyOnly[0]} is for --apply only`);
|
|
460
|
+
if (modes[0] === 'apply') {
|
|
461
|
+
if ((values.tier ?? []).length !== 1) throw new Error('--apply needs exactly one --tier');
|
|
462
|
+
if (!values.out) throw new Error('--apply needs --out <dir>');
|
|
463
|
+
if (values['no-fetch']) throw new Error('--apply always fetches; --no-fetch is for --dry-run and --check');
|
|
464
|
+
if (values.batches !== undefined && !['1', 'all'].includes(values.batches)) throw new Error('--batches must be 1 or all');
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
mode: modes[0],
|
|
468
|
+
tiers: values.tier ?? [],
|
|
469
|
+
clones: values.clone ?? [],
|
|
470
|
+
ref: values.ref,
|
|
471
|
+
fetch: !values['no-fetch'],
|
|
472
|
+
out: values.out,
|
|
473
|
+
baseline: values.baseline,
|
|
474
|
+
repoRoot: values['repo-root'],
|
|
475
|
+
json: values.json,
|
|
476
|
+
batchSize: intFlag('batch-size', 1, 100),
|
|
477
|
+
batches: values.batches ?? '1',
|
|
478
|
+
mergedDir: values['merged-dir'],
|
|
479
|
+
waitMs: intFlag('wait-ms', 1, 3600000),
|
|
480
|
+
pollMs: intFlag('poll-ms', 1, 600000),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
301
483
|
function parseFlush(argv) {
|
|
302
484
|
parseArgs({ args: argv, strict: true, allowPositionals: true, options: {} });
|
|
303
485
|
return {};
|
|
@@ -149,6 +149,10 @@ export function guessKindFromPath(sourcePath) {
|
|
|
149
149
|
* w-2026-10-16-dave-019 slice 2). `provenance.sourcePath` keeps the
|
|
150
150
|
* repo-relative source path. Absent: `payload.path` is the source path,
|
|
151
151
|
* unchanged.
|
|
152
|
+
* @param {string} [args.clientEventId] a caller-chosen event id (a uuid)
|
|
153
|
+
* for an idempotent rerun: `library migrate-mounts --apply` derives one
|
|
154
|
+
* per row so the ingest dedups a repeat post (w-2026-10-16-dave-019 slice
|
|
155
|
+
* 3). Absent: a fresh `randomUUID()`, unchanged.
|
|
152
156
|
* @param {() => Date} [args.clock] test seam
|
|
153
157
|
* @returns {object} EventInput ready for POST /events
|
|
154
158
|
*/
|
|
@@ -162,6 +166,7 @@ export function buildEnvelope({
|
|
|
162
166
|
sourceKind = 'manual',
|
|
163
167
|
source,
|
|
164
168
|
payloadPath,
|
|
169
|
+
clientEventId,
|
|
165
170
|
clock = () => new Date(),
|
|
166
171
|
}) {
|
|
167
172
|
// Repo-relative resolution + refusal for anything that would encode as
|
|
@@ -274,7 +279,7 @@ export function buildEnvelope({
|
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
const envelope = {
|
|
277
|
-
clientEventId: randomUUID(),
|
|
282
|
+
clientEventId: clientEventId ?? randomUUID(),
|
|
278
283
|
occurredAt: now.toISOString(),
|
|
279
284
|
producer: {
|
|
280
285
|
operator: producer.operator,
|