@moxxy/core 0.26.0 → 0.28.0
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/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/host-options.d.ts +2 -0
- package/dist/plugins/host-options.d.ts.map +1 -1
- package/dist/plugins/host.d.ts +8 -0
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +14 -0
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +1 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +11 -0
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/registries/modes.d.ts +8 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +11 -0
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/reflectors.d.ts +15 -0
- package/dist/registries/reflectors.d.ts.map +1 -0
- package/dist/registries/reflectors.js +16 -0
- package/dist/registries/reflectors.js.map +1 -0
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +5 -0
- package/dist/run-turn.js.map +1 -1
- package/dist/session-runtime.d.ts +4 -0
- package/dist/session-runtime.d.ts.map +1 -1
- package/dist/session.d.ts +9 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +17 -0
- package/dist/session.js.map +1 -1
- package/dist/sessions/event-shape.d.ts +68 -0
- package/dist/sessions/event-shape.d.ts.map +1 -0
- package/dist/sessions/event-shape.js +171 -0
- package/dist/sessions/event-shape.js.map +1 -0
- package/dist/sessions/persistence.d.ts +7 -5
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +39 -13
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/skill-usage.d.ts +62 -0
- package/dist/skill-usage.d.ts.map +1 -0
- package/dist/skill-usage.js +106 -0
- package/dist/skill-usage.js.map +1 -0
- package/dist/skills/synthesize.d.ts.map +1 -1
- package/dist/skills/synthesize.js +42 -0
- package/dist/skills/synthesize.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +4 -0
- package/dist/subagents/run-child.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +11 -0
- package/src/plugins/host-options.ts +2 -0
- package/src/plugins/host.ts +14 -0
- package/src/plugins/registry-kinds.test.ts +4 -0
- package/src/plugins/registry-kinds.ts +13 -0
- package/src/registries/modes.test.ts +27 -0
- package/src/registries/modes.ts +12 -0
- package/src/registries/reflectors.test.ts +50 -0
- package/src/registries/reflectors.ts +17 -0
- package/src/run-turn.ts +5 -0
- package/src/session-runtime.ts +4 -0
- package/src/session.ts +18 -0
- package/src/sessions/event-shape.test.ts +207 -0
- package/src/sessions/event-shape.ts +196 -0
- package/src/sessions/persistence.test.ts +132 -0
- package/src/sessions/persistence.ts +39 -13
- package/src/skill-usage.test.ts +102 -0
- package/src/skill-usage.ts +165 -0
- package/src/skills/synthesize.ts +42 -0
- package/src/subagents/run-child.ts +4 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime shape guard for event-log lines — the read side of the EventStore
|
|
3
|
+
* trust boundary.
|
|
4
|
+
*
|
|
5
|
+
* The per-session JSONL is parsed back into `MoxxyEvent`s on every resume/
|
|
6
|
+
* attach (`restoreEvents`), every history page (`readEventPage`), and every
|
|
7
|
+
* index hydration (`matchingSessionStatsFromLog`). Corrupt-JSON lines were
|
|
8
|
+
* always skipped, but a structurally-valid-but-wrong-shape line used to be
|
|
9
|
+
* CAST straight to `MoxxyEvent` and then drive replay and state
|
|
10
|
+
* reconstruction — e.g. a `compaction` line missing `replacedRange`/`summary`
|
|
11
|
+
* throws inside `projectMessagesFromLog`'s unconditional
|
|
12
|
+
* `event.summary.trim()` / `event.replacedRange[0]` dereferences, mid-replay.
|
|
13
|
+
* This guard closes that gap: wrong-shape lines are skipped with the exact
|
|
14
|
+
* same semantics as corrupt lines (never throw, never truncate what follows).
|
|
15
|
+
*
|
|
16
|
+
* CALIBRATION — why this is a shallow structural check, not a full schema:
|
|
17
|
+
*
|
|
18
|
+
* `restoreEvents` REWRITES the repaired log after skipping bad lines, so a
|
|
19
|
+
* false positive here permanently deletes history. The guard therefore
|
|
20
|
+
* rejects only shapes that would actively corrupt replay, and tolerates
|
|
21
|
+
* benign drift:
|
|
22
|
+
*
|
|
23
|
+
* - Envelope: strict on the fields the replay machinery itself dereferences
|
|
24
|
+
* (`id`, `seq`, `ts`, `type` — mirrors dedupe by `id`, ingest/paging/
|
|
25
|
+
* re-sequencing key on numeric `seq`). Lenient-if-present on `sessionId` /
|
|
26
|
+
* `turnId` / `source`: the existing session-ownership path already
|
|
27
|
+
* tolerates and normalizes a missing `sessionId`
|
|
28
|
+
* (`eventBelongsToSession`), and none of these are dereferenced in a way
|
|
29
|
+
* that can throw.
|
|
30
|
+
* - Unknown `type` with a valid envelope is KEPT, not dropped: every
|
|
31
|
+
* downstream fold pattern-matches known types and ignores the rest (there
|
|
32
|
+
* is no exhaustive-switch throw), and the desktop floor mechanism makes
|
|
33
|
+
* "older core replaying a newer log" a designed scenario — dropping (and
|
|
34
|
+
* then rewriting away) a newer version's events on rollback would be
|
|
35
|
+
* silent history loss. This matches the pre-guard behavior for that class
|
|
36
|
+
* of line exactly.
|
|
37
|
+
* - Known `type`: the variant's required primitive fields are checked
|
|
38
|
+
* shallowly (`typeof` / `Array.isArray`), because the projection folds
|
|
39
|
+
* dereference them unconditionally. Enum-valued strings (`stopReason`,
|
|
40
|
+
* `decidedBy`, …) are checked as `string` only — a newer version adding an
|
|
41
|
+
* enum member must not get its events dropped by an older reader.
|
|
42
|
+
*
|
|
43
|
+
* LOCKSTEP — the spec map below is typed so it cannot drift from the union:
|
|
44
|
+
* it is exhaustive over `MoxxyEventType` (adding a variant fails the build
|
|
45
|
+
* until a spec is added) and each spec's keys must be required non-envelope
|
|
46
|
+
* keys of that exact variant (renaming/removing a field fails the build).
|
|
47
|
+
* Listing a field is optional per-variant, so a future required field can be
|
|
48
|
+
* deliberately left unchecked for back-compat (old logs won't have it) — omit
|
|
49
|
+
* with a comment when doing so.
|
|
50
|
+
*
|
|
51
|
+
* PERFORMANCE — this runs once per line while iterating whole logs on the
|
|
52
|
+
* replay hot path. It allocates nothing per call: a handful of `typeof`
|
|
53
|
+
* checks plus an indexed walk over a per-variant spec array precomputed at
|
|
54
|
+
* module load. (This is also why it is hand-rolled rather than zod: a parsed
|
|
55
|
+
* schema library allocates per-line result/issue objects and would
|
|
56
|
+
* deep-validate bulky payloads — attachments, tool outputs — that replay
|
|
57
|
+
* never dereferences structurally.)
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import type { EventBase, MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from '@moxxy/sdk';
|
|
61
|
+
|
|
62
|
+
/** Shallow runtime kinds a required variant field can be checked as. */
|
|
63
|
+
type FieldKind = 'string' | 'number' | 'boolean' | 'array' | 'seqRange';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Keys of `T` that are required AND carry a checkable type. `undefined
|
|
67
|
+
* extends T[K]` excludes both optional fields and `unknown`-typed ones
|
|
68
|
+
* (`tool_call_requested.input`, `plugin_event.payload`) — the latter on
|
|
69
|
+
* purpose: any JSON value is a valid `unknown`, and `JSON.stringify` drops
|
|
70
|
+
* the key entirely when the value was `undefined` at emit time, so even a
|
|
71
|
+
* presence check would drop legitimate events.
|
|
72
|
+
*/
|
|
73
|
+
type CheckableKeys<T> = { [K in keyof T]-?: undefined extends T[K] ? never : K }[keyof T];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The checkable fields of one variant: required keys that are NOT part of the
|
|
77
|
+
* shared envelope (`EventBase` + the discriminant). Keys are optional so a
|
|
78
|
+
* back-compat omission is possible, but any key listed must exist on the
|
|
79
|
+
* variant — a rename/removal in the sdk union breaks this file's typecheck.
|
|
80
|
+
*/
|
|
81
|
+
type VariantFieldSpec<T extends MoxxyEventType> = {
|
|
82
|
+
readonly [K in Exclude<
|
|
83
|
+
CheckableKeys<MoxxyEventOfType<T>>,
|
|
84
|
+
keyof EventBase | 'type'
|
|
85
|
+
>]?: FieldKind;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Exhaustive per-variant field specs — one entry per union member. */
|
|
89
|
+
const VARIANT_SPECS: { readonly [T in MoxxyEventType]: VariantFieldSpec<T> } = {
|
|
90
|
+
user_prompt: { text: 'string' },
|
|
91
|
+
assistant_chunk: { delta: 'string' },
|
|
92
|
+
assistant_message: { content: 'string', stopReason: 'string' },
|
|
93
|
+
reasoning_chunk: { delta: 'string' },
|
|
94
|
+
reasoning_message: { content: 'string' },
|
|
95
|
+
tool_call_requested: { callId: 'string', name: 'string' },
|
|
96
|
+
tool_call_approved: { callId: 'string', decidedBy: 'string', mode: 'string' },
|
|
97
|
+
tool_call_denied: { callId: 'string', decidedBy: 'string', reason: 'string' },
|
|
98
|
+
tool_result: { callId: 'string', ok: 'boolean' },
|
|
99
|
+
skill_invoked: { skillId: 'string', name: 'string', reason: 'string' },
|
|
100
|
+
skill_created: {
|
|
101
|
+
skillId: 'string',
|
|
102
|
+
name: 'string',
|
|
103
|
+
path: 'string',
|
|
104
|
+
scope: 'string',
|
|
105
|
+
originatingPrompt: 'string',
|
|
106
|
+
},
|
|
107
|
+
plugin_registered: { pluginId: 'string', name: 'string', version: 'string', kind: 'array' },
|
|
108
|
+
plugin_unregistered: { pluginId: 'string', name: 'string', reason: 'string' },
|
|
109
|
+
mode_iteration: { strategy: 'string', iteration: 'number' },
|
|
110
|
+
compaction: {
|
|
111
|
+
compactor: 'string',
|
|
112
|
+
replacedRange: 'seqRange',
|
|
113
|
+
summary: 'string',
|
|
114
|
+
tokensSaved: 'number',
|
|
115
|
+
},
|
|
116
|
+
elision: {
|
|
117
|
+
elidedThrough: 'number',
|
|
118
|
+
stubbedRanges: 'array',
|
|
119
|
+
elideConversational: 'boolean',
|
|
120
|
+
conversationalRecallThreshold: 'number',
|
|
121
|
+
maxRecallBytes: 'number',
|
|
122
|
+
neverElideTools: 'array',
|
|
123
|
+
tokensSaved: 'number',
|
|
124
|
+
},
|
|
125
|
+
provider_request: { provider: 'string', model: 'string' },
|
|
126
|
+
provider_response: { provider: 'string', model: 'string' },
|
|
127
|
+
error: { kind: 'string', message: 'string' },
|
|
128
|
+
abort: { reason: 'string' },
|
|
129
|
+
plugin_event: { pluginId: 'string', subtype: 'string' },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Specs flattened to `[key, kind]` arrays ONCE at module load, so the per-line
|
|
133
|
+
* check walks a plain array by index — no per-call object/iterator work. */
|
|
134
|
+
const SPEC_ENTRIES: ReadonlyMap<string, ReadonlyArray<readonly [string, FieldKind]>> = new Map(
|
|
135
|
+
Object.entries(VARIANT_SPECS).map(([type, spec]) => [
|
|
136
|
+
type,
|
|
137
|
+
Object.entries(spec) as ReadonlyArray<readonly [string, FieldKind]>,
|
|
138
|
+
]),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
function fieldOk(value: unknown, kind: FieldKind): boolean {
|
|
142
|
+
switch (kind) {
|
|
143
|
+
case 'string':
|
|
144
|
+
return typeof value === 'string';
|
|
145
|
+
case 'number':
|
|
146
|
+
// JSON.parse CAN yield non-finite numbers (`1e999` → Infinity); a
|
|
147
|
+
// non-finite seq-adjacent number poisons every downstream comparison.
|
|
148
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
149
|
+
case 'boolean':
|
|
150
|
+
return typeof value === 'boolean';
|
|
151
|
+
case 'array':
|
|
152
|
+
return Array.isArray(value);
|
|
153
|
+
case 'seqRange':
|
|
154
|
+
// `[from, to]` seq bounds, compared against `event.seq` unconditionally
|
|
155
|
+
// by the projection fold — both must be real numbers.
|
|
156
|
+
return (
|
|
157
|
+
Array.isArray(value) &&
|
|
158
|
+
value.length === 2 &&
|
|
159
|
+
typeof value[0] === 'number' &&
|
|
160
|
+
Number.isFinite(value[0]) &&
|
|
161
|
+
typeof value[1] === 'number' &&
|
|
162
|
+
Number.isFinite(value[1])
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* True when `value` (a successfully-parsed JSONL line) is structurally safe to
|
|
169
|
+
* replay as a {@link MoxxyEvent}. See the module doc for what "safe" means —
|
|
170
|
+
* this is a calibrated floor (reject what corrupts replay, keep benign
|
|
171
|
+
* drift), not a proof of full conformance: an unknown newer event type with a
|
|
172
|
+
* valid envelope passes, exactly as it (unvalidated) did before this guard.
|
|
173
|
+
*/
|
|
174
|
+
export function isMoxxyEventShape(value: unknown): value is MoxxyEvent {
|
|
175
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
176
|
+
const e = value as Record<string, unknown>;
|
|
177
|
+
// Envelope — strict: replay machinery dereferences these on every event.
|
|
178
|
+
if (typeof e.type !== 'string') return false;
|
|
179
|
+
if (typeof e.id !== 'string') return false;
|
|
180
|
+
if (typeof e.seq !== 'number' || !Number.isFinite(e.seq)) return false;
|
|
181
|
+
if (typeof e.ts !== 'number' || !Number.isFinite(e.ts)) return false;
|
|
182
|
+
// Envelope — lenient-if-present: absent on some legacy logs (sessionId is
|
|
183
|
+
// normalized by `eventForSession`), but a present-with-wrong-type value is
|
|
184
|
+
// junk, not drift.
|
|
185
|
+
if (e.sessionId != null && typeof e.sessionId !== 'string') return false;
|
|
186
|
+
if (e.turnId != null && typeof e.turnId !== 'string') return false;
|
|
187
|
+
if (e.source != null && typeof e.source !== 'string') return false;
|
|
188
|
+
const fields = SPEC_ENTRIES.get(e.type);
|
|
189
|
+
// Unknown (newer) event type with a valid envelope: keep — see module doc.
|
|
190
|
+
if (!fields) return true;
|
|
191
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
192
|
+
const entry = fields[i]!;
|
|
193
|
+
if (!fieldOk(e[entry[0]], entry[1])) return false;
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
@@ -589,6 +589,138 @@ describe('SessionPersistence', () => {
|
|
|
589
589
|
expect(again.lines).toHaveLength(0);
|
|
590
590
|
});
|
|
591
591
|
|
|
592
|
+
it('restore skips valid-JSON wrong-shape lines exactly like corrupt ones and repairs the file', async () => {
|
|
593
|
+
const dir = await makeTempDir();
|
|
594
|
+
const id = '01BADSHAPE0000000000000000';
|
|
595
|
+
const mk = (seq: number, text: string) => ({
|
|
596
|
+
id: `e${seq}`,
|
|
597
|
+
seq,
|
|
598
|
+
ts: seq,
|
|
599
|
+
sessionId: id,
|
|
600
|
+
turnId: 't1',
|
|
601
|
+
source: 'user',
|
|
602
|
+
type: 'user_prompt',
|
|
603
|
+
text,
|
|
604
|
+
});
|
|
605
|
+
const lines = [
|
|
606
|
+
JSON.stringify(mk(0, 'zero')),
|
|
607
|
+
// Valid JSON, not an event at all (no envelope).
|
|
608
|
+
JSON.stringify({ foo: 'bar' }),
|
|
609
|
+
JSON.stringify(mk(2, 'two')),
|
|
610
|
+
// Valid envelope + known type, but missing the required fields the
|
|
611
|
+
// projection fold dereferences unconditionally (would throw mid-replay
|
|
612
|
+
// if trusted: `event.summary.trim()`, `event.replacedRange[0]`).
|
|
613
|
+
JSON.stringify({ id: 'junk', seq: 3, ts: 3, sessionId: id, turnId: 't1', source: 'compactor', type: 'compaction', compactor: 'x' }),
|
|
614
|
+
// Corrupt JSON still goes down its own (unchanged) path.
|
|
615
|
+
'{half a line',
|
|
616
|
+
JSON.stringify(mk(5, 'five')),
|
|
617
|
+
];
|
|
618
|
+
await fs.writeFile(path.join(dir, `${id}.jsonl`), lines.join('\n') + '\n', 'utf8');
|
|
619
|
+
|
|
620
|
+
const { logger, lines: logged } = captureLogger();
|
|
621
|
+
const restored = await restoreEvents(id, dir, logger);
|
|
622
|
+
|
|
623
|
+
// Survivors only, re-sequenced to contiguous 0..n-1, order + ids preserved.
|
|
624
|
+
expect(restored.map((e) => e.id)).toEqual(['e0', 'e2', 'e5']);
|
|
625
|
+
expect(restored.map((e) => e.seq)).toEqual([0, 1, 2]);
|
|
626
|
+
expect(restored.map((e) => (e as { text?: string }).text)).toEqual(['zero', 'two', 'five']);
|
|
627
|
+
// One structured warn, counting shape-junk separately from corrupt JSON.
|
|
628
|
+
const warn = logged.find((l) => l.level === 'warn');
|
|
629
|
+
expect(warn?.meta).toMatchObject({ corruptLines: 1, invalidShapeLines: 2, restoredEvents: 3 });
|
|
630
|
+
// Never throws mid-replay: every survivor ingests into a fresh mirror.
|
|
631
|
+
const mirror = new EventLog();
|
|
632
|
+
for (const e of restored) mirror.ingest(e);
|
|
633
|
+
expect(mirror.length).toBe(3);
|
|
634
|
+
// The file was repaired on disk, so the next restore is clean.
|
|
635
|
+
const repaired = (await fs.readFile(path.join(dir, `${id}.jsonl`), 'utf8'))
|
|
636
|
+
.trim()
|
|
637
|
+
.split('\n')
|
|
638
|
+
.map((line) => JSON.parse(line) as { id: string });
|
|
639
|
+
expect(repaired.map((e) => e.id)).toEqual(['e0', 'e2', 'e5']);
|
|
640
|
+
const again = captureLogger();
|
|
641
|
+
await restoreEvents(id, dir, again.logger);
|
|
642
|
+
expect(again.lines).toHaveLength(0);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('restore leaves a fully-valid log untouched on disk and replays it byte-identically', async () => {
|
|
646
|
+
const dir = await makeTempDir();
|
|
647
|
+
const id = '01ALLVALID0000000000000000';
|
|
648
|
+
const events = [
|
|
649
|
+
{ id: 'e0', seq: 0, ts: 1, sessionId: id, turnId: 't1', source: 'user', type: 'user_prompt', text: 'hi' },
|
|
650
|
+
{ id: 'e1', seq: 1, ts: 2, sessionId: id, turnId: 't1', source: 'model', type: 'assistant_message', content: 'hello', stopReason: 'end_turn' },
|
|
651
|
+
{ id: 'e2', seq: 2, ts: 3, sessionId: id, turnId: 't1', source: 'compactor', type: 'compaction', compactor: 'x', replacedRange: [0, 1], summary: 's', tokensSaved: 10 },
|
|
652
|
+
];
|
|
653
|
+
const original = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
654
|
+
await fs.writeFile(path.join(dir, `${id}.jsonl`), original, 'utf8');
|
|
655
|
+
|
|
656
|
+
const { logger, lines } = captureLogger();
|
|
657
|
+
const restored = await restoreEvents(id, dir, logger);
|
|
658
|
+
|
|
659
|
+
expect(restored).toEqual(events);
|
|
660
|
+
expect(lines).toHaveLength(0);
|
|
661
|
+
// No repair rewrite for a clean log — the bytes on disk are untouched.
|
|
662
|
+
expect(await fs.readFile(path.join(dir, `${id}.jsonl`), 'utf8')).toBe(original);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('readEventPage skips wrong-shape lines like corrupt ones (read-only, no rewrite)', async () => {
|
|
666
|
+
const dir = await makeTempDir();
|
|
667
|
+
const id = '01PAGEBADSHAPE000000000000';
|
|
668
|
+
const mk = (seq: number, text: string) => ({
|
|
669
|
+
id: `e${seq}`,
|
|
670
|
+
seq,
|
|
671
|
+
ts: seq,
|
|
672
|
+
sessionId: id,
|
|
673
|
+
turnId: 't1',
|
|
674
|
+
source: 'user',
|
|
675
|
+
type: 'user_prompt',
|
|
676
|
+
text,
|
|
677
|
+
});
|
|
678
|
+
const lines = [
|
|
679
|
+
JSON.stringify(mk(0, 'zero')),
|
|
680
|
+
JSON.stringify({ notAnEvent: true }),
|
|
681
|
+
JSON.stringify(mk(1, 'one')),
|
|
682
|
+
'{corrupt',
|
|
683
|
+
JSON.stringify(mk(2, 'two')),
|
|
684
|
+
];
|
|
685
|
+
const original = lines.join('\n') + '\n';
|
|
686
|
+
await fs.writeFile(path.join(dir, `${id}.jsonl`), original, 'utf8');
|
|
687
|
+
|
|
688
|
+
const page = await readEventPage(id, { before: null, limit: 10 }, dir);
|
|
689
|
+
expect(page.events.map((e) => e.seq)).toEqual([0, 1, 2]);
|
|
690
|
+
expect(page.prevCursor).toBeNull();
|
|
691
|
+
// Read-only: the junk stays on disk (restore owns the repair).
|
|
692
|
+
expect(await fs.readFile(path.join(dir, `${id}.jsonl`), 'utf8')).toBe(original);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('readIndex hydration ignores a wrong-shape line when recovering firstPrompt', async () => {
|
|
696
|
+
const dir = await makeTempDir();
|
|
697
|
+
await fs.mkdir(dir, { recursive: true });
|
|
698
|
+
const id = '01HYDRATEBADSHAPE000000000';
|
|
699
|
+
await fs.writeFile(
|
|
700
|
+
path.join(dir, `${id}.json`),
|
|
701
|
+
JSON.stringify({ ...meta(id, 3), firstPrompt: null }, null, 2),
|
|
702
|
+
'utf8',
|
|
703
|
+
);
|
|
704
|
+
const junkPrompt = JSON.stringify({ type: 'user_prompt', text: 'junk without envelope' });
|
|
705
|
+
const validPrompt = JSON.stringify({
|
|
706
|
+
id: 'e1',
|
|
707
|
+
seq: 1,
|
|
708
|
+
ts: 2,
|
|
709
|
+
sessionId: id,
|
|
710
|
+
turnId: 't1',
|
|
711
|
+
source: 'user',
|
|
712
|
+
type: 'user_prompt',
|
|
713
|
+
text: 'real prompt',
|
|
714
|
+
});
|
|
715
|
+
await fs.writeFile(path.join(dir, `${id}.jsonl`), junkPrompt + '\n' + validPrompt + '\n', 'utf8');
|
|
716
|
+
|
|
717
|
+
const [restored] = await readIndex(dir);
|
|
718
|
+
|
|
719
|
+
// The junk line neither becomes the label nor hides the later valid prompt.
|
|
720
|
+
expect(restored?.firstPrompt).toBe('real prompt');
|
|
721
|
+
expect(restored?.eventCount).toBe(1);
|
|
722
|
+
});
|
|
723
|
+
|
|
592
724
|
it('restore removes foreign-session events, creates a backup, and re-sequences survivors', async () => {
|
|
593
725
|
const dir = await makeTempDir();
|
|
594
726
|
const id = '01RESTOREFILTER000000000';
|
|
@@ -22,12 +22,14 @@ import { createMutex, type Mutex, type MoxxyEvent, type SessionId } from '@moxxy
|
|
|
22
22
|
import type { EventLogLike, EventPage, SessionMeta, SessionSource } from '@moxxy/sdk';
|
|
23
23
|
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
24
24
|
import { createLogger, type Logger } from '../logger.js';
|
|
25
|
+
import { isMoxxyEventShape } from './event-shape.js';
|
|
25
26
|
|
|
26
27
|
// `SessionSource`, `SessionMeta` and `EventPage` are the EventStore contract's
|
|
27
28
|
// data shapes — defined in @moxxy/sdk so `EventStoreDef` can reference them.
|
|
28
29
|
// Re-exported here (and onward from @moxxy/core) so existing importers are
|
|
29
30
|
// unaffected by the move.
|
|
30
31
|
export type { EventLogLike, EventPage, SessionMeta, SessionSource } from '@moxxy/sdk';
|
|
32
|
+
export { SESSION_SOURCES } from '@moxxy/sdk';
|
|
31
33
|
|
|
32
34
|
/** Schema version of the per-session metadata file (`<id>.json`). Bump when the
|
|
33
35
|
* shape changes incompatibly; readers tolerate a missing/older version. */
|
|
@@ -529,7 +531,10 @@ async function matchingSessionStatsFromLog(
|
|
|
529
531
|
for (const line of raw.split('\n')) {
|
|
530
532
|
if (!line.trim()) continue;
|
|
531
533
|
try {
|
|
532
|
-
|
|
534
|
+
const parsed: unknown = JSON.parse(line);
|
|
535
|
+
// Wrong-shape lines are skipped exactly like corrupt ones: neither a
|
|
536
|
+
// corrupt nor a junk line should hide a later valid prompt.
|
|
537
|
+
if (isMoxxyEventShape(parsed)) events.push(parsed);
|
|
533
538
|
} catch {
|
|
534
539
|
// A corrupt line should not hide a later valid prompt.
|
|
535
540
|
}
|
|
@@ -549,10 +554,11 @@ function providerHeaderFromEvent(event: MoxxyEvent): { provider?: string | null;
|
|
|
549
554
|
* Restore a previously-persisted session's events. Returns the full
|
|
550
555
|
* event array suitable for passing into `new EventLog(events)`.
|
|
551
556
|
*
|
|
552
|
-
* Skips malformed lines
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
557
|
+
* Skips malformed lines — corrupt JSON and valid-JSON-but-wrong-shape
|
|
558
|
+
* alike (see {@link isMoxxyEventShape}); a single bad append shouldn't
|
|
559
|
+
* make the rest of the conversation unreadable — and then RE-SEQUENCES
|
|
560
|
+
* the survivors to contiguous `seq` 0..n-1, preserving order and ids.
|
|
561
|
+
* This matters twice over:
|
|
556
562
|
*
|
|
557
563
|
* - Mirror replay: `EventLog.ingest` accepts only `seq === length`, so
|
|
558
564
|
* a gap left by one corrupt middle line would silently truncate every
|
|
@@ -584,15 +590,25 @@ export async function restoreEvents(
|
|
|
584
590
|
}
|
|
585
591
|
const events: MoxxyEvent[] = [];
|
|
586
592
|
let corruptLines = 0;
|
|
593
|
+
let invalidShapeLines = 0;
|
|
587
594
|
let foreignEvents = 0;
|
|
588
595
|
let normalizedSessionIds = 0;
|
|
589
596
|
for (const line of raw.split('\n')) {
|
|
590
597
|
if (!line.trim()) continue;
|
|
591
598
|
try {
|
|
592
|
-
const
|
|
593
|
-
|
|
599
|
+
const parsed: unknown = JSON.parse(line);
|
|
600
|
+
// Valid JSON but not a replayable event shape (see isMoxxyEventShape):
|
|
601
|
+
// skipped with the exact same semantics as a corrupt line — counted,
|
|
602
|
+
// re-sequenced around, and repaired away by the rewrite below. Casting
|
|
603
|
+
// it through instead used to let a junk line drive replay (a compaction
|
|
604
|
+
// line without `replacedRange` throws mid-projection).
|
|
605
|
+
if (!isMoxxyEventShape(parsed)) {
|
|
606
|
+
invalidShapeLines += 1;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const ownedEvent = eventForSession(parsed, sessionId);
|
|
594
610
|
if (ownedEvent) {
|
|
595
|
-
if (ownedEvent !==
|
|
611
|
+
if (ownedEvent !== parsed) normalizedSessionIds += 1;
|
|
596
612
|
events.push(ownedEvent);
|
|
597
613
|
} else {
|
|
598
614
|
foreignEvents += 1;
|
|
@@ -612,7 +628,13 @@ export async function restoreEvents(
|
|
|
612
628
|
}
|
|
613
629
|
}
|
|
614
630
|
|
|
615
|
-
if (
|
|
631
|
+
if (
|
|
632
|
+
corruptLines > 0 ||
|
|
633
|
+
invalidShapeLines > 0 ||
|
|
634
|
+
resequenced > 0 ||
|
|
635
|
+
foreignEvents > 0 ||
|
|
636
|
+
normalizedSessionIds > 0
|
|
637
|
+
) {
|
|
616
638
|
const message =
|
|
617
639
|
foreignEvents > 0
|
|
618
640
|
? 'session log restored with foreign-session events removed — re-sequenced to keep full history replayable'
|
|
@@ -621,6 +643,7 @@ export async function restoreEvents(
|
|
|
621
643
|
sessionId,
|
|
622
644
|
path: logPath,
|
|
623
645
|
corruptLines,
|
|
646
|
+
invalidShapeLines,
|
|
624
647
|
foreignEvents,
|
|
625
648
|
normalizedSessionIds,
|
|
626
649
|
resequencedEvents: resequenced,
|
|
@@ -695,7 +718,7 @@ export async function restoreEvents(
|
|
|
695
718
|
* `prevCursor` is the `seq` of the page's oldest event, or `null` once the
|
|
696
719
|
* first persisted event is included (no older page remains).
|
|
697
720
|
*
|
|
698
|
-
* Corrupt lines are skipped (matching {@link restoreEvents}); unlike
|
|
721
|
+
* Corrupt and wrong-shape lines are skipped (matching {@link restoreEvents}); unlike
|
|
699
722
|
* `restoreEvents` this is a READ-ONLY reader — it never rewrites the file, so
|
|
700
723
|
* it preserves the JSONL exactly (no atomic-write / mutex needed: there is no
|
|
701
724
|
* mutation). Paging keys on each event's on-disk `seq`. Determinism holds for a
|
|
@@ -724,13 +747,16 @@ export async function readEventPage(
|
|
|
724
747
|
return { events: [], prevCursor: null };
|
|
725
748
|
}
|
|
726
749
|
|
|
727
|
-
// Parse the JSONL, skipping corrupt lines (one bad append
|
|
728
|
-
// rest unreadable). Read-only: we never rewrite the file
|
|
750
|
+
// Parse the JSONL, skipping corrupt AND wrong-shape lines (one bad append
|
|
751
|
+
// must not make the rest unreadable). Read-only: we never rewrite the file
|
|
752
|
+
// here.
|
|
729
753
|
const all: MoxxyEvent[] = [];
|
|
730
754
|
for (const line of raw.split('\n')) {
|
|
731
755
|
if (!line.trim()) continue;
|
|
732
756
|
try {
|
|
733
|
-
|
|
757
|
+
const parsed: unknown = JSON.parse(line);
|
|
758
|
+
// skip a valid-JSON-but-not-an-event line, same as restoreEvents
|
|
759
|
+
if (isMoxxyEventShape(parsed)) all.push(parsed);
|
|
734
760
|
} catch {
|
|
735
761
|
// skip a malformed/half-written line, same as restoreEvents
|
|
736
762
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { loadSkillUsage, mergeSkillUsage } from './skill-usage.js';
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
let usagePath: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-skill-usage-'));
|
|
12
|
+
usagePath = path.join(tmpDir, 'usage.json');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('skill-usage store', () => {
|
|
20
|
+
it('returns an empty aggregate when the file is missing', async () => {
|
|
21
|
+
const file = await loadSkillUsage(usagePath);
|
|
22
|
+
expect(file.skills).toEqual({});
|
|
23
|
+
expect(file.version).toBe(1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('merges a delta and round-trips through disk', async () => {
|
|
27
|
+
await mergeSkillUsage(
|
|
28
|
+
{ 'deploy-app': { invocations: 2, lastInvokedAt: '2026-07-03T10:00:00.000Z' } },
|
|
29
|
+
usagePath,
|
|
30
|
+
);
|
|
31
|
+
const file = await loadSkillUsage(usagePath);
|
|
32
|
+
expect(file.skills['deploy-app']!.invocations).toBe(2);
|
|
33
|
+
expect(file.skills['deploy-app']!.lastInvokedAt).toBe('2026-07-03T10:00:00.000Z');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('accumulates invocations across merges and advances lastInvokedAt', async () => {
|
|
37
|
+
await mergeSkillUsage(
|
|
38
|
+
{ 'deploy-app': { invocations: 1, lastInvokedAt: '2026-07-01T00:00:00.000Z' } },
|
|
39
|
+
usagePath,
|
|
40
|
+
);
|
|
41
|
+
await mergeSkillUsage(
|
|
42
|
+
{ 'deploy-app': { invocations: 3, lastInvokedAt: '2026-07-02T00:00:00.000Z' } },
|
|
43
|
+
usagePath,
|
|
44
|
+
);
|
|
45
|
+
const file = await loadSkillUsage(usagePath);
|
|
46
|
+
expect(file.skills['deploy-app']!.invocations).toBe(4);
|
|
47
|
+
// The later timestamp wins even if a merge arrives out of order.
|
|
48
|
+
expect(file.skills['deploy-app']!.lastInvokedAt).toBe('2026-07-02T00:00:00.000Z');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps the earliest createdAt and never lets a later merge overwrite it', async () => {
|
|
52
|
+
await mergeSkillUsage(
|
|
53
|
+
{ 'deploy-app': { invocations: 0, createdAt: '2026-06-01T00:00:00.000Z' } },
|
|
54
|
+
usagePath,
|
|
55
|
+
);
|
|
56
|
+
await mergeSkillUsage(
|
|
57
|
+
{ 'deploy-app': { invocations: 1, createdAt: '2026-07-01T00:00:00.000Z' } },
|
|
58
|
+
usagePath,
|
|
59
|
+
);
|
|
60
|
+
const file = await loadSkillUsage(usagePath);
|
|
61
|
+
expect(file.skills['deploy-app']!.createdAt).toBe('2026-06-01T00:00:00.000Z');
|
|
62
|
+
expect(file.skills['deploy-app']!.invocations).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does not advance lastInvokedAt when an out-of-order (earlier) delta arrives', async () => {
|
|
66
|
+
await mergeSkillUsage(
|
|
67
|
+
{ s: { invocations: 1, lastInvokedAt: '2026-07-05T00:00:00.000Z' } },
|
|
68
|
+
usagePath,
|
|
69
|
+
);
|
|
70
|
+
await mergeSkillUsage(
|
|
71
|
+
{ s: { invocations: 1, lastInvokedAt: '2026-07-01T00:00:00.000Z' } },
|
|
72
|
+
usagePath,
|
|
73
|
+
);
|
|
74
|
+
const file = await loadSkillUsage(usagePath);
|
|
75
|
+
expect(file.skills['s']!.lastInvokedAt).toBe('2026-07-05T00:00:00.000Z');
|
|
76
|
+
expect(file.skills['s']!.invocations).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('an empty delta is a no-op read (does not create the file)', async () => {
|
|
80
|
+
const file = await mergeSkillUsage({}, usagePath);
|
|
81
|
+
expect(file.skills).toEqual({});
|
|
82
|
+
await expect(fs.access(usagePath)).rejects.toThrow();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to empty on a corrupt (unparseable) file', async () => {
|
|
86
|
+
await fs.writeFile(usagePath, '{ this is not json');
|
|
87
|
+
expect((await loadSkillUsage(usagePath)).skills).toEqual({});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falls back to empty on a shape-invalid file (non-numeric counter)', async () => {
|
|
91
|
+
// A hand-edited file with a string counter must not flow into the additive
|
|
92
|
+
// merge (which would corrupt the aggregate via string concatenation).
|
|
93
|
+
await fs.writeFile(
|
|
94
|
+
usagePath,
|
|
95
|
+
JSON.stringify({ version: 1, updatedAt: 'x', skills: { s: { invocations: '5' } } }),
|
|
96
|
+
);
|
|
97
|
+
expect((await loadSkillUsage(usagePath)).skills).toEqual({});
|
|
98
|
+
// A subsequent merge therefore starts fresh rather than concatenating.
|
|
99
|
+
await mergeSkillUsage({ s: { invocations: 2 } }, usagePath);
|
|
100
|
+
expect((await loadSkillUsage(usagePath)).skills['s']!.invocations).toBe(2);
|
|
101
|
+
});
|
|
102
|
+
});
|