wicked-crew 0.8.3 → 0.8.4
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/api/skills.d.ts.map +1 -1
- package/dist/api/skills.js +16 -8
- package/dist/api/skills.js.map +1 -1
- package/dist/skills/runtime.d.ts +13 -2
- package/dist/skills/runtime.d.ts.map +1 -1
- package/dist/skills/runtime.js +32 -5
- package/dist/skills/runtime.js.map +1 -1
- package/dist/skills/store.d.ts +57 -5
- package/dist/skills/store.d.ts.map +1 -1
- package/dist/skills/store.js +161 -42
- package/dist/skills/store.js.map +1 -1
- package/dist/skills/tree.d.ts +39 -0
- package/dist/skills/tree.d.ts.map +1 -1
- package/dist/skills/tree.js +102 -13
- package/dist/skills/tree.js.map +1 -1
- package/dist/studio/assets/{index-BKkB4En4.js → index-B-ce66JW.js} +120 -120
- package/dist/studio/assets/index-TvS52qj5.css +32 -0
- package/dist/studio/editors/wicked-page/editor.json +1 -1
- package/dist/studio/editors/wicked-page/index.html +1 -1
- package/dist/studio/index.html +2 -2
- package/dist/studio/testid-inventory.json +96 -5
- package/endpoint-manifest.json +1 -1
- package/package.json +2 -2
- package/dist/studio/assets/index-CGjbTpLl.css +0 -32
package/dist/skills/store.js
CHANGED
|
@@ -144,7 +144,7 @@ import { LiveGenerations } from './live-generations.js';
|
|
|
144
144
|
import { discoverLivePluginDetailed, GARDEN_INSTALL_COMMAND, gitStateOf, PluginSourceSymlinkError, REQUIRED_GARDEN_VERSION, } from './plugin-source.js';
|
|
145
145
|
import { existsIn, extractPluginRootRefs, extractRelativeRefs, looksBinary, PORTABILITY_EVIDENCE_CAP, PORTABILITY_REASONS, PORTABILITY_RULES_IDENTITY, portabilityEvidenceOf, portabilityIssuesOf, portabilityReasonsOf, resolvePluginRootRef, resolveRelativeRef, } from './refs.js';
|
|
146
146
|
import { CURRENT_TMP_PREFIX, STAGING_PREFIX } from './root-names.js';
|
|
147
|
-
import { assertNoSymlinkComponents, copyFiles, EntrySwappedError,
|
|
147
|
+
import { assertNoSymlinkComponents, budgetPacer, copyFiles, copyFilesSteps, drain, EntrySwappedError, fingerprintTreeSteps, hashFileSet, hashTreeSteps, impliedDirs, isCarriedFile, makeTreeReadOnly, makeTreeReadOnlySteps, paced, pruneEmptyDirs, readFileNoFollow, removeTreeForce, removeTreeForcePaced, sha256Hex, SKIP_DIR_NAMES, SymlinkComponentError, walkEntriesSteps, walkFiles, walkTree, walkTreeSteps, writeFileAtomic, } from './tree.js';
|
|
148
148
|
/** The root-level names the store creates live in ONE table (`root-names.ts`, design v3.5 §2) — re-exported for the tests that observe the flip. */
|
|
149
149
|
export { CURRENT_TMP_PREFIX } from './root-names.js';
|
|
150
150
|
import { baselineVenvDir, UV_CACHE_DIRNAME, VENV_READY_MARKER } from './venv.js';
|
|
@@ -512,6 +512,7 @@ export class SkillsStore {
|
|
|
512
512
|
live = new LiveGenerations();
|
|
513
513
|
/** The one publish that may run at a time (module header) — `null` when none is in flight. */
|
|
514
514
|
publishInFlight = null;
|
|
515
|
+
makePacer;
|
|
515
516
|
/** One provisioning per baseline hash: a concurrent caller awaits the in-flight one. */
|
|
516
517
|
venvInFlight = new Map();
|
|
517
518
|
/**
|
|
@@ -554,6 +555,7 @@ export class SkillsStore {
|
|
|
554
555
|
});
|
|
555
556
|
this.now = opts.now ?? (() => new Date().toISOString());
|
|
556
557
|
this.warn = opts.warn ?? ((m) => console.warn(m));
|
|
558
|
+
this.makePacer = opts.pacer ?? (() => budgetPacer());
|
|
557
559
|
}
|
|
558
560
|
// ── Paths ─────────────────────────────────────────────────────────────────────────────────
|
|
559
561
|
get root() {
|
|
@@ -851,6 +853,18 @@ export class SkillsStore {
|
|
|
851
853
|
if (expected !== m.revision)
|
|
852
854
|
throw new RevisionMismatchError(expected, m.revision);
|
|
853
855
|
}
|
|
856
|
+
/**
|
|
857
|
+
* A MUTATION while a publish runs is refused before it touches anything (crew#852): the publish
|
|
858
|
+
* now yields to the event loop between tree steps, so an API write could otherwise commit between
|
|
859
|
+
* its validation and its commit — the publish would then either fail its own CAS or stage content
|
|
860
|
+
* its manifest no longer describes. Same envelope as a second publish (`publish-in-flight`, 2xx,
|
|
861
|
+
* nothing written — re-read and retry); before the pacing a write in that window queued behind
|
|
862
|
+
* the blocked loop and failed its CAS after the publish landed. Reads are never refused.
|
|
863
|
+
*/
|
|
864
|
+
refuseDuringPublish() {
|
|
865
|
+
if (this.publishInFlight !== null)
|
|
866
|
+
throw new SkillsPublishInFlightError(this.revision());
|
|
867
|
+
}
|
|
854
868
|
// ── `current` ─────────────────────────────────────────────────────────────────────────────
|
|
855
869
|
/**
|
|
856
870
|
* The published snapshot `current` resolves to, VERIFIED (module header), or `null` when there
|
|
@@ -861,6 +875,18 @@ export class SkillsStore {
|
|
|
861
875
|
* re-derived each time it is handed out.
|
|
862
876
|
*/
|
|
863
877
|
currentSnapshot() {
|
|
878
|
+
return drain(this.currentSnapshotSteps());
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* `currentSnapshot()` on the event loop (crew#852): the same verification, step for step, with
|
|
882
|
+
* its walk, fingerprint and hash paced so a request landing mid-verification is answered within
|
|
883
|
+
* one slice instead of after the whole generation. The route layer's publish and `GET /skills`
|
|
884
|
+
* drive this one; boot and the synchronous readers keep `currentSnapshot()`.
|
|
885
|
+
*/
|
|
886
|
+
async currentSnapshotPaced(pacer) {
|
|
887
|
+
return paced(this.currentSnapshotSteps(), pacer);
|
|
888
|
+
}
|
|
889
|
+
*currentSnapshotSteps() {
|
|
864
890
|
this.assertRootIdentity(); // never answered through a root that changed identity (codex round 4)
|
|
865
891
|
const link = this.currentLink();
|
|
866
892
|
let target;
|
|
@@ -874,7 +900,7 @@ export class SkillsStore {
|
|
|
874
900
|
throw new SkillsCurrentInvalidError(link, 'it is not a symbolic link');
|
|
875
901
|
throw err;
|
|
876
902
|
}
|
|
877
|
-
return this.
|
|
903
|
+
return yield* this.verifyCurrentSteps(link, target);
|
|
878
904
|
}
|
|
879
905
|
/**
|
|
880
906
|
* The skill rows of the VERIFIED current generation — what the engine's intake admission judges
|
|
@@ -887,12 +913,16 @@ export class SkillsStore {
|
|
|
887
913
|
const current = this.currentSnapshot();
|
|
888
914
|
if (current === null)
|
|
889
915
|
return null;
|
|
916
|
+
return this.snapshotSkillsOf(current);
|
|
917
|
+
}
|
|
918
|
+
/** `currentSnapshotSkills()` for a generation the caller holds VERIFIED already (the ladder's own, just exported) — the metadata read alone, no re-walk (crew#852). */
|
|
919
|
+
snapshotSkillsOf(current) {
|
|
890
920
|
const meta = this.readSnapshotMetadata(current.path);
|
|
891
921
|
if (typeof meta === 'string')
|
|
892
922
|
return null;
|
|
893
923
|
return { gen: current.gen, skills: meta.parsed.skills.map((row) => row.name) };
|
|
894
924
|
}
|
|
895
|
-
|
|
925
|
+
*verifyCurrentSteps(link, target) {
|
|
896
926
|
const invalid = (detail) => {
|
|
897
927
|
throw new SkillsCurrentInvalidError(link, detail);
|
|
898
928
|
};
|
|
@@ -973,7 +1003,7 @@ export class SkillsStore {
|
|
|
973
1003
|
// moves during verification pairs the verified result with a fingerprint that no longer
|
|
974
1004
|
// matches, so the next read re-verifies) and once more AFTER it; the result is memoised only
|
|
975
1005
|
// when both agree.
|
|
976
|
-
const fingerprint =
|
|
1006
|
+
const fingerprint = yield* fingerprintTreeSteps(real);
|
|
977
1007
|
const memo = this.verifiedCurrent;
|
|
978
1008
|
if (memo !== null &&
|
|
979
1009
|
fingerprint !== null &&
|
|
@@ -989,7 +1019,7 @@ export class SkillsStore {
|
|
|
989
1019
|
return invalid(linkProblem);
|
|
990
1020
|
return memo.result;
|
|
991
1021
|
}
|
|
992
|
-
const tree =
|
|
1022
|
+
const tree = yield* walkTreeSteps(real);
|
|
993
1023
|
const special = tree.others[0];
|
|
994
1024
|
if (special !== undefined)
|
|
995
1025
|
return invalid(`${special.rel} is neither a file, a directory nor a symlink — a published generation carries no special nodes`);
|
|
@@ -1001,7 +1031,7 @@ export class SkillsStore {
|
|
|
1001
1031
|
if (prunedDir !== undefined) {
|
|
1002
1032
|
return invalid(`unexpected directory ${prunedDir} — a published generation never carries a ${posix.basename(prunedDir)} directory (publish copies no pruned directory; the environment is the .venv link at the generation root): the immutable snapshot was modified`);
|
|
1003
1033
|
}
|
|
1004
|
-
const hash = this.
|
|
1034
|
+
const hash = yield* this.snapshotHashSteps(tree);
|
|
1005
1035
|
if (hash !== parsed.contentHash) {
|
|
1006
1036
|
return invalid(`content hash mismatch — snapshot.json records ${parsed.contentHash}, the tree hashes ${hash}: the immutable snapshot was modified`);
|
|
1007
1037
|
}
|
|
@@ -1014,14 +1044,14 @@ export class SkillsStore {
|
|
|
1014
1044
|
const running = { ...PORTABILITY_RULES_IDENTITY };
|
|
1015
1045
|
const recorded = parsed.rulesVersion === undefined || parsed.rulesSha256 === undefined ? null : { version: parsed.rulesVersion, sha256: parsed.rulesSha256 };
|
|
1016
1046
|
const stale = recorded === null || recorded.version !== running.version || recorded.sha256 !== running.sha256;
|
|
1017
|
-
const rows = this.
|
|
1047
|
+
const rows = yield* this.snapshotRowsProblemSteps(parsed, tree, stale);
|
|
1018
1048
|
if ('problem' in rows)
|
|
1019
1049
|
return invalid(rows.problem);
|
|
1020
1050
|
const linkProblem = this.snapshotLinkProblem(real, tree.links, parsed);
|
|
1021
1051
|
if (linkProblem !== null)
|
|
1022
1052
|
return invalid(linkProblem);
|
|
1023
1053
|
const result = { gen: parsed.gen, path: real, rules: { recorded, running, stale }, drift: rows.drift };
|
|
1024
|
-
const after =
|
|
1054
|
+
const after = yield* fingerprintTreeSteps(real);
|
|
1025
1055
|
this.verifiedCurrent =
|
|
1026
1056
|
fingerprint !== null && after === fingerprint
|
|
1027
1057
|
? { gen: parsed.gen, contentHash: parsed.contentHash, snapshotHash: meta.rawSha, fingerprint, links: [...tree.links], result }
|
|
@@ -1045,7 +1075,7 @@ export class SkillsStore {
|
|
|
1045
1075
|
* else (a missing SKILL.md, a kind claim, the view shape against the RECORDED rows) is judged the
|
|
1046
1076
|
* same under any rules: none of it depends on the rule table.
|
|
1047
1077
|
*/
|
|
1048
|
-
|
|
1078
|
+
*snapshotRowsProblemSteps(parsed, tree, staleRules) {
|
|
1049
1079
|
const problem = (detail) => ({ problem: detail });
|
|
1050
1080
|
const drift = [];
|
|
1051
1081
|
const files = tree.files;
|
|
@@ -1061,6 +1091,7 @@ export class SkillsStore {
|
|
|
1061
1091
|
if (skillMd === undefined)
|
|
1062
1092
|
return problem(`skill row ${row.name} names ${row.dir}, but the generation carries no ${row.dir}/SKILL.md`);
|
|
1063
1093
|
const fm = parseFrontmatter(readFileNoFollow(skillMd.abs).toString('utf8'));
|
|
1094
|
+
yield;
|
|
1064
1095
|
if (!fm.ok)
|
|
1065
1096
|
return problem(`${row.dir}/SKILL.md frontmatter does not parse (${fm.reason}) — its row cannot be re-derived`);
|
|
1066
1097
|
const kind = skillKindOf(fm.fields);
|
|
@@ -1072,6 +1103,7 @@ export class SkillsStore {
|
|
|
1072
1103
|
if (!f.rel.startsWith(prefix) || owningSkillDir(f.rel, dirs) !== row.dir)
|
|
1073
1104
|
continue;
|
|
1074
1105
|
const buf = readFileNoFollow(f.abs);
|
|
1106
|
+
yield;
|
|
1075
1107
|
if (looksBinary(buf))
|
|
1076
1108
|
continue;
|
|
1077
1109
|
for (const hit of portabilityIssuesOf(buf.toString('utf8'), { fileRel: f.rel, skillDir: row.dir, skillDirs: dirs, exists }))
|
|
@@ -1132,7 +1164,10 @@ export class SkillsStore {
|
|
|
1132
1164
|
}
|
|
1133
1165
|
/** Hash over a snapshot tree — files (`snapshot.json` excluded), link entries (path + link text) AND directory entries (codex round 9: an extra empty directory changes it). */
|
|
1134
1166
|
snapshotHash(tree) {
|
|
1135
|
-
return
|
|
1167
|
+
return drain(this.snapshotHashSteps(tree));
|
|
1168
|
+
}
|
|
1169
|
+
*snapshotHashSteps(tree) {
|
|
1170
|
+
return yield* hashTreeSteps(tree.files.filter((f) => f.rel !== SNAPSHOT_MANIFEST_FILENAME), tree.links, tree.dirs);
|
|
1136
1171
|
}
|
|
1137
1172
|
/** The link text publish writes for a snapshot's `.venv`: relative on POSIX, the absolute target for a Windows junction. */
|
|
1138
1173
|
venvLinkText(baseline) {
|
|
@@ -1443,6 +1478,10 @@ export class SkillsStore {
|
|
|
1443
1478
|
* covers it, and no other pruned name is linked from anywhere.
|
|
1444
1479
|
*/
|
|
1445
1480
|
baselineProblem(hash) {
|
|
1481
|
+
return drain(this.baselineProblemSteps(hash));
|
|
1482
|
+
}
|
|
1483
|
+
/** `baselineProblem` as steps (crew#852): it walks AND re-hashes the whole bundle — the walk's yields, then one per file hashed. */
|
|
1484
|
+
*baselineProblemSteps(hash) {
|
|
1446
1485
|
const dir = this.baselineDir(hash);
|
|
1447
1486
|
const st = lstatOrNull(dir);
|
|
1448
1487
|
if (st === null)
|
|
@@ -1453,7 +1492,7 @@ export class SkillsStore {
|
|
|
1453
1492
|
return `${dir} is not a directory`;
|
|
1454
1493
|
let tree;
|
|
1455
1494
|
try {
|
|
1456
|
-
tree =
|
|
1495
|
+
tree = yield* walkTreeSteps(dir);
|
|
1457
1496
|
}
|
|
1458
1497
|
catch (err) {
|
|
1459
1498
|
return `${dir} cannot be walked (${err instanceof Error ? err.message : String(err)})`;
|
|
@@ -1464,7 +1503,7 @@ export class SkillsStore {
|
|
|
1464
1503
|
const special = tree.others[0];
|
|
1465
1504
|
if (special !== undefined)
|
|
1466
1505
|
return `${dir} carries ${special.rel}, which is neither a file nor a directory`;
|
|
1467
|
-
const actual =
|
|
1506
|
+
const actual = yield* hashTreeSteps(tree.files, []);
|
|
1468
1507
|
if (actual !== hash)
|
|
1469
1508
|
return `${dir} hashes to ${actual}, not to its name — a bundle file was modified, added or removed`;
|
|
1470
1509
|
return null;
|
|
@@ -1774,6 +1813,10 @@ export class SkillsStore {
|
|
|
1774
1813
|
// ── Scanning ──────────────────────────────────────────────────────────────────────────────
|
|
1775
1814
|
/** Every managed file under `effective/`, hashed. */
|
|
1776
1815
|
scanEffective() {
|
|
1816
|
+
return drain(this.scanEffectiveSteps());
|
|
1817
|
+
}
|
|
1818
|
+
/** `scanEffective` as steps (crew#852): the walk's yields, then one per file hashed. */
|
|
1819
|
+
*scanEffectiveSteps() {
|
|
1777
1820
|
this.assertRootIdentity();
|
|
1778
1821
|
// ONE walker classifies every entry (tree.ts `walkEntries`; codex round 9): the files are hashed,
|
|
1779
1822
|
// the links and special nodes are handed to the validation to refuse by name, the directories are
|
|
@@ -1782,9 +1825,16 @@ export class SkillsStore {
|
|
|
1782
1825
|
// `node_modules/`, `.venv/` or `__pycache__/` is in `links` / `others` (marked `pruned`) for the
|
|
1783
1826
|
// validation to refuse; the files and directories beneath one stay out of the scan — never
|
|
1784
1827
|
// carried, never hashed, exactly as before.
|
|
1785
|
-
const entries =
|
|
1828
|
+
const entries = yield* walkEntriesSteps(this.effectiveDir());
|
|
1829
|
+
const files = [];
|
|
1830
|
+
for (const e of entries) {
|
|
1831
|
+
if (!isCarriedFile(e))
|
|
1832
|
+
continue;
|
|
1833
|
+
files.push({ rel: e.rel, abs: e.abs, sha: sha256Hex(readFileNoFollow(e.abs)) });
|
|
1834
|
+
yield;
|
|
1835
|
+
}
|
|
1786
1836
|
return {
|
|
1787
|
-
files
|
|
1837
|
+
files,
|
|
1788
1838
|
links: entries.filter((e) => e.kind === 'symlink'),
|
|
1789
1839
|
others: entries.filter((e) => e.kind === 'other'),
|
|
1790
1840
|
dirs: entries.filter((e) => e.kind === 'dir' && e.pruned === null).map((e) => e.rel),
|
|
@@ -1938,6 +1988,10 @@ export class SkillsStore {
|
|
|
1938
1988
|
* blocking; a mutation on ANOTHER skill carries it as a warning (that mutation did land).
|
|
1939
1989
|
*/
|
|
1940
1990
|
recomputeDerived(m) {
|
|
1991
|
+
return drain(this.recomputeDerivedSteps(m));
|
|
1992
|
+
}
|
|
1993
|
+
/** `recomputeDerived` as steps (crew#852): it reads every file of every skill for the portability scan — one `yield` per file read, one per skill. */
|
|
1994
|
+
*recomputeDerivedSteps(m) {
|
|
1941
1995
|
const refused = [];
|
|
1942
1996
|
const catalogMd = new Map();
|
|
1943
1997
|
const byOwner = this.recordsByOwner(m);
|
|
@@ -1996,6 +2050,7 @@ export class SkillsStore {
|
|
|
1996
2050
|
refused.push(this.pathFinding(err, name, rel));
|
|
1997
2051
|
continue;
|
|
1998
2052
|
}
|
|
2053
|
+
yield;
|
|
1999
2054
|
if (buf === null || looksBinary(buf))
|
|
2000
2055
|
continue;
|
|
2001
2056
|
for (const hit of portabilityIssuesOf(buf.toString('utf8'), ctx(rel)))
|
|
@@ -2004,6 +2059,7 @@ export class SkillsStore {
|
|
|
2004
2059
|
const reasons = portabilityReasonsOf(hits);
|
|
2005
2060
|
entry.portable = reasons.length === 0;
|
|
2006
2061
|
entry.portability = { portable: entry.portable, reasons, evidence: portabilityEvidenceOf(hits) };
|
|
2062
|
+
yield;
|
|
2007
2063
|
}
|
|
2008
2064
|
// A DIRECTLY registered reference is core regardless of its readability (design v3.5 §5; codex
|
|
2009
2065
|
// round 8): a skill a workflow names by `skill_ref` keeps `core: true` when its `SKILL.md` is
|
|
@@ -2290,6 +2346,7 @@ export class SkillsStore {
|
|
|
2290
2346
|
const entry = m.skills[name];
|
|
2291
2347
|
if (entry === undefined)
|
|
2292
2348
|
throw new UnknownSkillError(name);
|
|
2349
|
+
this.refuseDuringPublish();
|
|
2293
2350
|
this.assertRevision(m, expectedRevision);
|
|
2294
2351
|
return entry;
|
|
2295
2352
|
}
|
|
@@ -2507,6 +2564,7 @@ export class SkillsStore {
|
|
|
2507
2564
|
/** Write one root support file (`scripts/`, `schemas/`, `.claude-plugin/`, …) — always a warning. */
|
|
2508
2565
|
writeSupport(rawRel, content, expectedRevision) {
|
|
2509
2566
|
const m = this.manifest();
|
|
2567
|
+
this.refuseDuringPublish();
|
|
2510
2568
|
this.assertRevision(m, expectedRevision);
|
|
2511
2569
|
let target;
|
|
2512
2570
|
try {
|
|
@@ -2742,6 +2800,7 @@ export class SkillsStore {
|
|
|
2742
2800
|
/** Add a user skill at `skills/<name minus the prefix>` — staged, then swapped in (`swapOwnFiles`). */
|
|
2743
2801
|
add(name, files, expectedRevision) {
|
|
2744
2802
|
const m = this.manifest();
|
|
2803
|
+
this.refuseDuringPublish();
|
|
2745
2804
|
this.assertRevision(m, expectedRevision);
|
|
2746
2805
|
const findings = this.addGuards(m, name, files);
|
|
2747
2806
|
if (verdictOf(findings) === 'blocked')
|
|
@@ -2920,6 +2979,7 @@ export class SkillsStore {
|
|
|
2920
2979
|
*/
|
|
2921
2980
|
refreshBaseline(expectedRevision) {
|
|
2922
2981
|
const m = this.manifest();
|
|
2982
|
+
this.refuseDuringPublish();
|
|
2923
2983
|
this.assertRevision(m, expectedRevision);
|
|
2924
2984
|
const source = this.requireSource('no installed wicked-garden plugin found to refresh from (neither the marketplace cache nor the installer-managed copy)');
|
|
2925
2985
|
const previous = m.baseline;
|
|
@@ -3249,18 +3309,34 @@ export class SkillsStore {
|
|
|
3249
3309
|
const v = this.validate(m);
|
|
3250
3310
|
return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
|
|
3251
3311
|
}
|
|
3312
|
+
/** `analyze()` on the event loop (crew#852): the same whole-tree validation, its scan paced. Nothing is written either way. */
|
|
3313
|
+
async analyzePaced(pacer) {
|
|
3314
|
+
const m = this.manifest();
|
|
3315
|
+
const v = await paced(this.validateSteps(m), pacer);
|
|
3316
|
+
return { verdict: verdictOf(v.findings), findings: v.findings, revision: m.revision };
|
|
3317
|
+
}
|
|
3252
3318
|
/**
|
|
3253
3319
|
* ONE publish at a time (module header): a concurrent call is refused with
|
|
3254
3320
|
* `SkillsPublishInFlightError` (the route's 409) rather than queued — the caller's revision would
|
|
3255
3321
|
* be stale by the time a queued publish ran.
|
|
3256
3322
|
*/
|
|
3257
|
-
async publish(expectedRevision) {
|
|
3323
|
+
async publish(expectedRevision, afterCommit) {
|
|
3258
3324
|
if (this.publishInFlight !== null)
|
|
3259
3325
|
throw new SkillsPublishInFlightError(this.revision());
|
|
3260
|
-
const
|
|
3261
|
-
this.
|
|
3326
|
+
const pacer = this.makePacer();
|
|
3327
|
+
const run = this.publishSerialized(expectedRevision, pacer);
|
|
3328
|
+
// `afterCommit` (the route's export: verifying the new generation and handing it to the engine)
|
|
3329
|
+
// runs INSIDE the in-flight window (codex r1 on #853): it is paced, so the loop turns while it
|
|
3330
|
+
// verifies, and a second publish admitted in that window could commit a newer generation that
|
|
3331
|
+
// the first request would then export over. Held here, that publish is `publish-in-flight`
|
|
3332
|
+
// and `isPublishing()` is true for the whole request.
|
|
3333
|
+
const whole = afterCommit === undefined ? run : run.then(async (result) => {
|
|
3334
|
+
await afterCommit(result, pacer);
|
|
3335
|
+
return result;
|
|
3336
|
+
});
|
|
3337
|
+
this.publishInFlight = whole;
|
|
3262
3338
|
try {
|
|
3263
|
-
return await
|
|
3339
|
+
return await whole;
|
|
3264
3340
|
}
|
|
3265
3341
|
finally {
|
|
3266
3342
|
this.publishInFlight = null;
|
|
@@ -3290,9 +3366,20 @@ export class SkillsStore {
|
|
|
3290
3366
|
* `snapshots/<gen>/` (staging + rename, never over an existing generation) with the generated
|
|
3291
3367
|
* views, LOCK it read-only, COMMIT the manifest (venv state included), flip `current`, and reap
|
|
3292
3368
|
* generations / baselines nothing references. A `blocked` verdict persists nothing — the caller's
|
|
3293
|
-
* revision stays valid.
|
|
3369
|
+
* revision stays valid.
|
|
3370
|
+
*
|
|
3371
|
+
* Nothing here holds the event loop for longer than one slice (crew#852): every walk, hash, copy
|
|
3372
|
+
* and lock over the tree runs as PACED steps (tree.ts `paced` / `budgetPacer`), so `/health`, the
|
|
3373
|
+
* run listing and the stall watchdog keep answering while a 15k-file catalogue publishes. The
|
|
3374
|
+
* loop therefore turns many times between the CAS and the commit — a read in that window sees the
|
|
3375
|
+
* previous generation and the manifest as it was (nothing is written before the commit); a WRITE
|
|
3376
|
+
* that commits in that window moves the revision, and the commit below re-checks the root identity
|
|
3377
|
+
* and the revision first: a moved revision removes the staged generation (no manifest, no
|
|
3378
|
+
* generation) and fails the caller's CAS exactly as a stale `expectedRevision` does. The
|
|
3379
|
+
* published-generation semantics are unchanged: `gen` increments once, the manifest lands before
|
|
3380
|
+
* `current` flips, a reader sees the old generation or the new one, never half of either.
|
|
3294
3381
|
*/
|
|
3295
|
-
async publishSerialized(expectedRevision) {
|
|
3382
|
+
async publishSerialized(expectedRevision, pacer) {
|
|
3296
3383
|
// Refuse a root that changed identity (codex round 4) or a symlinked storage ancestor (codex
|
|
3297
3384
|
// round 3) BEFORE anything is provisioned, staged or copied: a `snapshots -> /outside` would
|
|
3298
3385
|
// take the first copy out of the store.
|
|
@@ -3311,7 +3398,7 @@ export class SkillsStore {
|
|
|
3311
3398
|
// The baseline must be the bundle its name claims BEFORE anything is provisioned in it (codex
|
|
3312
3399
|
// round 7): a corrupt baseline is `baseline-corrupt`, blocking — nothing provisioned, nothing
|
|
3313
3400
|
// written. `validate` re-derives the same hash AFTER the provisioner ran.
|
|
3314
|
-
const preProblem = this.
|
|
3401
|
+
const preProblem = await paced(this.baselineProblemSteps(pre.baseline), pacer);
|
|
3315
3402
|
if (preProblem !== null) {
|
|
3316
3403
|
return { verdict: 'blocked', findings: [this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${pre.baseline}`, preProblem)], revision: pre.revision, snapshot: null };
|
|
3317
3404
|
}
|
|
@@ -3326,13 +3413,17 @@ export class SkillsStore {
|
|
|
3326
3413
|
// The `unchanged` fast path (crew#547 items 1-2 / DES-L6 PR-L6-1 (b)): BEFORE the slow step, the
|
|
3327
3414
|
// SAME content hash the slow path would compute is derived from the effective tree; equal to the
|
|
3328
3415
|
// current generation's — and that generation still verifies under the running rules — means
|
|
3329
|
-
// there is nothing to publish: nothing
|
|
3416
|
+
// there is nothing to publish: nothing written, no generation minted. The steps are paced
|
|
3417
|
+
// (crew#852), so the `unchanged` answer is re-checked against the revision on disk: a write that
|
|
3418
|
+
// landed while the tree was hashed makes the caller's revision stale, exactly as before the check.
|
|
3330
3419
|
const venvDir0 = baselineVenvDir(this.baselineDir(pre.baseline));
|
|
3331
3420
|
const ready0 = this.venvReady(venvDir0);
|
|
3332
|
-
const fast = this.
|
|
3421
|
+
const fast = await paced(this.unchangedPublishSteps(pre, ready0), pacer);
|
|
3333
3422
|
t.validate += fast.validateMs;
|
|
3334
3423
|
t.hash += fast.hashMs;
|
|
3335
3424
|
if (fast.result !== null) {
|
|
3425
|
+
this.assertRootUnchanged(bound, expectedRevision);
|
|
3426
|
+
this.assertRevision(this.manifest(), expectedRevision);
|
|
3336
3427
|
timing(ready0 ? 'ready' : 'skipped', ` · unchanged (gen ${fast.result.snapshot?.gen ?? '?'})`);
|
|
3337
3428
|
return fast.result;
|
|
3338
3429
|
}
|
|
@@ -3346,7 +3437,7 @@ export class SkillsStore {
|
|
|
3346
3437
|
const m = this.manifest();
|
|
3347
3438
|
this.assertRevision(m, expectedRevision);
|
|
3348
3439
|
const tValidate = performance.now();
|
|
3349
|
-
const v = this.
|
|
3440
|
+
const v = await paced(this.validateSteps(m), pacer);
|
|
3350
3441
|
t.validate += performance.now() - tValidate; // validated twice on a CHANGED publish (accepted; the line shows the cost)
|
|
3351
3442
|
if (venv === 'failed') {
|
|
3352
3443
|
v.findings.push(finding('venv-failed', 'blocking', "the baseline's shared read-only Python environment is REQUIRED (the bundle carries a pyproject.toml; skills `uv run` from the plugin root): a snapshot without it hands every worker a plugin whose scripts cannot start. Install uv, fix the sync error the log names, and publish again", `uv sync did not produce ${baselineVenvDir(this.baselineDir(m.baseline))} (see the daemon log)`, {}));
|
|
@@ -3369,14 +3460,14 @@ export class SkillsStore {
|
|
|
3369
3460
|
// later verification hash the directories they WALK, so an extra directory — empty or not — is a
|
|
3370
3461
|
// mismatch, never an invisible passenger.
|
|
3371
3462
|
const tHash = performance.now();
|
|
3372
|
-
const contentHash =
|
|
3463
|
+
const contentHash = await paced(hashTreeSteps(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel))), pacer);
|
|
3373
3464
|
t.hash += performance.now() - tHash;
|
|
3374
3465
|
const tStage = performance.now();
|
|
3375
3466
|
const snapshots = this.snapshotsDir();
|
|
3376
3467
|
mkdirSync(snapshots, { recursive: true });
|
|
3377
3468
|
this.sweepStaging(snapshots);
|
|
3378
3469
|
const staging = join(snapshots, `${STAGING_PREFIX}${randomBytes(6).toString('hex')}`);
|
|
3379
|
-
|
|
3470
|
+
await paced(copyFilesSteps(allFiles, staging), pacer);
|
|
3380
3471
|
const record = m.baselines[m.baseline];
|
|
3381
3472
|
const snapshot = {
|
|
3382
3473
|
gen,
|
|
@@ -3417,7 +3508,7 @@ export class SkillsStore {
|
|
|
3417
3508
|
this.symlink(venvLink.text, join(staging, VENV_LINKNAME), venvLink.absTarget);
|
|
3418
3509
|
// Re-walk (lstat) and re-hash the staged generation AFTER the copy and BEFORE the rename (design
|
|
3419
3510
|
// v3.5 §3): files and the one permitted link must hash to the content it was copied from.
|
|
3420
|
-
const stagedHash = this.
|
|
3511
|
+
const stagedHash = await paced(this.snapshotHashSteps(await paced(walkTreeSteps(staging), pacer)), pacer);
|
|
3421
3512
|
if (stagedHash !== contentHash) {
|
|
3422
3513
|
removeTreeForce(staging);
|
|
3423
3514
|
throw new SkillsPublishError(`the staged generation hashes to ${stagedHash}, not to the content it was copied from (${contentHash}) — modified between copy and rename; nothing published`);
|
|
@@ -3431,12 +3522,24 @@ export class SkillsStore {
|
|
|
3431
3522
|
// Immutable by contract, and now by mode bits: a published generation is locked read-only
|
|
3432
3523
|
// (verification by hash stays the authority — the lock is what makes an accidental edit fail).
|
|
3433
3524
|
try {
|
|
3434
|
-
|
|
3525
|
+
await paced(makeTreeReadOnlySteps(dest), pacer);
|
|
3435
3526
|
}
|
|
3436
3527
|
catch (err) {
|
|
3437
3528
|
removeTreeForce(dest);
|
|
3438
3529
|
throw new SkillsPublishError(`could not lock ${dest} read-only: ${err instanceof Error ? err.message : String(err)}`);
|
|
3439
3530
|
}
|
|
3531
|
+
// The paced steps let the loop turn (crew#852): the manifest about to be committed is the one
|
|
3532
|
+
// bound at the start, so it lands only through the same root and over the same revision — a
|
|
3533
|
+
// write that committed meanwhile fails this publish's CAS (the caller re-reads and retries) and
|
|
3534
|
+
// the generation it never came to own is removed, exactly like a commit that fails below.
|
|
3535
|
+
try {
|
|
3536
|
+
this.assertRootUnchanged(bound, expectedRevision);
|
|
3537
|
+
this.assertRevision(this.manifest(), expectedRevision);
|
|
3538
|
+
}
|
|
3539
|
+
catch (err) {
|
|
3540
|
+
removeTreeForce(dest);
|
|
3541
|
+
throw err;
|
|
3542
|
+
}
|
|
3440
3543
|
// Manifest FIRST, then `current`: a crash in between leaves a committed generation `ensureReady`
|
|
3441
3544
|
// finishes flipping to; the reverse order would leave a live `current` the manifest disowns.
|
|
3442
3545
|
const included = new Set(v.snapshotFiles.map((f) => f.rel));
|
|
@@ -3465,9 +3568,9 @@ export class SkillsStore {
|
|
|
3465
3568
|
this.flipCurrent(gen);
|
|
3466
3569
|
this.live.published(gen);
|
|
3467
3570
|
for (const g of retiredGens)
|
|
3468
|
-
|
|
3571
|
+
await removeTreeForcePaced(this.snapshotDir(g), pacer); // locked read-only at publish
|
|
3469
3572
|
for (const dir of prune.dirs)
|
|
3470
|
-
|
|
3573
|
+
await removeTreeForcePaced(dir, pacer);
|
|
3471
3574
|
t.stage = performance.now() - tStage;
|
|
3472
3575
|
timing(venvLabel, ` · gen ${gen}`);
|
|
3473
3576
|
return {
|
|
@@ -3494,7 +3597,7 @@ export class SkillsStore {
|
|
|
3494
3597
|
* touch: a rollback reads the same manifest. A blocked validation is left to the slow path so the
|
|
3495
3598
|
* findings (a `venv-failed` included) are reported exactly as before.
|
|
3496
3599
|
*/
|
|
3497
|
-
|
|
3600
|
+
*unchangedPublishSteps(pre, venvReady) {
|
|
3498
3601
|
const none = (validateMs = 0, hashMs = 0) => ({ result: null, validateMs, hashMs });
|
|
3499
3602
|
const published = pre.published;
|
|
3500
3603
|
if (published === null)
|
|
@@ -3511,13 +3614,13 @@ export class SkillsStore {
|
|
|
3511
3614
|
else
|
|
3512
3615
|
return none();
|
|
3513
3616
|
const tValidate = performance.now();
|
|
3514
|
-
const v = this.
|
|
3617
|
+
const v = yield* this.validateSteps(pre);
|
|
3515
3618
|
const validateMs = performance.now() - tValidate;
|
|
3516
3619
|
if (verdictOf(v.findings) === 'blocked')
|
|
3517
3620
|
return none(validateMs);
|
|
3518
3621
|
const tHash = performance.now();
|
|
3519
3622
|
const allFiles = sortedRels([...v.snapshotFiles, ...this.viewFiles(v).files]);
|
|
3520
|
-
const candidate =
|
|
3623
|
+
const candidate = yield* hashTreeSteps(allFiles, venvLink === null ? [] : [{ rel: VENV_LINKNAME, target: venvLink.text }], impliedDirs(allFiles.map((f) => f.rel)));
|
|
3521
3624
|
const hashMs = performance.now() - tHash;
|
|
3522
3625
|
if (candidate !== published.contentHash)
|
|
3523
3626
|
return none(validateMs, hashMs);
|
|
@@ -3525,7 +3628,7 @@ export class SkillsStore {
|
|
|
3525
3628
|
return none(validateMs, hashMs);
|
|
3526
3629
|
let current;
|
|
3527
3630
|
try {
|
|
3528
|
-
current = this.
|
|
3631
|
+
current = yield* this.currentSnapshotSteps();
|
|
3529
3632
|
}
|
|
3530
3633
|
catch {
|
|
3531
3634
|
return none(validateMs, hashMs); // an unverifiable current generation is re-minted by the slow path
|
|
@@ -3559,8 +3662,21 @@ export class SkillsStore {
|
|
|
3559
3662
|
viewFiles(v) {
|
|
3560
3663
|
const files = [];
|
|
3561
3664
|
const copilotSkills = [];
|
|
3562
|
-
const byRel = new Map(v.snapshotFiles.map((f) => [f.rel, f]));
|
|
3563
3665
|
const dirs = new Set(v.enabledSkills.map(({ entry }) => entry.dir));
|
|
3666
|
+
// ONE pass groups the file set by its owning skill dir (crew#852): the former per-skill scan of
|
|
3667
|
+
// every file was skills × files `owningSkillDir` calls — millions on the real catalogue, all on
|
|
3668
|
+
// the event loop. A file whose owner is `entry.dir` sits under `entry.dir/` by construction.
|
|
3669
|
+
const byOwner = new Map();
|
|
3670
|
+
for (const f of v.snapshotFiles) {
|
|
3671
|
+
const owner = owningSkillDir(f.rel, dirs);
|
|
3672
|
+
if (owner === null)
|
|
3673
|
+
continue;
|
|
3674
|
+
const group = byOwner.get(owner);
|
|
3675
|
+
if (group === undefined)
|
|
3676
|
+
byOwner.set(owner, [f]);
|
|
3677
|
+
else
|
|
3678
|
+
group.push(f);
|
|
3679
|
+
}
|
|
3564
3680
|
for (const { name, entry } of v.enabledSkills) {
|
|
3565
3681
|
if (!entry.portable)
|
|
3566
3682
|
continue;
|
|
@@ -3571,10 +3687,8 @@ export class SkillsStore {
|
|
|
3571
3687
|
}
|
|
3572
3688
|
copilotSkills.push(name);
|
|
3573
3689
|
const prefix = `${entry.dir}/`;
|
|
3574
|
-
for (const
|
|
3575
|
-
|
|
3576
|
-
continue;
|
|
3577
|
-
files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${rel.slice(prefix.length)}`, abs: f.abs });
|
|
3690
|
+
for (const f of byOwner.get(entry.dir) ?? []) {
|
|
3691
|
+
files.push({ rel: `${COPILOT_VIEW_SKILLS_REL}/${name}/${f.rel.slice(prefix.length)}`, abs: f.abs });
|
|
3578
3692
|
}
|
|
3579
3693
|
}
|
|
3580
3694
|
return { files: sortedRels(files), copilotSkills: copilotSkills.sort() };
|
|
@@ -3738,14 +3852,18 @@ export class SkillsStore {
|
|
|
3738
3852
|
* commits it; analyze and a blocked publish drop it). Never touches `effective/`.
|
|
3739
3853
|
*/
|
|
3740
3854
|
validate(m) {
|
|
3855
|
+
return drain(this.validateSteps(m));
|
|
3856
|
+
}
|
|
3857
|
+
/** `validate` as steps (crew#852): the effective scan's yields (walk + per-file hash); the rules themselves are cheap. */
|
|
3858
|
+
*validateSteps(m) {
|
|
3741
3859
|
const findings = [];
|
|
3742
3860
|
// The baseline the records point at must be the bundle its name claims (codex round 7): reset
|
|
3743
3861
|
// restores from it, `?side=baseline` reads it, the env is provisioned in it — and publish calls
|
|
3744
3862
|
// this AFTER the provisioner ran, so a provisioner that wrote outside `.venv` is caught here.
|
|
3745
|
-
const baselineProblem = this.
|
|
3863
|
+
const baselineProblem = yield* this.baselineProblemSteps(m.baseline);
|
|
3746
3864
|
if (baselineProblem !== null)
|
|
3747
3865
|
findings.push(this.baselineCorruptFinding(null, `${BASELINE_DIRNAME}/${m.baseline}`, baselineProblem));
|
|
3748
|
-
const scan = this.
|
|
3866
|
+
const scan = yield* this.scanEffectiveSteps();
|
|
3749
3867
|
const scanned = scan.files;
|
|
3750
3868
|
const onDisk = new Map(scanned.map((f) => [f.rel, f]));
|
|
3751
3869
|
// Direct filesystem edits: detected by hash, reported, recorded — never silently trusted.
|
|
@@ -3790,7 +3908,7 @@ export class SkillsStore {
|
|
|
3790
3908
|
}
|
|
3791
3909
|
// Derived fields are recomputed CONTAINED: a skill whose dir (or a file in it) crosses a symlink
|
|
3792
3910
|
// is skipped, never read through, and BLOCKS here by name (codex round 5).
|
|
3793
|
-
findings.push(...this.
|
|
3911
|
+
findings.push(...(yield* this.recomputeDerivedSteps(m)));
|
|
3794
3912
|
// EVERY entry of effective/ is classified (codex round 9): a symlink ANYWHERE under it is refused
|
|
3795
3913
|
// by name — there is no permitted link there (the store never follows one, and a snapshot would
|
|
3796
3914
|
// otherwise copy whatever it reaches) — and so is a node that is neither a file nor a directory.
|
|
@@ -3829,6 +3947,7 @@ export class SkillsStore {
|
|
|
3829
3947
|
const f = onDisk.get(rel);
|
|
3830
3948
|
if (f !== undefined)
|
|
3831
3949
|
catalogMd.set(name, readFileNoFollow(f.abs).toString('utf8'));
|
|
3950
|
+
yield;
|
|
3832
3951
|
}
|
|
3833
3952
|
// The core closure must be COMPLETE: a registered ref or a mandate naming no catalog skill is
|
|
3834
3953
|
// a phase the engine would refuse to launch — blocking here, not a warning (v3 §3/§5).
|