@lmzhen/dsh-evolution-core 0.32.3 → 0.32.5
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/README.md +63 -117
- package/lib/composition/index.js +2 -2
- package/lib/{composition-DSesmTvf.js → composition-3QFfEe56.js} +1 -1
- package/lib/{ensure-BuWsjPGD.js → ensure-C2tUoF5U.js} +1 -1
- package/lib/{errors-Br2-uUSK.js → errors-DRZ9A187.js} +1 -0
- package/lib/index.js +423 -113
- package/lib/restrictor.js +1 -1
- package/lib/subagent/index.js +1 -1
- package/lib/types/errors.d.ts +1 -0
- package/lib/types/errors.js +1 -0
- package/lib/types/events.d.ts +1 -0
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.js +1 -0
- package/lib/types/mutation-storage-adapter.d.ts +5 -2
- package/lib/types/mutation-storage-adapter.js +59 -22
- package/lib/types/mutation-unit.d.ts +2 -2
- package/lib/types/mutation-unit.js +18 -45
- package/lib/types/params.js +1 -1
- package/lib/types/platform-services.js +0 -1
- package/lib/types/session-projection.d.ts +14 -0
- package/lib/types/session-projection.js +12 -3
- package/lib/types/skill-reads.d.ts +19 -0
- package/lib/types/skill-reads.js +42 -2
- package/lib/types/skill-store.js +4 -2
- package/lib/types/tool-dispatch.d.ts +16 -0
- package/lib/types/tool-dispatch.js +42 -11
- package/lib/types/unit-journal.d.ts +5 -3
- package/lib/types/unit-journal.js +7 -6
- package/lib/types/unit-storage.d.ts +5 -4
- package/lib/types/unit-storage.js +8 -8
- package/lib/types/unit-transaction.js +5 -7
- package/lib/types/write-gates.d.ts +4 -0
- package/lib/types/write-gates.js +16 -1
- package/lib/types/write-kernel.d.ts +81 -0
- package/lib/types/write-kernel.js +155 -0
- package/package.json +2 -2
- package/persisted-write-inventory.json +1 -10
|
@@ -375,9 +375,8 @@ function parsedArguments(signal) {
|
|
|
375
375
|
return null;
|
|
376
376
|
return parsed;
|
|
377
377
|
}
|
|
378
|
-
/** The path-shaped argument of
|
|
379
|
-
function
|
|
380
|
-
const record = parsedArguments(signal);
|
|
378
|
+
/** The path-shaped argument of one already-parsed call, when it carries one. */
|
|
379
|
+
function readPathFromRecord(record) {
|
|
381
380
|
if (record === null)
|
|
382
381
|
return null;
|
|
383
382
|
for (const key of ['file_path', 'path', 'file', 'target']) {
|
|
@@ -387,6 +386,23 @@ function readPathArgument(signal) {
|
|
|
387
386
|
}
|
|
388
387
|
return null;
|
|
389
388
|
}
|
|
389
|
+
/** The path-shaped argument of a file tool, when it carries one. */
|
|
390
|
+
function readPathArgument(signal) {
|
|
391
|
+
return readPathFromRecord(parsedArguments(signal));
|
|
392
|
+
}
|
|
393
|
+
/** The path one dispatch names, for the read-evidence projection. */
|
|
394
|
+
export function dispatchPathOf(step) {
|
|
395
|
+
let parsed = step.arguments;
|
|
396
|
+
if (typeof parsed === 'string') {
|
|
397
|
+
try {
|
|
398
|
+
parsed = JSON.parse(parsed);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return parsed !== null && typeof parsed === 'object' ? readPathFromRecord(parsed) : null;
|
|
405
|
+
}
|
|
390
406
|
/**
|
|
391
407
|
* Read one dispatch as a SUPPORT-FILE read attributed to a skill: a read of
|
|
392
408
|
* `<root>/<skill>/<support dir>/…` is demand evidence for that one file, the
|
|
@@ -397,14 +413,8 @@ function readPathArgument(signal) {
|
|
|
397
413
|
* @returns the skill name and the skill-root-relative path, or `null` when the
|
|
398
414
|
* dispatch is not a support-file read. Pure: no IO, no service lookup.
|
|
399
415
|
*/
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
return null;
|
|
403
|
-
if (signal.ok === false)
|
|
404
|
-
return null;
|
|
405
|
-
const raw = readPathArgument(signal);
|
|
406
|
-
if (raw === null)
|
|
407
|
-
return null;
|
|
416
|
+
/** Attribute one raw path string to a skill support file (pure; no IO). */
|
|
417
|
+
export function supportFileReadFromPath(raw, options) {
|
|
408
418
|
const normalized = raw.replace(/\\/g, '/').trim();
|
|
409
419
|
if (normalized === '' || normalized.split('/').includes('..'))
|
|
410
420
|
return null;
|
|
@@ -432,6 +442,14 @@ export function supportFileReadOf(signal, options) {
|
|
|
432
442
|
return null;
|
|
433
443
|
return { skill: skill.trim(), rel: parts.slice(1).join('/') };
|
|
434
444
|
}
|
|
445
|
+
export function supportFileReadOf(signal, options) {
|
|
446
|
+
if (!options.toolNames.includes(signal.name))
|
|
447
|
+
return null;
|
|
448
|
+
if (signal.ok === false)
|
|
449
|
+
return null;
|
|
450
|
+
const raw = readPathArgument(signal);
|
|
451
|
+
return raw === null ? null : supportFileReadFromPath(raw, options);
|
|
452
|
+
}
|
|
435
453
|
export function skillReadNameOf(signal) {
|
|
436
454
|
if (!SKILL_READ_TOOL_NAMES.has(signal.name))
|
|
437
455
|
return undefined;
|
|
@@ -468,6 +486,19 @@ export function collectReadSkillNames(events) {
|
|
|
468
486
|
}
|
|
469
487
|
return names;
|
|
470
488
|
}
|
|
489
|
+
/** Every skill support file this log read through a non-failed configured file tool. */
|
|
490
|
+
export function collectReadSupportFiles(events, options) {
|
|
491
|
+
const reads = new Map();
|
|
492
|
+
for (const signal of foldToolDispatches(events)) {
|
|
493
|
+
const hit = supportFileReadOf(signal, options);
|
|
494
|
+
if (hit === null)
|
|
495
|
+
continue;
|
|
496
|
+
const set = reads.get(hit.skill) ?? new Set();
|
|
497
|
+
set.add(hit.rel);
|
|
498
|
+
reads.set(hit.skill, set);
|
|
499
|
+
}
|
|
500
|
+
return reads;
|
|
501
|
+
}
|
|
471
502
|
/** Tool names that touch the skill library at all — the review's skill-signal set. */
|
|
472
503
|
const SKILL_TOOL_NAMES = new Set([...SKILL_READ_TOOL_NAMES, 'skill_manage']);
|
|
473
504
|
/**
|
|
@@ -19,11 +19,13 @@ export interface UnitJournal {
|
|
|
19
19
|
readonly storageKey: string;
|
|
20
20
|
readonly snapshotSha256: string;
|
|
21
21
|
readonly checkpoints?: readonly UnitJournalCheckpoint[];
|
|
22
|
+
readonly kind?: 'merge' | 'tree-change';
|
|
22
23
|
readonly updatedAt: number;
|
|
23
24
|
}
|
|
24
|
-
export
|
|
25
|
+
export type UnitJournalScope = 'merge' | 'skill';
|
|
26
|
+
export declare function unitJournalPath(home: string, unitId: string, scope?: UnitJournalScope): string;
|
|
25
27
|
export declare function unitJournalPhaseRank(phase: UnitJournalPhase): number;
|
|
26
28
|
export declare function assertUnitJournalTransition(from: UnitJournalPhase, to: UnitJournalPhase): void;
|
|
27
|
-
export declare function writeUnitJournal(io: EvolutionIoLike, home: string, journal: UnitJournal): Promise<string>;
|
|
28
|
-
export declare function readUnitJournal(io: EvolutionIoLike, home: string, unitId: string): Promise<UnitJournal | null>;
|
|
29
|
+
export declare function writeUnitJournal(io: EvolutionIoLike, home: string, journal: UnitJournal, scope?: UnitJournalScope): Promise<string>;
|
|
30
|
+
export declare function readUnitJournal(io: EvolutionIoLike, home: string, unitId: string, scope?: UnitJournalScope): Promise<UnitJournal | null>;
|
|
29
31
|
//# sourceMappingURL=unit-journal.d.ts.map
|
|
@@ -14,8 +14,9 @@ export const UNIT_JOURNAL_PHASES = [
|
|
|
14
14
|
'failed',
|
|
15
15
|
'rolled-back',
|
|
16
16
|
];
|
|
17
|
-
export function unitJournalPath(home, unitId) {
|
|
18
|
-
|
|
17
|
+
export function unitJournalPath(home, unitId, scope = 'merge') {
|
|
18
|
+
const base = scope === 'merge' ? join(home, 'merge', 'unit-journals') : join(home, 'units', 'skill', 'journals');
|
|
19
|
+
return join(base, createHash('sha256').update(unitId).digest('hex') + '.json');
|
|
19
20
|
}
|
|
20
21
|
export function unitJournalPhaseRank(phase) {
|
|
21
22
|
return UNIT_JOURNAL_PHASES.indexOf(phase);
|
|
@@ -28,8 +29,8 @@ export function assertUnitJournalTransition(from, to) {
|
|
|
28
29
|
if (unitJournalPhaseRank(to) <= unitJournalPhaseRank(from))
|
|
29
30
|
throw new Error('unit-journal-nonmonotonic:' + from + '->' + to);
|
|
30
31
|
}
|
|
31
|
-
export async function writeUnitJournal(io, home, journal) {
|
|
32
|
-
const path = unitJournalPath(home, journal.unitId);
|
|
32
|
+
export async function writeUnitJournal(io, home, journal, scope = 'merge') {
|
|
33
|
+
const path = unitJournalPath(home, journal.unitId, scope);
|
|
33
34
|
const content = JSON.stringify(journal, null, 2) + '\n';
|
|
34
35
|
if (io.transact !== undefined)
|
|
35
36
|
await io.transact(path, () => content);
|
|
@@ -37,8 +38,8 @@ export async function writeUnitJournal(io, home, journal) {
|
|
|
37
38
|
await io.writeText(path, content);
|
|
38
39
|
return path;
|
|
39
40
|
}
|
|
40
|
-
export async function readUnitJournal(io, home, unitId) {
|
|
41
|
-
const raw = await io.readText(unitJournalPath(home, unitId));
|
|
41
|
+
export async function readUnitJournal(io, home, unitId, scope = 'merge') {
|
|
42
|
+
const raw = await io.readText(unitJournalPath(home, unitId, scope));
|
|
42
43
|
if (raw === null)
|
|
43
44
|
return null;
|
|
44
45
|
try {
|
|
@@ -78,12 +78,13 @@ export interface UnitIndexEntry {
|
|
|
78
78
|
}
|
|
79
79
|
/** Stable, filesystem-safe key; unitId stays opaque (may contain ':' or '/'). */
|
|
80
80
|
export declare function unitStorageKey(unitId: string): string;
|
|
81
|
-
export
|
|
82
|
-
export declare function
|
|
81
|
+
export type UnitStoreScope = 'merge' | 'skill';
|
|
82
|
+
export declare function unitSnapshotsDir(home: string, scope?: UnitStoreScope): string;
|
|
83
|
+
export declare function unitSnapshotPath(home: string, unitId: string, scope?: UnitStoreScope): string;
|
|
83
84
|
export declare function unitEvidencePath(home: string, unitId: string): string;
|
|
84
85
|
export declare function unitIndexPath(home: string): string;
|
|
85
|
-
export declare function writeUnitSnapshot(io: EvolutionIoLike, home: string, snapshot: UnitSnapshot): Promise<string>;
|
|
86
|
-
export declare function readUnitSnapshot(io: EvolutionIoLike, home: string, unitId: string): Promise<UnitSnapshot | null>;
|
|
86
|
+
export declare function writeUnitSnapshot(io: EvolutionIoLike, home: string, snapshot: UnitSnapshot, scope?: UnitStoreScope): Promise<string>;
|
|
87
|
+
export declare function readUnitSnapshot(io: EvolutionIoLike, home: string, unitId: string, scope?: UnitStoreScope): Promise<UnitSnapshot | null>;
|
|
87
88
|
export declare function writeUnitEvidence(io: EvolutionIoLike, home: string, evidence: UnitEvidenceRecord): Promise<string>;
|
|
88
89
|
export declare function readUnitEvidence(io: EvolutionIoLike, home: string, unitId: string): Promise<UnitEvidenceRecord | null>;
|
|
89
90
|
export declare function readUnitIndex(io: EvolutionIoLike, home: string): Promise<UnitIndexEntry[]>;
|
|
@@ -6,11 +6,11 @@ export const UNIT_STORAGE_SCHEMA_VERSION = 1;
|
|
|
6
6
|
export function unitStorageKey(unitId) {
|
|
7
7
|
return createHash('sha256').update(unitId).digest('hex');
|
|
8
8
|
}
|
|
9
|
-
export function unitSnapshotsDir(home) {
|
|
10
|
-
return join(home, 'merge', 'unit-snapshots');
|
|
9
|
+
export function unitSnapshotsDir(home, scope = 'merge') {
|
|
10
|
+
return scope === 'merge' ? join(home, 'merge', 'unit-snapshots') : join(home, 'units', 'skill', 'snapshots');
|
|
11
11
|
}
|
|
12
|
-
export function unitSnapshotPath(home, unitId) {
|
|
13
|
-
return join(unitSnapshotsDir(home), unitStorageKey(unitId) + '.json');
|
|
12
|
+
export function unitSnapshotPath(home, unitId, scope = 'merge') {
|
|
13
|
+
return join(unitSnapshotsDir(home, scope), unitStorageKey(unitId) + '.json');
|
|
14
14
|
}
|
|
15
15
|
export function unitEvidencePath(home, unitId) {
|
|
16
16
|
return join(home, 'merge', 'units', unitStorageKey(unitId), 'evidence.json');
|
|
@@ -18,8 +18,8 @@ export function unitEvidencePath(home, unitId) {
|
|
|
18
18
|
export function unitIndexPath(home) {
|
|
19
19
|
return join(home, 'merge', 'unit-index.json');
|
|
20
20
|
}
|
|
21
|
-
export async function writeUnitSnapshot(io, home, snapshot) {
|
|
22
|
-
const path = unitSnapshotPath(home, snapshot.unitId);
|
|
21
|
+
export async function writeUnitSnapshot(io, home, snapshot, scope = 'merge') {
|
|
22
|
+
const path = unitSnapshotPath(home, snapshot.unitId, scope);
|
|
23
23
|
const content = JSON.stringify(snapshot, null, 2) + '\n';
|
|
24
24
|
if (io.transact !== undefined)
|
|
25
25
|
await io.transact(path, () => content);
|
|
@@ -27,8 +27,8 @@ export async function writeUnitSnapshot(io, home, snapshot) {
|
|
|
27
27
|
await io.writeText(path, content);
|
|
28
28
|
return path;
|
|
29
29
|
}
|
|
30
|
-
export async function readUnitSnapshot(io, home, unitId) {
|
|
31
|
-
const raw = await io.readText(unitSnapshotPath(home, unitId));
|
|
30
|
+
export async function readUnitSnapshot(io, home, unitId, scope = 'merge') {
|
|
31
|
+
const raw = await io.readText(unitSnapshotPath(home, unitId, scope));
|
|
32
32
|
if (raw === null)
|
|
33
33
|
return null;
|
|
34
34
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** G2 opaque UnitTransaction. Core owns prepare/commit/verify/rollback lifecycle. */
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { acquireTreeWriteLock } from "./tree-lock.js";
|
|
4
|
-
import { readUnitEvidence, readUnitReceipt, readUnitSnapshot, unitStorageKey, writeUnitEvidence, writeUnitReceipt, writeUnitSnapshot, } from "./unit-storage.js";
|
|
4
|
+
import { readUnitEvidence, readUnitReceipt, readUnitIndex, readUnitSnapshot, unitStorageKey, writeUnitEvidence, writeUnitReceipt, writeUnitSnapshot, } from "./unit-storage.js";
|
|
5
5
|
import { readUnitJournal, writeUnitJournal } from "./unit-journal.js";
|
|
6
6
|
function canonical(value) {
|
|
7
7
|
if (value === null || typeof value !== "object")
|
|
@@ -30,9 +30,6 @@ function canonicalJson(value) {
|
|
|
30
30
|
export function unitEvidenceDigest(evidence) {
|
|
31
31
|
return createHash("sha256").update(canonicalJson({ ...evidence, at: 0, mutations: 0 })).digest("hex");
|
|
32
32
|
}
|
|
33
|
-
function evidenceDigest(evidence) {
|
|
34
|
-
return createHash("sha256").update(JSON.stringify({ ...evidence, at: 0, mutations: 0 }, null, 2) + "\n").digest("hex");
|
|
35
|
-
}
|
|
36
33
|
export class UnitTransactions {
|
|
37
34
|
options;
|
|
38
35
|
prepared = new Map();
|
|
@@ -243,7 +240,7 @@ export class UnitTransactions {
|
|
|
243
240
|
};
|
|
244
241
|
if (!(await lock.assertHeld()))
|
|
245
242
|
throw new Error("unit-fence-lost");
|
|
246
|
-
const evidenceSha256 =
|
|
243
|
+
const evidenceSha256 = unitEvidenceDigest(evidence);
|
|
247
244
|
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
|
248
245
|
await writeUnitReceipt(this.options.io, this.options.home, {
|
|
249
246
|
schemaVersion: 1, unitId: prepared.unitId, runId: stored.plan.runId, planDigest: stored.digest,
|
|
@@ -269,7 +266,8 @@ export class UnitTransactions {
|
|
|
269
266
|
async verify(ref) {
|
|
270
267
|
if ("unitId" in ref)
|
|
271
268
|
return await readUnitEvidence(this.options.io, this.options.home, ref.unitId);
|
|
272
|
-
const
|
|
269
|
+
const entries = (await readUnitIndex(this.options.io, this.options.home)).filter(entry => entry.runId === ref.runId);
|
|
270
|
+
const all = await Promise.all(entries.map(async (entry) => readUnitEvidence(this.options.io, this.options.home, entry.unitId)));
|
|
273
271
|
const found = all.filter((item) => item !== null && item.runId === ref.runId);
|
|
274
272
|
const only = found[0];
|
|
275
273
|
return found.length === 0 ? null : found.length === 1 ? (only ?? null) : found;
|
|
@@ -334,7 +332,7 @@ export class UnitTransactions {
|
|
|
334
332
|
...(snapshot.validationPlan === undefined ? {} : { validationPlan: snapshot.validationPlan }),
|
|
335
333
|
at: this.now(),
|
|
336
334
|
};
|
|
337
|
-
if (receipt?.evidenceSha256 !== undefined &&
|
|
335
|
+
if (receipt?.evidenceSha256 !== undefined && unitEvidenceDigest(evidence) !== receipt.evidenceSha256)
|
|
338
336
|
return { ok: false, code: "unit-rollback-blocked", message: "receipt evidence hash does not match rebuilt evidence" };
|
|
339
337
|
await writeUnitEvidence(this.options.io, this.options.home, evidence);
|
|
340
338
|
if (journal.phase !== "committed")
|
|
@@ -54,6 +54,8 @@ export interface SkillWriteGateServices {
|
|
|
54
54
|
readonly confirm: WriteConfirm | undefined;
|
|
55
55
|
/** Names this session read successfully; `undefined` when the session log is not readable. */
|
|
56
56
|
readonly readNames: ReadonlySet<string> | undefined;
|
|
57
|
+
/** Support files this session read, per skill; `undefined` when no evidence. */
|
|
58
|
+
readonly readSupportFiles?: ReadonlyMap<string, ReadonlySet<string>> | undefined;
|
|
57
59
|
/** Report a degraded gate; must not throw. */
|
|
58
60
|
readonly warn: (message: string) => void;
|
|
59
61
|
/** The registry's `skillWriteConfirm`; `auto` (the default) skips the question. */
|
|
@@ -87,6 +89,8 @@ export interface WriteGateContext {
|
|
|
87
89
|
readonly protectedNames: readonly string[] | undefined;
|
|
88
90
|
/** Names this session read successfully; `undefined` when the session log is not readable. */
|
|
89
91
|
readonly readNames: ReadonlySet<string> | undefined;
|
|
92
|
+
/** Support files this session read, per skill; `undefined` when no evidence. */
|
|
93
|
+
readonly readSupportFiles?: ReadonlyMap<string, ReadonlySet<string>> | undefined;
|
|
90
94
|
/** The human confirm seam — read only by the gates that apply to `'admission'`. */
|
|
91
95
|
readonly confirm: WriteConfirm | undefined;
|
|
92
96
|
/** The registry's `skillWriteConfirm`: `auto` (the default) skips the question entirely. */
|
package/lib/types/write-gates.js
CHANGED
|
@@ -76,6 +76,7 @@ export function skillWriteGateContext(services, request) {
|
|
|
76
76
|
origin: request.origin,
|
|
77
77
|
protectedNames: protectedSkillNamesOf(services.policy),
|
|
78
78
|
readNames: services.readNames,
|
|
79
|
+
...(services.readSupportFiles === undefined ? {} : { readSupportFiles: services.readSupportFiles }),
|
|
79
80
|
confirm: services.confirm,
|
|
80
81
|
...(services.confirmMode === undefined ? {} : { confirmMode: services.confirmMode }),
|
|
81
82
|
...(services.confirmTimeoutSeconds === undefined ? {} : { confirmTimeoutSeconds: services.confirmTimeoutSeconds }),
|
|
@@ -109,6 +110,7 @@ function gateArgsOf(args) {
|
|
|
109
110
|
// (`args.absorbed_into ? { absorbedInto } : {}`) — so `''` performs a BARE archive and must not
|
|
110
111
|
// borrow the merge exemption (review 2026-09-26, P1).
|
|
111
112
|
absorbedInto: typeof absorbedInto === 'string' && absorbedInto.trim() !== '' ? absorbedInto : undefined,
|
|
113
|
+
filePath: typeof scalars.file_path === 'string' && scalars.file_path.trim() !== '' ? scalars.file_path.replace(/\\/g, '/').replace(/^\.\//, '') : undefined,
|
|
112
114
|
scalars,
|
|
113
115
|
};
|
|
114
116
|
}
|
|
@@ -195,7 +197,7 @@ const POLICY_PROTECTION = {
|
|
|
195
197
|
const READ_BEFORE_WRITE = {
|
|
196
198
|
id: 'read-before-write',
|
|
197
199
|
appliesTo: ['admission'],
|
|
198
|
-
run: ({ view, origin, readNames, warn }) => {
|
|
200
|
+
run: ({ view, origin, readNames, readSupportFiles, warn }) => {
|
|
199
201
|
if (origin === 'foreground')
|
|
200
202
|
return null;
|
|
201
203
|
if (readNames === undefined) {
|
|
@@ -208,6 +210,19 @@ const READ_BEFORE_WRITE = {
|
|
|
208
210
|
// P2). The rule's default-to-`patch` (V6-26) stays where it belongs, on the plan path.
|
|
209
211
|
if (view.action === undefined)
|
|
210
212
|
return null;
|
|
213
|
+
// P2-27: a support-file write is judged on THAT FILE, not on the skill body.
|
|
214
|
+
// Reading the SKILL.md does not mean the model saw references/x.md.
|
|
215
|
+
if ((view.action === 'write_file' || view.action === 'remove_file') && view.filePath !== undefined) {
|
|
216
|
+
if (readSupportFiles === undefined) {
|
|
217
|
+
warn('skill_manage: the support-file read check could not run for this call — no support-file read evidence; the write is allowed.');
|
|
218
|
+
}
|
|
219
|
+
else if (readSupportFiles.get(view.name)?.has(view.filePath) === true) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
return errorText('e-326-support-write-without-a-read', { a1: view.name, a2: view.filePath });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
211
226
|
if (!isUnreadWrite({ action: view.action, name: view.name }, readNames))
|
|
212
227
|
return null;
|
|
213
228
|
return errorText('e-318-skill-write-without-a-read', { a1: view.name });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type EvolutionIoLike } from './io.ts';
|
|
2
|
+
/** The four persistent writer kinds the family permits. */
|
|
3
|
+
export type WriteUnitKind = 'skill' | 'state' | 'merge' | 'curator';
|
|
4
|
+
/** One planned write: expected is the baseline read before the plan, next is the new bytes. */
|
|
5
|
+
export interface WriteTarget {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly expected: string | null;
|
|
8
|
+
readonly next: string | null;
|
|
9
|
+
readonly skill?: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
/** A structured failure; conflict always means the target changed before/inside the write. */
|
|
12
|
+
export interface WriteKernelError {
|
|
13
|
+
readonly code: 'cas-conflict' | 'write-failed' | 'compensation-blocked';
|
|
14
|
+
readonly message: string;
|
|
15
|
+
readonly target: string;
|
|
16
|
+
}
|
|
17
|
+
/** Outcome of one write or one multi-target commit. */
|
|
18
|
+
export interface WriteKernelResult {
|
|
19
|
+
readonly ok: boolean;
|
|
20
|
+
readonly changed: readonly string[];
|
|
21
|
+
readonly errors: readonly WriteKernelError[];
|
|
22
|
+
readonly warnings?: readonly string[] | undefined;
|
|
23
|
+
}
|
|
24
|
+
export interface WriteKernelOptions {
|
|
25
|
+
readonly io: EvolutionIoLike;
|
|
26
|
+
readonly transact?: ((io: EvolutionIoLike, path: string, task: (current: string | null) => string | null | Promise<string | null>) => Promise<void>) | undefined;
|
|
27
|
+
readonly now?: (() => number) | undefined;
|
|
28
|
+
}
|
|
29
|
+
export interface WriteOneOptions {
|
|
30
|
+
/** false is the create-under-root-lock retry seam: a mismatch defers instead of failing. */
|
|
31
|
+
readonly guardBaseline?: boolean | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The family's single write primitive: compare the baseline INSIDE the write
|
|
35
|
+
* transaction, never overwrite a concurrent value, and compensate only this
|
|
36
|
+
* unit's own writes. Business state machines stay in their domain owners.
|
|
37
|
+
*/
|
|
38
|
+
export declare class WriteKernel {
|
|
39
|
+
private readonly io;
|
|
40
|
+
private readonly transact;
|
|
41
|
+
private readonly now;
|
|
42
|
+
constructor(options: WriteKernelOptions);
|
|
43
|
+
/** @returns the current time source; exposed for adapters that stamp evidence. */
|
|
44
|
+
clock(): number;
|
|
45
|
+
/**
|
|
46
|
+
* Run a read-modify-write task inside the same transaction seam. Unlike
|
|
47
|
+
* writeOne, task errors keep their type (state files rely on WRITE_GATE_ERROR_NAME)
|
|
48
|
+
* and only committed-only failures become warnings. expected is optional because
|
|
49
|
+
* the state medium passes the task the locked current value itself.
|
|
50
|
+
*/
|
|
51
|
+
run(target: {
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly expected?: string | null | undefined;
|
|
54
|
+
}, task: (current: string | null) => string | null | Promise<string | null>): Promise<{
|
|
55
|
+
readonly warnings?: readonly string[] | undefined;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Write one target only if the current bytes still equal the baseline.
|
|
59
|
+
* A conflict performs no write. With guardBaseline:false a stale callback
|
|
60
|
+
* defers (returns the current bytes) so a retrying transact can re-observe;
|
|
61
|
+
* if no retry ever writes, the result is still a conflict.
|
|
62
|
+
*/
|
|
63
|
+
writeOne(target: WriteTarget, options?: WriteOneOptions): Promise<WriteKernelResult>;
|
|
64
|
+
/**
|
|
65
|
+
* Write a plan in order. A preflight drift writes nothing; a later failure
|
|
66
|
+
* compensates only the targets this commit wrote, and only while they still
|
|
67
|
+
* hold this commit's bytes.
|
|
68
|
+
*/
|
|
69
|
+
commit(targets: readonly WriteTarget[]): Promise<WriteKernelResult>;
|
|
70
|
+
/**
|
|
71
|
+
* Restore a target to its before bytes only if it still holds the bytes this
|
|
72
|
+
* unit wrote. A concurrent writer wins and the compensation is reported.
|
|
73
|
+
*/
|
|
74
|
+
restore(target: {
|
|
75
|
+
readonly path: string;
|
|
76
|
+
readonly before: string | null;
|
|
77
|
+
readonly after: string | null;
|
|
78
|
+
}): Promise<WriteKernelResult>;
|
|
79
|
+
private compensate;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=write-kernel.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isCommittedWarning } from "./io.js";
|
|
2
|
+
/**
|
|
3
|
+
* The family's single write primitive: compare the baseline INSIDE the write
|
|
4
|
+
* transaction, never overwrite a concurrent value, and compensate only this
|
|
5
|
+
* unit's own writes. Business state machines stay in their domain owners.
|
|
6
|
+
*/
|
|
7
|
+
export class WriteKernel {
|
|
8
|
+
io;
|
|
9
|
+
transact;
|
|
10
|
+
now;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.io = options.io;
|
|
13
|
+
this.transact = options.transact;
|
|
14
|
+
this.now = options.now ?? Date.now;
|
|
15
|
+
}
|
|
16
|
+
/** @returns the current time source; exposed for adapters that stamp evidence. */
|
|
17
|
+
clock() { return this.now(); }
|
|
18
|
+
/**
|
|
19
|
+
* Run a read-modify-write task inside the same transaction seam. Unlike
|
|
20
|
+
* writeOne, task errors keep their type (state files rely on WRITE_GATE_ERROR_NAME)
|
|
21
|
+
* and only committed-only failures become warnings. expected is optional because
|
|
22
|
+
* the state medium passes the task the locked current value itself.
|
|
23
|
+
*/
|
|
24
|
+
async run(target, task) {
|
|
25
|
+
let invoked = false;
|
|
26
|
+
let conflict = false;
|
|
27
|
+
const invoke = async (current) => {
|
|
28
|
+
if (target.expected !== undefined && current !== target.expected) {
|
|
29
|
+
conflict = true;
|
|
30
|
+
throw Object.assign(new Error('concurrent modification detected: ' + target.path), { noWrite: true });
|
|
31
|
+
}
|
|
32
|
+
invoked = true;
|
|
33
|
+
return await task(current);
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
if (this.transact !== undefined)
|
|
37
|
+
await this.transact(this.io, target.path, invoke);
|
|
38
|
+
else if (this.io.transact !== undefined)
|
|
39
|
+
await this.io.transact(target.path, invoke);
|
|
40
|
+
else {
|
|
41
|
+
const current = await this.io.readText(target.path);
|
|
42
|
+
const next = await invoke(current);
|
|
43
|
+
if (next === null && current !== null)
|
|
44
|
+
await this.io.remove(target.path);
|
|
45
|
+
else if (next !== null && next !== current)
|
|
46
|
+
await this.io.writeText(target.path, next);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (conflict)
|
|
51
|
+
throw Object.assign(new Error('concurrent modification detected: ' + target.path), { noWrite: true });
|
|
52
|
+
if (isCommittedWarning(error))
|
|
53
|
+
return { warnings: [error instanceof Error ? error.message : String(error)] };
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (!invoked)
|
|
57
|
+
throw new Error('internal error: the write transaction for ' + target.path + ' did not invoke the task; no write was performed');
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Write one target only if the current bytes still equal the baseline.
|
|
62
|
+
* A conflict performs no write. With guardBaseline:false a stale callback
|
|
63
|
+
* defers (returns the current bytes) so a retrying transact can re-observe;
|
|
64
|
+
* if no retry ever writes, the result is still a conflict.
|
|
65
|
+
*/
|
|
66
|
+
async writeOne(target, options = {}) {
|
|
67
|
+
const guard = options.guardBaseline !== false;
|
|
68
|
+
let wrote = false;
|
|
69
|
+
let deferred = false;
|
|
70
|
+
let conflict = false;
|
|
71
|
+
const task = (current) => {
|
|
72
|
+
if (current !== target.expected) {
|
|
73
|
+
if (guard) {
|
|
74
|
+
conflict = true;
|
|
75
|
+
throw Object.assign(new Error('concurrent modification detected: ' + target.path), { noWrite: true });
|
|
76
|
+
}
|
|
77
|
+
deferred = true;
|
|
78
|
+
return current;
|
|
79
|
+
}
|
|
80
|
+
wrote = true;
|
|
81
|
+
return target.next;
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
if (this.transact !== undefined)
|
|
85
|
+
await this.transact(this.io, target.path, task);
|
|
86
|
+
else if (this.io.transact !== undefined)
|
|
87
|
+
await this.io.transact(target.path, task);
|
|
88
|
+
else {
|
|
89
|
+
const current = await this.io.readText(target.path);
|
|
90
|
+
const next = task(current);
|
|
91
|
+
if (next === null && current !== null)
|
|
92
|
+
await this.io.remove(target.path);
|
|
93
|
+
else if (next !== null && next !== current)
|
|
94
|
+
await this.io.writeText(target.path, next);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (conflict)
|
|
99
|
+
return { ok: false, changed: [], errors: [{ code: 'cas-conflict', message: error instanceof Error ? error.message : String(error), target: target.path }] };
|
|
100
|
+
if (isCommittedWarning(error))
|
|
101
|
+
return { ok: true, changed: [target.path], errors: [], warnings: [error instanceof Error ? error.message : String(error)] };
|
|
102
|
+
return { ok: false, changed: [], errors: [{ code: 'write-failed', message: error instanceof Error ? error.message : String(error), target: target.path }] };
|
|
103
|
+
}
|
|
104
|
+
if (conflict || (!wrote && deferred))
|
|
105
|
+
return { ok: false, changed: [], errors: [{ code: 'cas-conflict', message: 'concurrent modification detected: ' + target.path, target: target.path }] };
|
|
106
|
+
if (!wrote)
|
|
107
|
+
return { ok: false, changed: [], errors: [{ code: 'write-failed', message: 'transaction did not invoke the write task: ' + target.path, target: target.path }] };
|
|
108
|
+
return { ok: true, changed: [target.path], errors: [] };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Write a plan in order. A preflight drift writes nothing; a later failure
|
|
112
|
+
* compensates only the targets this commit wrote, and only while they still
|
|
113
|
+
* hold this commit's bytes.
|
|
114
|
+
*/
|
|
115
|
+
async commit(targets) {
|
|
116
|
+
for (const target of targets) {
|
|
117
|
+
const current = await this.io.readText(target.path);
|
|
118
|
+
if (current !== target.expected)
|
|
119
|
+
return { ok: false, changed: [], errors: [{ code: 'cas-conflict', message: 'target changed after plan: ' + target.path, target: target.path }] };
|
|
120
|
+
}
|
|
121
|
+
const written = [];
|
|
122
|
+
for (const target of targets) {
|
|
123
|
+
const result = await this.writeOne(target);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
const compensation = await this.compensate(written);
|
|
126
|
+
return { ok: false, changed: written.map(item => item.path), errors: [...result.errors, ...compensation] };
|
|
127
|
+
}
|
|
128
|
+
written.push(target);
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, changed: written.map(item => item.path), errors: [] };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Restore a target to its before bytes only if it still holds the bytes this
|
|
134
|
+
* unit wrote. A concurrent writer wins and the compensation is reported.
|
|
135
|
+
*/
|
|
136
|
+
async restore(target) {
|
|
137
|
+
const result = await this.writeOne({ path: target.path, expected: target.after, next: target.before });
|
|
138
|
+
if (result.ok)
|
|
139
|
+
return result;
|
|
140
|
+
if (result.errors.some(error => error.code === 'cas-conflict')) {
|
|
141
|
+
return { ok: false, changed: [], errors: [{ code: 'compensation-blocked', message: 'compensation refused: target changed after this unit; leave the concurrent bytes in place', target: target.path }] };
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
async compensate(written) {
|
|
146
|
+
const errors = [];
|
|
147
|
+
for (const target of [...written].reverse()) {
|
|
148
|
+
const result = await this.restore({ path: target.path, before: target.expected, after: target.next });
|
|
149
|
+
if (!result.ok)
|
|
150
|
+
errors.push(...result.errors);
|
|
151
|
+
}
|
|
152
|
+
return errors;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=write-kernel.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lmzhen/dsh-evolution-core",
|
|
3
3
|
"description": "Shared stores, prompts, signals and lifecycle logic for the dsh-evolution plugin family (community build)",
|
|
4
|
-
"version": "0.32.
|
|
4
|
+
"version": "0.32.5",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"yaml": "^2.4.2",
|
|
50
50
|
"zod": "^4.4.3",
|
|
51
51
|
"js-yaml": "^4.1.0",
|
|
52
|
-
"@lmzhen/dsh-evolution-contract": "^0.32.
|
|
52
|
+
"@lmzhen/dsh-evolution-contract": "^0.32.5"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@deepseek-ai/cordis": "^4.0.5",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"path": "<evolutionHome>/<review|curator|pending>-state.json",
|
|
88
88
|
"writer": "evolution-state-json/src/index.ts",
|
|
89
89
|
"serializedBy": "transact",
|
|
90
|
-
"marker": "
|
|
90
|
+
"marker": "new WriteKernel({ io: io() }).run({ path: join(root, file)",
|
|
91
91
|
"state": [],
|
|
92
92
|
"note": "The durable state stack (review/curator/pending). S4.4/O-6 moved its three warn/rewrite sets into the provider instance (QuarantineMemory), so no module-scope N12 store remains here."
|
|
93
93
|
},
|
|
@@ -297,15 +297,6 @@
|
|
|
297
297
|
"marker": "transactIo(io, pairIndexPath(home)",
|
|
298
298
|
"state": [],
|
|
299
299
|
"note": "pairKey → result/runId/target index; RMW under the same IO lock so two merge runs cannot lose each other's entries."
|
|
300
|
-
},
|
|
301
|
-
{
|
|
302
|
-
"id": "state-store-collections",
|
|
303
|
-
"path": "<evolutionHome>/state-store/<collection>.json",
|
|
304
|
-
"writer": "evolution-state-json/src/state-store.ts",
|
|
305
|
-
"serializedBy": "transact",
|
|
306
|
-
"marker": "transactIo(this.io, this.path(collection)",
|
|
307
|
-
"state": [],
|
|
308
|
-
"note": "Generic contract StateStore collections (one file per collection); provider serial queue plus transactIo. cross-process false on the node backend."
|
|
309
300
|
}
|
|
310
301
|
]
|
|
311
302
|
}
|