@ran-sh/dsh-crew 1.3.0 → 1.3.2
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/package.json
CHANGED
package/src/dsh-cli-runtime.mjs
CHANGED
|
@@ -784,6 +784,75 @@ function ensureProfileScaffold(profileRoot) {
|
|
|
784
784
|
return changed;
|
|
785
785
|
}
|
|
786
786
|
|
|
787
|
+
function readPackageVersionAt(dir, read = readFileSync) {
|
|
788
|
+
try {
|
|
789
|
+
const parsed = JSON.parse(read(join(dir, 'package.json'), 'utf8'));
|
|
790
|
+
return typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
|
|
791
|
+
} catch { return null; }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Nearest `<dir>/node_modules/<name>` walking up from `dir`, mirroring the
|
|
795
|
+
// resolution a require() from that directory would take.
|
|
796
|
+
export function resolveNearestBundleVersion(dir, name, { exists = defaultExists, read = readFileSync } = {}) {
|
|
797
|
+
let cursor = dir;
|
|
798
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
799
|
+
const candidate = join(cursor, 'node_modules', ...name.split('/'));
|
|
800
|
+
if (exists(candidate)) return readPackageVersionAt(candidate, read);
|
|
801
|
+
const parent = dirname(cursor);
|
|
802
|
+
if (parent === cursor) break;
|
|
803
|
+
cursor = parent;
|
|
804
|
+
}
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Stop a profile from resolving its bundle packages out of a foreign cohort.
|
|
810
|
+
*
|
|
811
|
+
* A profile first installed against a source checkout records `dsh-base` and
|
|
812
|
+
* `dsh-web-app` as links into that checkout. Switching the CLI to another
|
|
813
|
+
* cohort leaves those links behind, and because they sit in the profile's own
|
|
814
|
+
* node_modules they shadow the matching packages installed beside the profile.
|
|
815
|
+
* The host then composes the web app of one cohort while the plugin table comes
|
|
816
|
+
* from another; the client module table is built per cohort, so the mismatched
|
|
817
|
+
* plugin dies at require time with "missed the module table".
|
|
818
|
+
*
|
|
819
|
+
* Only an entry whose fallthrough provably resolves the target cohort is
|
|
820
|
+
* removed. A profile that deliberately tracks a different cohort keeps its
|
|
821
|
+
* links and is reported as skipped, never as an error.
|
|
822
|
+
*/
|
|
823
|
+
export function reconcileProfileBundleCohort({
|
|
824
|
+
home = homedir(),
|
|
825
|
+
profileRoot,
|
|
826
|
+
version = TARGET_DSH_VERSION,
|
|
827
|
+
bundles = CREW_PROFILE_DEFAULT_BUNDLES,
|
|
828
|
+
exists = defaultExists,
|
|
829
|
+
read = readFileSync,
|
|
830
|
+
remove = (path) => rmSync(path, { recursive: true, force: true }),
|
|
831
|
+
} = {}) {
|
|
832
|
+
const root = profileRoot ?? crewProfileDir({ home });
|
|
833
|
+
if (typeof root !== 'string' || !isAbsolute(root)) return { ok: false, code: 'INVALID_PROFILE_ROOT' };
|
|
834
|
+
const modulesDir = join(root, 'node_modules');
|
|
835
|
+
const parentDir = dirname(root);
|
|
836
|
+
const unshadowed = [];
|
|
837
|
+
const skipped = [];
|
|
838
|
+
const removeBundle = (path) => remove(path);
|
|
839
|
+
for (const name of bundles) {
|
|
840
|
+
const shadowPath = join(modulesDir, ...name.split('/'));
|
|
841
|
+
if (!exists(shadowPath)) continue;
|
|
842
|
+
const shadowed = readPackageVersionAt(shadowPath, read);
|
|
843
|
+
if (shadowed === version) continue;
|
|
844
|
+
const fallthrough = resolveNearestBundleVersion(parentDir, name, { exists, read });
|
|
845
|
+
if (fallthrough !== version) {
|
|
846
|
+
skipped.push({ bundle: name, shadowed: shadowed ?? null, fallthrough: fallthrough ?? null });
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
try { removeBundle(shadowPath); } catch { return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name }; }
|
|
850
|
+
if (exists(shadowPath)) return { ok: false, code: 'PROFILE_BUNDLE_UNSHADOW_FAILED', profileRoot: root, bundle: name };
|
|
851
|
+
unshadowed.push({ bundle: name, shadowed: shadowed ?? null });
|
|
852
|
+
}
|
|
853
|
+
return { ok: true, changed: unshadowed.length > 0, profileRoot: root, unshadowed, skipped };
|
|
854
|
+
}
|
|
855
|
+
|
|
787
856
|
function readCrewTombstones(home) {
|
|
788
857
|
const lifecycleFile = join(dirname(crewDshHome({ home })), 'provider-lifecycle.json');
|
|
789
858
|
if (!existsSync(lifecycleFile)) return { ok: true, tombstones: {} };
|
|
@@ -894,9 +963,16 @@ export function ensurePluginRegistration({
|
|
|
894
963
|
export function ensureCrewPluginRegistration({ home = homedir(), root, name } = {}) {
|
|
895
964
|
const result = ensurePluginRegistration({ profileRoot: crewProfileDir({ home }), root, name });
|
|
896
965
|
if (!result.ok) return result;
|
|
966
|
+
const reconciliation = reconcileProfileBundleCohort({ home, profileRoot: result.profileRoot });
|
|
967
|
+
if (!reconciliation.ok) return reconciliation;
|
|
897
968
|
const reconciled = reconcileCrewProfileProviders({ home, profileRoot: result.profileRoot });
|
|
898
969
|
if (!reconciled.ok) return reconciled;
|
|
899
|
-
return {
|
|
970
|
+
return {
|
|
971
|
+
...result,
|
|
972
|
+
changed: result.changed || reconciled.changed || reconciliation.changed,
|
|
973
|
+
provider_reconciliation: reconciled.removed ?? [],
|
|
974
|
+
bundle_reconciliation: reconciliation.unshadowed ?? [],
|
|
975
|
+
};
|
|
900
976
|
}
|
|
901
977
|
|
|
902
978
|
/**
|
|
@@ -13,6 +13,22 @@ function method(object, name) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Whether one registered agent currently owns a running turn.
|
|
18
|
+
*
|
|
19
|
+
* Registration is not activity: the session controller resumes a session into
|
|
20
|
+
* the registry as soon as it opens it, and leaves it there after the turn ends
|
|
21
|
+
* with `status: 'idle'`. Treating a non-empty registry as "in use" therefore
|
|
22
|
+
* wedged cleanup permanently behind ACTIVE_SESSIONS on a machine whose web UI had
|
|
23
|
+
* ever opened a session. Only a `running` status means work is actually in
|
|
24
|
+
* flight; anything else (idle, or a status an older agent does not expose) is
|
|
25
|
+
* treated as not running, and `creating > 0` remains the independent guard for a
|
|
26
|
+
* creation that has not published yet.
|
|
27
|
+
*/
|
|
28
|
+
function isRunning(agent) {
|
|
29
|
+
try { return agent?.status === 'running'; } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
export function installHistoryAdmissionGate(agents, isMaintenancePending) {
|
|
17
33
|
if (typeof agents?.create !== 'function' || typeof agents?.list !== 'function'
|
|
18
34
|
|| typeof isMaintenancePending !== 'function') throw new Error('HISTORY_ADMISSION_UNAVAILABLE');
|
|
@@ -40,7 +56,7 @@ export function installHistoryAdmissionGate(agents, isMaintenancePending) {
|
|
|
40
56
|
if (entries.some(entry => method(agents, entry.name) !== entry.wrapper)) return false;
|
|
41
57
|
try {
|
|
42
58
|
const live = agents.list();
|
|
43
|
-
return Array.isArray(live) && creating === 0 && live.
|
|
59
|
+
return Array.isArray(live) && creating === 0 && !live.some(isRunning);
|
|
44
60
|
} catch { return false; }
|
|
45
61
|
},
|
|
46
62
|
dispose() {
|
|
@@ -26,10 +26,21 @@ function pathInside(root, relativePath) {
|
|
|
26
26
|
return current;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// One canonical session artifact path. The jsonl backend keeps one immutable
|
|
30
|
+
// file per Session format generation: `session.jsonl` for version 0 and
|
|
31
|
+
// `session.vN.jsonl` (N >= 1) after that, each optionally zstd-compressed.
|
|
32
|
+
// Sharing the shape between the inventory and the archive guard keeps both in
|
|
33
|
+
// step when the backend's naming changes.
|
|
34
|
+
export const SESSION_ARTIFACT_PATTERN = /^harness\/sessions\/[^/]+\/[^/]+\/session(?:\.v[1-9][0-9]*)?\.jsonl(?:\.zstd)?$/;
|
|
35
|
+
|
|
36
|
+
export function isSessionArtifactPath(relativePath, sessionId) {
|
|
37
|
+
return typeof relativePath === 'string'
|
|
38
|
+
&& SESSION_ARTIFACT_PATTERN.test(relativePath)
|
|
39
|
+
&& relativePath.split('/')[3] === sessionId;
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
function artifactPath(root, file) {
|
|
30
|
-
if (!validId(file?.sessionId) ||
|
|
31
|
-
|| !/^harness\/sessions\/[^/]+\/[^/]+\/session\.jsonl(?:\.zstd)?$/.test(file.relativePath)
|
|
32
|
-
|| file.relativePath.split('/')[3] !== file.sessionId) fail('INVALID_ARTIFACT');
|
|
43
|
+
if (!validId(file?.sessionId) || !isSessionArtifactPath(file.relativePath, file.sessionId)) fail('INVALID_ARTIFACT');
|
|
33
44
|
return pathInside(root, file.relativePath);
|
|
34
45
|
}
|
|
35
46
|
|
package/src/history/service.mjs
CHANGED
|
@@ -3,36 +3,65 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
3
3
|
import { relative } from 'node:path';
|
|
4
4
|
import { planHistoryCleanup } from './cleanup-plan.mjs';
|
|
5
5
|
import { installHistoryAdmissionGate } from './admission-gate.mjs';
|
|
6
|
-
import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest } from './archive-store.mjs';
|
|
6
|
+
import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
|
|
7
7
|
import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
|
|
8
8
|
|
|
9
9
|
export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
|
|
10
10
|
const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
|
|
11
11
|
const plans = new Map();
|
|
12
12
|
let entering = false;
|
|
13
|
+
// One row per materialized session: the header to archive and the artifact
|
|
14
|
+
// location the backend reported. The 0.1.5 jsonl backend resolves each
|
|
15
|
+
// session's current immutable generation itself and hands back a concrete
|
|
16
|
+
// path through `listArtifacts()`. The earlier backend listed snapshots and
|
|
17
|
+
// only hinted at a location, leaving the encoding to be probed below.
|
|
18
|
+
async function readInventory() {
|
|
19
|
+
if (typeof persistence?.listArtifacts === 'function') {
|
|
20
|
+
const artifacts = await persistence.listArtifacts();
|
|
21
|
+
if (!Array.isArray(artifacts)) throw Error('HISTORY_INVENTORY_INVALID');
|
|
22
|
+
return artifacts.map(({ header, path }) => {
|
|
23
|
+
if (typeof path !== 'string') throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
24
|
+
return { header, located: path, resolved: true };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (typeof persistence?.listSnapshots === 'function') {
|
|
28
|
+
const rows = await persistence.listSnapshots();
|
|
29
|
+
if (!Array.isArray(rows)) throw Error('HISTORY_INVENTORY_INVALID');
|
|
30
|
+
return rows.map(({ header }) => {
|
|
31
|
+
const location = persistence.locate(header);
|
|
32
|
+
if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
33
|
+
return { header, located: location.path, resolved: false };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
37
|
+
}
|
|
13
38
|
async function snapshot(options) {
|
|
14
|
-
if (typeof persistence?.
|
|
15
|
-
|
|
39
|
+
if (typeof persistence?.locate !== 'function') throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
40
|
+
// Refuse only a backend that explicitly declares it exposes no per-session
|
|
41
|
+
// raw artifact. The 0.1.5 backend dropped the flag rather than setting it
|
|
42
|
+
// false, and the artifact path itself is validated below either way.
|
|
43
|
+
if (persistence.supportsRawArtifacts === false) throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
16
44
|
const bytes = readHistoryBytes(historyPath(crewRoot, 'harness/storages/workspace.json'));
|
|
17
45
|
const store = decodeWorkspaceStore(bytes);
|
|
18
|
-
const listed = await
|
|
19
|
-
if (
|
|
46
|
+
const listed = await readInventory();
|
|
47
|
+
if (listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
|
|
20
48
|
let total = 0;
|
|
21
|
-
const sessions = listed.map(({ header,
|
|
49
|
+
const sessions = listed.map(({ header, located, resolved }) => {
|
|
22
50
|
if (!header || typeof header.id !== 'string') throw Error('HISTORY_INVENTORY_INVALID');
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
let path = relative(crewRoot, located).replaceAll('\\', '/');
|
|
52
|
+
if (!isSessionArtifactPath(path, header.id)) throw Error('HISTORY_STORAGE_UNSUPPORTED');
|
|
53
|
+
if (!resolved) {
|
|
54
|
+
// An unresolved location is a hint that may not name the encoding that
|
|
55
|
+
// actually exists, so exactly one candidate must be present on disk.
|
|
56
|
+
const plain = path.replace(/\.zstd$/, '');
|
|
57
|
+
const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
|
|
58
|
+
if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
|
|
59
|
+
path = existing[0];
|
|
60
|
+
}
|
|
32
61
|
const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
|
|
33
62
|
if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
|
|
34
63
|
return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
|
|
35
|
-
revision: JSON.stringify([revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
|
|
64
|
+
revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
|
|
36
65
|
});
|
|
37
66
|
// A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
|
|
38
67
|
const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '1.3.
|
|
39
|
+
export const RUNTIME_VERSION = '1.3.2';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|