amalgm 0.1.258 → 0.1.259
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/lib/cli.js +151 -16
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/observer/index.js +65 -4
- package/runtime/scripts/amalgm-mcp/observer/scan.js +60 -1
- package/runtime/scripts/amalgm-mcp/observer/store.js +43 -8
- package/runtime/scripts/amalgm-mcp/registration/service.js +15 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +16 -8
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +85 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +50 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +21 -0
package/lib/cli.js
CHANGED
|
@@ -120,7 +120,10 @@ function usage() {
|
|
|
120
120
|
' amalgm stop',
|
|
121
121
|
' amalgm register [path ...] Register local ground (default: current directory)',
|
|
122
122
|
' amalgm add <entity-uuid> [directory] Add a cloud-backed tree to this machine',
|
|
123
|
+
' amalgm ls [entity] List the verified cloud workspace',
|
|
124
|
+
' amalgm cd <entity> Resolve a materialized cloud directory',
|
|
123
125
|
' amalgm status [path-or-uuid] Show cloud-backed entity and watcher state',
|
|
126
|
+
' amalgm shell init zsh Enable `amalgm cd` in the current shell',
|
|
124
127
|
' amalgm doctor',
|
|
125
128
|
' amalgm service [install|start|stop|status|uninstall] [--mode auto|systemd|launchd|windows|portable]',
|
|
126
129
|
' amalgm update [--tag latest|canary]',
|
|
@@ -1177,12 +1180,10 @@ async function runtimeStatus() {
|
|
|
1177
1180
|
}
|
|
1178
1181
|
}
|
|
1179
1182
|
|
|
1180
|
-
/**
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
|
|
1184
|
-
async function entityStatus(positionals) {
|
|
1185
|
-
if (positionals.length > 1) throw new Error('Usage: amalgm status [path-or-uuid]');
|
|
1183
|
+
/** One runtime read for every navigation command. The endpoint owns facts;
|
|
1184
|
+
* these commands only select and render them, so navigating never becomes a
|
|
1185
|
+
* backdoor mutation or cloud refresh. */
|
|
1186
|
+
async function readEntityStatus() {
|
|
1186
1187
|
const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
|
|
1187
1188
|
let answer = null;
|
|
1188
1189
|
if (port) {
|
|
@@ -1195,26 +1196,71 @@ async function entityStatus(positionals) {
|
|
|
1195
1196
|
if (!answer) {
|
|
1196
1197
|
console.error('Amalgm runtime is not running. Start it with `amalgm start`.');
|
|
1197
1198
|
process.exitCode = 1;
|
|
1198
|
-
return;
|
|
1199
|
+
return null;
|
|
1199
1200
|
}
|
|
1200
1201
|
if (answer.status !== 200 || !answer.payload) {
|
|
1201
1202
|
console.error(`Error: ${answer.payload?.error || `runtime responded ${answer.status}`}`);
|
|
1202
1203
|
process.exitCode = 1;
|
|
1203
|
-
return;
|
|
1204
|
+
return null;
|
|
1204
1205
|
}
|
|
1206
|
+
return answer.payload;
|
|
1207
|
+
}
|
|
1205
1208
|
|
|
1206
|
-
|
|
1207
|
-
|
|
1209
|
+
/** A selector is deliberately exact: full UUID, local address, canonical
|
|
1210
|
+
* cloud path, or an unambiguous entity name. A guessed fuzzy match would
|
|
1211
|
+
* make a cloud navigation command choose the wrong ground. */
|
|
1212
|
+
function selectEntity(status, selector) {
|
|
1213
|
+
const entities = status.entities || [];
|
|
1214
|
+
if (!selector) return null;
|
|
1208
1215
|
const uuid = selector && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selector)
|
|
1209
1216
|
? selector.toLowerCase()
|
|
1210
1217
|
: null;
|
|
1211
1218
|
const localPath = selector && !uuid ? path.resolve(selector) : null;
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1219
|
+
const cloudPath = uuid ? null : String(selector).replace(/^\/+|\/+$/g, '');
|
|
1220
|
+
const matches = entities.filter((entity) => entity.uuid === uuid
|
|
1221
|
+
|| entity.localPath === localPath
|
|
1222
|
+
|| entity.cloudPath === cloudPath
|
|
1223
|
+
|| entity.name === selector);
|
|
1224
|
+
if (matches.length === 0) throw new Error(`No cloud-backed entity matches ${selector}.`);
|
|
1225
|
+
if (matches.length > 1) {
|
|
1226
|
+
throw new Error(`Entity selector ${selector} is ambiguous: ${matches.map((entity) => entity.cloudPath || entity.uuid).join(', ')}. Use a UUID or canonical cloud path.`);
|
|
1227
|
+
}
|
|
1228
|
+
return matches[0];
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function requireCurrentCloudWorkspace(status) {
|
|
1232
|
+
const cloud = status.cloud || {};
|
|
1233
|
+
if (!cloud.replica) {
|
|
1234
|
+
throw new Error(cloud.identity?.enabled
|
|
1235
|
+
? 'Cloud workspace is initializing; wait for the signed-in runtime to finish its handshake.'
|
|
1236
|
+
: 'Cloud workspace is unavailable because this machine is not signed in.');
|
|
1237
|
+
}
|
|
1238
|
+
if (!cloud.catalog?.current) {
|
|
1239
|
+
throw new Error('Cloud workspace is catching up; wait for the active verified cloud head before listing it.');
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function printEntityLine(entity) {
|
|
1244
|
+
const where = entity.localPath ? 'local' : 'cloud only';
|
|
1245
|
+
console.log(` ${entity.name}\t${entity.type}\t${entity.status}\t${where}\t${entity.uuid}`);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** `amalgm status [path-or-uuid]` is a read-only health view. Daemon status
|
|
1249
|
+
* remains available as `amalgm service status`. */
|
|
1250
|
+
async function entityStatus(positionals) {
|
|
1251
|
+
if (positionals.length > 1) throw new Error('Usage: amalgm status [path-or-uuid]');
|
|
1252
|
+
const status = await readEntityStatus();
|
|
1253
|
+
if (!status) return;
|
|
1254
|
+
const selector = positionals[0] || null;
|
|
1255
|
+
let entities = status.entities || [];
|
|
1256
|
+
if (selector) {
|
|
1257
|
+
try {
|
|
1258
|
+
entities = [selectEntity(status, selector)];
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
console.error(error.message);
|
|
1261
|
+
process.exitCode = 1;
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1218
1264
|
}
|
|
1219
1265
|
|
|
1220
1266
|
const cloud = status.cloud || {};
|
|
@@ -1246,6 +1292,91 @@ async function entityStatus(positionals) {
|
|
|
1246
1292
|
}
|
|
1247
1293
|
}
|
|
1248
1294
|
|
|
1295
|
+
/** `amalgm ls [entity]` lists the canonical cloud graph, never this
|
|
1296
|
+
* machine's incidental directory. A stale catalog is useful in `status`,
|
|
1297
|
+
* but is never presented as a navigable workspace here. */
|
|
1298
|
+
async function entityList(positionals) {
|
|
1299
|
+
if (positionals.length > 1) throw new Error('Usage: amalgm ls [entity]');
|
|
1300
|
+
const status = await readEntityStatus();
|
|
1301
|
+
if (!status) return;
|
|
1302
|
+
try {
|
|
1303
|
+
requireCurrentCloudWorkspace(status);
|
|
1304
|
+
const selected = positionals[0] ? selectEntity(status, positionals[0]) : null;
|
|
1305
|
+
if (selected && !selected.catalogued) {
|
|
1306
|
+
throw new Error(`Entity ${selected.uuid} is not in the verified cloud workspace yet.`);
|
|
1307
|
+
}
|
|
1308
|
+
const entities = (status.entities || []).filter((entity) => entity.catalogued
|
|
1309
|
+
&& entity.status === 'active'
|
|
1310
|
+
&& (selected ? entity.parentUUID === selected.uuid : entity.parentUUID === null))
|
|
1311
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
1312
|
+
if (selected) console.log(`${selected.cloudPath || selected.uuid}:`);
|
|
1313
|
+
console.log('NAME\tTYPE\tSTATE\tLOCATION\tUUID');
|
|
1314
|
+
for (const entity of entities) printEntityLine(entity);
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
console.error(error.message);
|
|
1317
|
+
process.exitCode = 1;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function resolveEntityDirectory(positionals) {
|
|
1322
|
+
if (positionals.length !== 1) throw new Error('Usage: amalgm cd <entity>');
|
|
1323
|
+
const status = await readEntityStatus();
|
|
1324
|
+
if (!status) return null;
|
|
1325
|
+
const entity = selectEntity(status, positionals[0]);
|
|
1326
|
+
if (!entity.localPath) {
|
|
1327
|
+
throw new Error(`Cloud entity ${entity.cloudPath || entity.uuid} is not materialized on this machine. Run \`amalgm add ${entity.uuid} <directory>\` first.`);
|
|
1328
|
+
}
|
|
1329
|
+
let stat;
|
|
1330
|
+
try { stat = fs.statSync(entity.localPath); } catch {
|
|
1331
|
+
throw new Error(`Local binding for ${entity.cloudPath || entity.uuid} is unavailable at ${entity.localPath}. Run \`amalgm status ${entity.uuid}\` for details.`);
|
|
1332
|
+
}
|
|
1333
|
+
if (!stat.isDirectory()) {
|
|
1334
|
+
throw new Error(`${entity.cloudPath || entity.uuid} is a ${entity.type}, not a directory.`);
|
|
1335
|
+
}
|
|
1336
|
+
return entity.localPath;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** A standalone process cannot change the invoking shell. This direct form
|
|
1340
|
+
* resolves a directory honestly and points to the opt-in shell integration;
|
|
1341
|
+
* the shell function below intercepts the same spelling and performs cd. */
|
|
1342
|
+
async function entityCd(positionals) {
|
|
1343
|
+
try {
|
|
1344
|
+
const target = await resolveEntityDirectory(positionals);
|
|
1345
|
+
if (!target) return;
|
|
1346
|
+
console.log(target);
|
|
1347
|
+
console.log('Run `eval "$(amalgm shell init zsh)"` once to make `amalgm cd <entity>` change this shell.');
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
console.error(error.message);
|
|
1350
|
+
process.exitCode = 1;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async function resolveEntityDirectoryMachine(positionals) {
|
|
1355
|
+
try {
|
|
1356
|
+
const target = await resolveEntityDirectory(positionals);
|
|
1357
|
+
if (target) console.log(target);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
console.error(error.message);
|
|
1360
|
+
process.exitCode = 1;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function shellCommand(positionals) {
|
|
1365
|
+
if (positionals[0] !== 'init' || !['zsh', 'bash'].includes(positionals[1] || 'zsh') || positionals.length > 2) {
|
|
1366
|
+
throw new Error('Usage: amalgm shell init [zsh|bash]');
|
|
1367
|
+
}
|
|
1368
|
+
console.log(`amalgm() {
|
|
1369
|
+
if [ "$1" = cd ]; then
|
|
1370
|
+
shift
|
|
1371
|
+
local _amalgm_destination
|
|
1372
|
+
_amalgm_destination="$(command amalgm __resolve-directory "$@")" || return $?
|
|
1373
|
+
builtin cd "$_amalgm_destination"
|
|
1374
|
+
return $?
|
|
1375
|
+
fi
|
|
1376
|
+
command amalgm "$@"
|
|
1377
|
+
}`);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1249
1380
|
async function doctor() {
|
|
1250
1381
|
ensureBaseDirs();
|
|
1251
1382
|
|
|
@@ -1718,7 +1849,11 @@ async function main(argv) {
|
|
|
1718
1849
|
if (command === 'stop') return stop();
|
|
1719
1850
|
if (command === 'register') return register(positionals);
|
|
1720
1851
|
if (command === 'add') return add(positionals);
|
|
1852
|
+
if (command === 'ls') return entityList(positionals);
|
|
1853
|
+
if (command === 'cd') return entityCd(positionals);
|
|
1721
1854
|
if (command === 'status') return entityStatus(positionals);
|
|
1855
|
+
if (command === 'shell') return shellCommand(positionals);
|
|
1856
|
+
if (command === '__resolve-directory') return resolveEntityDirectoryMachine(positionals);
|
|
1722
1857
|
if (command === 'doctor') return doctor();
|
|
1723
1858
|
if (command === 'service') return serviceCommand(options, positionals);
|
|
1724
1859
|
if (command === 'update') return update(options);
|
package/package.json
CHANGED
|
@@ -40,7 +40,7 @@ const path = require('path');
|
|
|
40
40
|
const { createApply } = require('./apply');
|
|
41
41
|
const { createContinuity } = require('./continuity');
|
|
42
42
|
const { createStore } = require('./store');
|
|
43
|
-
const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot } = require('./scan');
|
|
43
|
+
const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot } = require('./scan');
|
|
44
44
|
const { createVerify } = require('./verify');
|
|
45
45
|
const { watchDir, watchRecursive } = require('./watch');
|
|
46
46
|
const { adapterFor } = require('../adapters');
|
|
@@ -343,18 +343,75 @@ function createObserver(options) {
|
|
|
343
343
|
}
|
|
344
344
|
if (parent) {
|
|
345
345
|
// Born from a files root, returned to it: the parent's next scan
|
|
346
|
-
// reclaims the files as creates.
|
|
346
|
+
// reclaims the files as creates. No doorbell will ever name the
|
|
347
|
+
// reclaimed ground, so every file is suspect — the scan must walk.
|
|
347
348
|
detach(root.rootId);
|
|
348
349
|
store.removeRoot(root.rootId);
|
|
349
|
-
live.get(parent.rootId)
|
|
350
|
+
const parentState = live.get(parent.rootId);
|
|
351
|
+
parentState?.territories.delete(root.path.slice(parent.path.length + 1));
|
|
352
|
+
if (parentState) parentState.checkAll = true;
|
|
350
353
|
scanFilesRoot(parent);
|
|
351
354
|
return;
|
|
352
355
|
}
|
|
353
356
|
store.setRootKind(root.rootId, 'files');
|
|
354
|
-
if (state)
|
|
357
|
+
if (state) {
|
|
358
|
+
state.kind = 'files';
|
|
359
|
+
state.checkAll = true; // conversion ground has no doorbells: walk it
|
|
360
|
+
}
|
|
355
361
|
scanFilesRoot({ ...root, kind: 'files' });
|
|
356
362
|
}
|
|
357
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The scoped scan a NAMED doorbell earns: read only the directories
|
|
366
|
+
* the rung paths live in, compare only those directories' rows, and
|
|
367
|
+
* conclude only what is channel-local — an existing file's bytes
|
|
368
|
+
* changed, an existing link's written target changed. Anything
|
|
369
|
+
* structural (a create, a delete, a rename, a retag, a `.git`, an
|
|
370
|
+
* unreadable or absent directory) may be half of a story another
|
|
371
|
+
* directory holds, so it concludes nothing here and escalates to the
|
|
372
|
+
* full walk, which owns those laws unchanged. Returns true when the
|
|
373
|
+
* scoped conclusion was complete; false means walk.
|
|
374
|
+
*/
|
|
375
|
+
function scanRungDirs(root, state, rung) {
|
|
376
|
+
const scopeDirs = new Set();
|
|
377
|
+
for (const relPath of rung) {
|
|
378
|
+
scopeDirs.add(relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '');
|
|
379
|
+
// A rung directory is also looked at directly: its own children
|
|
380
|
+
// may be what actually changed (the OS may name only the folder).
|
|
381
|
+
if (isDirectory(path.join(root.path, relPath))) scopeDirs.add(relPath);
|
|
382
|
+
}
|
|
383
|
+
const entries = { files: [], dirs: [], links: [] };
|
|
384
|
+
for (const relDir of scopeDirs) {
|
|
385
|
+
if (relDir !== '' && underTerritory(state, relDir)) return false; // territory ground answers to its own root
|
|
386
|
+
const look = listDir(root.path, relDir, shouldEnroll);
|
|
387
|
+
if (look.absent || look.unsettled || look.hasGit) return false;
|
|
388
|
+
for (const dir of look.dirs) {
|
|
389
|
+
// A nested repo is territory, never a dir channel — walkRoot
|
|
390
|
+
// reports it to repoDirs; the scoped look just leaves it alone.
|
|
391
|
+
if (state.territories.has(dir.relPath)) continue;
|
|
392
|
+
entries.dirs.push(dir);
|
|
393
|
+
}
|
|
394
|
+
entries.files.push(...look.files);
|
|
395
|
+
entries.links.push(...look.links);
|
|
396
|
+
}
|
|
397
|
+
const dirs = [...scopeDirs];
|
|
398
|
+
const rows = {
|
|
399
|
+
files: dirs.flatMap((relDir) => store.filesInDir(root.rootId, relDir)),
|
|
400
|
+
dirs: dirs.flatMap((relDir) => store.dirsInDir(root.rootId, relDir)),
|
|
401
|
+
links: dirs.flatMap((relDir) => store.linksInDir(root.rootId, relDir)),
|
|
402
|
+
};
|
|
403
|
+
const dirDiff = diffDirState(rows.dirs, entries.dirs);
|
|
404
|
+
const linkDiff = diffLinkState(rows.links, entries.links);
|
|
405
|
+
const fileDiff = diffRootState(rows.files, entries.files, rung);
|
|
406
|
+
const structural = dirDiff.renames.length || dirDiff.retags.length || dirDiff.creates.length || dirDiff.deletes.length
|
|
407
|
+
|| linkDiff.renames.length || linkDiff.creates.length || linkDiff.deletes.length
|
|
408
|
+
|| fileDiff.renames.length || fileDiff.creates.length || fileDiff.deletes.length;
|
|
409
|
+
if (structural) return false;
|
|
410
|
+
applyLinkDiff(root, linkDiff); // changes and retags: payload-local facts
|
|
411
|
+
applyDiff(root, fileDiff); // checks only: content truth on existing channels
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
|
|
358
415
|
function scanFilesRoot(root, { allowRootRepository = false } = {}) {
|
|
359
416
|
// Drain the doorbell's named hints first: anything that rings during the
|
|
360
417
|
// walk below belongs to the next scan, not this one.
|
|
@@ -366,6 +423,10 @@ function createObserver(options) {
|
|
|
366
423
|
state.checkAll = false;
|
|
367
424
|
}
|
|
368
425
|
|
|
426
|
+
// A named doorbell earns a scoped look; only a nameless one (or a
|
|
427
|
+
// scoped look that met structural news) pays for the whole walk.
|
|
428
|
+
if (!checkAll && rung.size > 0 && state && scanRungDirs(root, state, rung)) return;
|
|
429
|
+
|
|
369
430
|
const { files: entries, dirs, links, repoDirs, unsettled } = walkRoot(
|
|
370
431
|
root.path,
|
|
371
432
|
shouldEnroll,
|
|
@@ -126,6 +126,65 @@ function walkRoot(rootPath, shouldEnroll, { allowRootRepository = false } = {})
|
|
|
126
126
|
return { files, dirs, links, repoDirs, unsettled };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* One directory's immediate truth — the scoped look a NAMED doorbell
|
|
131
|
+
* earns (walkRoot is the nameless doorbell's). Same laws, one level
|
|
132
|
+
* deep: entries classified exactly as walkRoot classifies them,
|
|
133
|
+
* symlinks read but never followed. The caller decides what a scoped
|
|
134
|
+
* look may conclude; this function only refuses to pretend it saw more
|
|
135
|
+
* than one directory: a `.git` here is territory news (`hasGit`), a
|
|
136
|
+
* listing that failed is `unsettled`, a directory that is gone is
|
|
137
|
+
* `absent` — all three send the caller to the full walk, which owns
|
|
138
|
+
* those conclusions.
|
|
139
|
+
*/
|
|
140
|
+
function listDir(rootPath, relDir, shouldEnroll) {
|
|
141
|
+
const absDir = relDir ? path.join(rootPath, relDir) : rootPath;
|
|
142
|
+
const out = { files: [], dirs: [], links: [], hasGit: false, absent: false, unsettled: false };
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (lookFailed(error).truth === 'absent') out.absent = true;
|
|
148
|
+
else out.unsettled = true;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
if (entries.some((entry) => entry.name === '.git')) {
|
|
152
|
+
out.hasGit = true;
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
157
|
+
if (!shouldEnroll(relPath)) continue;
|
|
158
|
+
let stat;
|
|
159
|
+
try {
|
|
160
|
+
stat = fs.lstatSync(path.join(rootPath, relPath));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// Vanished mid-look is honest absence (simply not listed); a
|
|
163
|
+
// failed look concludes nothing — the full walk reads again.
|
|
164
|
+
if (lookFailed(error).truth === 'unsettled') out.unsettled = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (stat.isDirectory()) {
|
|
168
|
+
out.dirs.push({ relPath, device: stat.dev, inode: stat.ino });
|
|
169
|
+
} else if (stat.isSymbolicLink()) {
|
|
170
|
+
try {
|
|
171
|
+
out.links.push({ relPath, device: stat.dev, inode: stat.ino, target: fs.readlinkSync(path.join(rootPath, relPath)) });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (lookFailed(error).truth === 'unsettled') out.unsettled = true;
|
|
174
|
+
}
|
|
175
|
+
} else if (stat.isFile()) {
|
|
176
|
+
out.files.push({
|
|
177
|
+
relPath,
|
|
178
|
+
device: stat.dev,
|
|
179
|
+
inode: stat.ino,
|
|
180
|
+
size: stat.size,
|
|
181
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
129
188
|
function defaultShouldEnroll(relPath) {
|
|
130
189
|
return require('../workspace/tree/inclusion').shouldEnroll(relPath);
|
|
131
190
|
}
|
|
@@ -384,4 +443,4 @@ function diffRootState(rows, entries, mustCheck = null) {
|
|
|
384
443
|
return actions;
|
|
385
444
|
}
|
|
386
445
|
|
|
387
|
-
module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot };
|
|
446
|
+
module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot };
|
|
@@ -78,6 +78,20 @@ function rowToFile(row) {
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function rowToDir(row) {
|
|
82
|
+
return {
|
|
83
|
+
dirId: row.dir_id, rootId: row.root_id, relPath: row.rel_path,
|
|
84
|
+
device: row.device, inode: row.inode,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function rowToLink(row) {
|
|
89
|
+
return {
|
|
90
|
+
linkId: row.link_id, rootId: row.root_id, relPath: row.rel_path,
|
|
91
|
+
device: row.device, inode: row.inode, target: row.target,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
const TABLES = ['observer_roots', 'observer_files', 'observer_dirs', 'observer_links'];
|
|
82
96
|
|
|
83
97
|
function rowToRoot(row) {
|
|
@@ -127,6 +141,24 @@ function createStore(db) {
|
|
|
127
141
|
db.exec(SCHEMA);
|
|
128
142
|
db.pragma(`user_version = ${GENERATION}`);
|
|
129
143
|
|
|
144
|
+
/**
|
|
145
|
+
* One directory's immediate child rows — the scoped scan's slice of
|
|
146
|
+
* memory, served by the (root_id, rel_path) unique index. '0' is the
|
|
147
|
+
* character after '/', so [`dir/`, `dir0`) is exactly the rel_path
|
|
148
|
+
* range under the directory; depth filters to immediate children
|
|
149
|
+
* because rel_path is flat text.
|
|
150
|
+
*/
|
|
151
|
+
function immediateRows(table, rootId, relDir) {
|
|
152
|
+
if (relDir === '') {
|
|
153
|
+
return db.prepare(`SELECT * FROM ${table} WHERE root_id = ? AND instr(rel_path, '/') = 0 ORDER BY rel_path`)
|
|
154
|
+
.all(rootId);
|
|
155
|
+
}
|
|
156
|
+
const prefix = `${relDir}/`;
|
|
157
|
+
return db.prepare(`SELECT * FROM ${table} WHERE root_id = ? AND rel_path >= ? AND rel_path < ? ORDER BY rel_path`)
|
|
158
|
+
.all(rootId, prefix, `${relDir}0`)
|
|
159
|
+
.filter((row) => !row.rel_path.slice(prefix.length).includes('/'));
|
|
160
|
+
}
|
|
161
|
+
|
|
130
162
|
return {
|
|
131
163
|
addRoot({ rootId, path, kind, device = null, inode = null }) {
|
|
132
164
|
db.prepare('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
|
|
@@ -177,6 +209,15 @@ function createStore(db) {
|
|
|
177
209
|
return db.prepare('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
178
210
|
.map(rowToFile);
|
|
179
211
|
},
|
|
212
|
+
filesInDir(rootId, relDir) {
|
|
213
|
+
return immediateRows('observer_files', rootId, relDir).map(rowToFile);
|
|
214
|
+
},
|
|
215
|
+
dirsInDir(rootId, relDir) {
|
|
216
|
+
return immediateRows('observer_dirs', rootId, relDir).map(rowToDir);
|
|
217
|
+
},
|
|
218
|
+
linksInDir(rootId, relDir) {
|
|
219
|
+
return immediateRows('observer_links', rootId, relDir).map(rowToLink);
|
|
220
|
+
},
|
|
180
221
|
fileByPath(rootId, relPath) {
|
|
181
222
|
return rowToFile(
|
|
182
223
|
db.prepare('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
|
|
@@ -204,10 +245,7 @@ function createStore(db) {
|
|
|
204
245
|
|
|
205
246
|
dirsForRoot(rootId) {
|
|
206
247
|
return db.prepare('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
207
|
-
.map(
|
|
208
|
-
dirId: row.dir_id, rootId: row.root_id, relPath: row.rel_path,
|
|
209
|
-
device: row.device, inode: row.inode,
|
|
210
|
-
}));
|
|
248
|
+
.map(rowToDir);
|
|
211
249
|
},
|
|
212
250
|
/**
|
|
213
251
|
* One compare result is ONE truth transition. Movers may exchange
|
|
@@ -240,10 +278,7 @@ function createStore(db) {
|
|
|
240
278
|
// per-row like files — every commit is a valid last-known truth.
|
|
241
279
|
linksForRoot(rootId) {
|
|
242
280
|
return db.prepare('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
243
|
-
.map(
|
|
244
|
-
linkId: row.link_id, rootId: row.root_id, relPath: row.rel_path,
|
|
245
|
-
device: row.device, inode: row.inode, target: row.target,
|
|
246
|
-
}));
|
|
281
|
+
.map(rowToLink);
|
|
247
282
|
},
|
|
248
283
|
insertLink({ linkId, rootId, relPath, device, inode, target }) {
|
|
249
284
|
db.prepare('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
|
|
@@ -818,6 +818,20 @@ function createRegistrationService({
|
|
|
818
818
|
const localRecords = boundary.current.registry.syncRecords();
|
|
819
819
|
const localByUuid = new Map(localRecords.map((record) => [record.uuid, record]));
|
|
820
820
|
const cloudByUuid = new Map(cloud.catalog.records.map((record) => [record.uuid, record]));
|
|
821
|
+
function cloudPathOf(uuid) {
|
|
822
|
+
const names = [];
|
|
823
|
+
const seen = new Set();
|
|
824
|
+
let record = cloudByUuid.get(uuid) || null;
|
|
825
|
+
while (record) {
|
|
826
|
+
if (seen.has(record.uuid)) return null;
|
|
827
|
+
seen.add(record.uuid);
|
|
828
|
+
names.unshift(record.name);
|
|
829
|
+
if (record.parentUUID === null) break;
|
|
830
|
+
record = cloudByUuid.get(record.parentUUID) || null;
|
|
831
|
+
if (!record) return null;
|
|
832
|
+
}
|
|
833
|
+
return names.length > 0 ? names.join('/') : null;
|
|
834
|
+
}
|
|
821
835
|
const roots = observer.status().map((root) => {
|
|
822
836
|
const tree = boundary.current.tree(root.path);
|
|
823
837
|
const record = tree ? tree.root() : null;
|
|
@@ -847,6 +861,7 @@ function createRegistrationService({
|
|
|
847
861
|
// `cloudBacked`: the enclosing cloud summary says whether the
|
|
848
862
|
// catalog is actually pinned to an authoritative current head.
|
|
849
863
|
catalogued: cloudByUuid.has(uuid),
|
|
864
|
+
cloudPath: cloudByUuid.has(uuid) ? cloudPathOf(uuid) : null,
|
|
850
865
|
localPath,
|
|
851
866
|
root: rootByUuid.get(uuid) || null,
|
|
852
867
|
};
|
|
@@ -70,15 +70,23 @@ function untrackedFiles(repoPath) {
|
|
|
70
70
|
// its own card; the parent's checkpoint must not contain it.
|
|
71
71
|
if (rel.endsWith('/')) return [];
|
|
72
72
|
const abs = path.join(repoPath, rel);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
try {
|
|
74
|
+
const stat = fs.lstatSync(abs);
|
|
75
|
+
if (stat.isSymbolicLink()) {
|
|
76
|
+
const content = Buffer.from(fs.readlinkSync(abs));
|
|
77
|
+
return [{ path: rel, mode: '120000', content, sha256: sha256(content) }];
|
|
78
|
+
}
|
|
79
|
+
if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
|
|
80
|
+
const content = fs.readFileSync(abs);
|
|
81
|
+
const mode = stat.mode & 0o111 ? '100755' : '100644';
|
|
82
|
+
return [{ path: rel, mode, content, sha256: sha256(content) }];
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Git's file listing is necessarily a point-in-time observation. A file
|
|
85
|
+
// may be removed or renamed before it can be read; the next watch event
|
|
86
|
+
// captures its replacement, while this capture truthfully omits it.
|
|
87
|
+
if (error && error.code === 'ENOENT') return [];
|
|
88
|
+
throw error;
|
|
77
89
|
}
|
|
78
|
-
if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
|
|
79
|
-
const content = fs.readFileSync(abs);
|
|
80
|
-
const mode = stat.mode & 0o111 ? '100755' : '100644';
|
|
81
|
-
return [{ path: rel, mode, content, sha256: sha256(content) }];
|
|
82
90
|
});
|
|
83
91
|
}
|
|
84
92
|
|
|
@@ -655,3 +655,88 @@ test('watcher death rebuilds the doorbell and reconciles the gap once', () => {
|
|
|
655
655
|
fs.watch = realWatch;
|
|
656
656
|
}
|
|
657
657
|
});
|
|
658
|
+
|
|
659
|
+
// --- the scoped scan ------------------------------------------------------
|
|
660
|
+
// A NAMED doorbell earns a look at its own directory, never a walk of the
|
|
661
|
+
// world; anything structural escalates to the full walk unchanged. The
|
|
662
|
+
// injected enrollment policy is the witness: the full walk asks it about
|
|
663
|
+
// every directory's entries, so a scoped look must never ask about a
|
|
664
|
+
// sibling directory's ground.
|
|
665
|
+
|
|
666
|
+
const { fakeWatchKit } = require('./observer.rig');
|
|
667
|
+
|
|
668
|
+
function scopedRig() {
|
|
669
|
+
const dir = makeDir('scoped');
|
|
670
|
+
fs.mkdirSync(path.join(dir, 'a'));
|
|
671
|
+
fs.mkdirSync(path.join(dir, 'b'));
|
|
672
|
+
fs.writeFileSync(path.join(dir, 'a', 'one.md'), 'v1');
|
|
673
|
+
fs.writeFileSync(path.join(dir, 'b', 'two.md'), 'v1');
|
|
674
|
+
const asked = [];
|
|
675
|
+
const bells = fakeWatchKit();
|
|
676
|
+
const { observer, events } = makeObserver({
|
|
677
|
+
settleMs: 15,
|
|
678
|
+
watch: bells.watch,
|
|
679
|
+
shouldEnroll: (relPath) => {
|
|
680
|
+
asked.push(relPath);
|
|
681
|
+
return true;
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
observer.enrollRoot(dir);
|
|
685
|
+
observer.start();
|
|
686
|
+
events.length = 0;
|
|
687
|
+
asked.length = 0;
|
|
688
|
+
return { dir, asked, bells, observer, events };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function settled(events, arrived, deadlineMs = 5_000) {
|
|
692
|
+
const deadline = Date.now() + deadlineMs;
|
|
693
|
+
while (!arrived() && Date.now() < deadline) {
|
|
694
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
695
|
+
}
|
|
696
|
+
return arrived();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
test('a named doorbell scans only its own directory', async () => {
|
|
700
|
+
const { dir, asked, bells, observer, events } = scopedRig();
|
|
701
|
+
fs.writeFileSync(path.join(dir, 'a', 'one.md'), 'v2');
|
|
702
|
+
bells.ring(dir, 'a/one.md');
|
|
703
|
+
assert.ok(await settled(events, () => eventsOf(events, 'file.changed').length > 0));
|
|
704
|
+
observer.stop();
|
|
705
|
+
|
|
706
|
+
const changed = eventsOf(events, 'file.changed');
|
|
707
|
+
assert.equal(changed.length, 1);
|
|
708
|
+
assert.equal(changed[0].path, 'a/one.md');
|
|
709
|
+
assert.ok(asked.includes('a/one.md'), 'the rung directory was read');
|
|
710
|
+
assert.ok(!asked.includes('b/two.md'), `the sibling directory was never walked: ${asked.join(', ')}`);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test('a create under a named doorbell escalates to the full walk', async () => {
|
|
714
|
+
const { dir, asked, bells, observer, events } = scopedRig();
|
|
715
|
+
fs.writeFileSync(path.join(dir, 'a', 'new.md'), 'fresh');
|
|
716
|
+
bells.ring(dir, 'a/new.md');
|
|
717
|
+
assert.ok(await settled(events, () => eventsOf(events, 'file.created').length > 0));
|
|
718
|
+
observer.stop();
|
|
719
|
+
|
|
720
|
+
const created = eventsOf(events, 'file.created');
|
|
721
|
+
assert.equal(created.length, 1);
|
|
722
|
+
assert.equal(created[0].path, 'a/new.md');
|
|
723
|
+
assert.ok(asked.includes('b/two.md'), 'structural news pays for the whole walk');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('a cross-directory move naming only the new side still concludes a rename', async () => {
|
|
727
|
+
const { dir, bells, observer, events } = scopedRig();
|
|
728
|
+
fs.renameSync(path.join(dir, 'a', 'one.md'), path.join(dir, 'b', 'moved.md'));
|
|
729
|
+
// A rename may lawfully deliver only the NEW name: the scoped look sees
|
|
730
|
+
// an orphan entry, refuses to conclude, and the full walk's inode rescue
|
|
731
|
+
// names the move — never a create beside a ghost.
|
|
732
|
+
bells.ring(dir, 'b/moved.md');
|
|
733
|
+
assert.ok(await settled(events, () => eventsOf(events, 'file.renamed').length > 0));
|
|
734
|
+
observer.stop();
|
|
735
|
+
|
|
736
|
+
const renamed = eventsOf(events, 'file.renamed');
|
|
737
|
+
assert.equal(renamed.length, 1);
|
|
738
|
+
assert.equal(renamed[0].from, 'a/one.md');
|
|
739
|
+
assert.equal(renamed[0].to, 'b/moved.md');
|
|
740
|
+
assert.equal(eventsOf(events, 'file.created').length, 0);
|
|
741
|
+
assert.equal(eventsOf(events, 'file.deleted').length, 0);
|
|
742
|
+
});
|
|
@@ -435,6 +435,56 @@ test('a signed-in register call writes every entity delivery intent in identity.
|
|
|
435
435
|
}
|
|
436
436
|
});
|
|
437
437
|
|
|
438
|
+
test('service status exposes only a verified cloud path as a navigable workspace', async () => {
|
|
439
|
+
const service = createRegistrationService({
|
|
440
|
+
dir: path.join(tempRoot, 'state-cloud-status'),
|
|
441
|
+
homeDir: path.join(tempRoot, 'cloud-status-home'),
|
|
442
|
+
cloudUserId: 'user-cloud-status',
|
|
443
|
+
cloudDeviceId: 'computer-cloud-status',
|
|
444
|
+
});
|
|
445
|
+
const bootstrap = service.entityCloud.bootstrap(service.registration.registry.syncRecords());
|
|
446
|
+
service.entityCloud.install({
|
|
447
|
+
registry: service.registration.registry,
|
|
448
|
+
resourceId: bootstrap.resourceId,
|
|
449
|
+
authorityEpoch: 1,
|
|
450
|
+
headVersion: 0,
|
|
451
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
452
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
453
|
+
});
|
|
454
|
+
const ground = makeGround('cloud-status');
|
|
455
|
+
fs.mkdirSync(path.join(ground, 'src'));
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const registered = await service.register(ground);
|
|
459
|
+
const beforeAcknowledgement = service.status();
|
|
460
|
+
assert.equal(beforeAcknowledgement.cloud.catalog.current, false,
|
|
461
|
+
'a pending local registration is visible to status but not a navigable verified cloud workspace');
|
|
462
|
+
|
|
463
|
+
let version = 0;
|
|
464
|
+
for (;;) {
|
|
465
|
+
const [claim] = service.entityCloud.claim();
|
|
466
|
+
if (!claim) break;
|
|
467
|
+
version += 1;
|
|
468
|
+
service.entityCloud.acknowledge({
|
|
469
|
+
resourceId: claim.resourceId,
|
|
470
|
+
mutationId: claim.mutationId,
|
|
471
|
+
version,
|
|
472
|
+
committedAt: '2026-08-05T00:00:00.000Z',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const status = service.status();
|
|
477
|
+
assert.equal(status.cloud.catalog.current, true);
|
|
478
|
+
const root = status.entities.find((entity) => entity.uuid === registered.record.uuid);
|
|
479
|
+
assert.equal(root.cloudPath, path.basename(ground));
|
|
480
|
+
const child = status.entities.find((entity) => entity.cloudPath === `${path.basename(ground)}/src`);
|
|
481
|
+
assert.ok(child, 'the cloud path is derived from canonical parentage, never a local absolute path');
|
|
482
|
+
assert.equal(child.localPath, path.join(ground, 'src'));
|
|
483
|
+
} finally {
|
|
484
|
+
service.close();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
438
488
|
test('a large signed-in register call publishes one complete cloud graph before Watch begins', async () => {
|
|
439
489
|
const service = createRegistrationService({
|
|
440
490
|
dir: path.join(tempRoot, 'state-cloud-register-large'),
|
|
@@ -175,6 +175,27 @@ test('an unborn repo with staged and untracked work round-trips', () => {
|
|
|
175
175
|
assert.equal(state.card.bundle, null);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
test('a file that disappears after Git lists it is omitted instead of aborting capture', () => {
|
|
179
|
+
const source = makeRepo('vanishing-untracked');
|
|
180
|
+
const gone = path.join(source, 'gone-during-capture.md');
|
|
181
|
+
fs.writeFileSync(gone, 'transient');
|
|
182
|
+
const originalLstat = fs.lstatSync;
|
|
183
|
+
fs.lstatSync = (candidate, ...args) => {
|
|
184
|
+
if (candidate === gone) {
|
|
185
|
+
const error = new Error('gone');
|
|
186
|
+
error.code = 'ENOENT';
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return originalLstat(candidate, ...args);
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const state = capture(source, { withBundle: false });
|
|
193
|
+
assert.equal(state.checkpoint.untracked.some((file) => file.path === 'gone-during-capture.md'), false);
|
|
194
|
+
} finally {
|
|
195
|
+
fs.lstatSync = originalLstat;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
178
199
|
// --- identity ------------------------------------------------------------------
|
|
179
200
|
|
|
180
201
|
test('capture is deterministic: same state, same ids, twice', () => {
|