@moxxy/sdk 0.13.0 → 0.14.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.
@@ -0,0 +1,207 @@
1
+ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { z } from 'zod';
6
+ import { createJsonFileStore } from './json-file-store.js';
7
+
8
+ interface Item {
9
+ id: string;
10
+ n: number;
11
+ }
12
+
13
+ const itemSchema = z.object({ id: z.string(), n: z.number() });
14
+ const fileSchema = z.object({ version: z.literal(1), items: z.array(itemSchema) });
15
+
16
+ /** A `load` that mirrors the scheduler policy: corrupt/invalid -> empty. */
17
+ function lenientLoad(raw: string | null): Item[] {
18
+ if (raw === null) return [];
19
+ try {
20
+ const parsed = fileSchema.safeParse(JSON.parse(raw));
21
+ return parsed.success ? [...parsed.data.items] : [];
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+
27
+ describe('createJsonFileStore', () => {
28
+ let dir: string;
29
+ let file: string;
30
+
31
+ beforeEach(async () => {
32
+ dir = await mkdtemp(join(tmpdir(), 'moxxy-jfs-'));
33
+ file = join(dir, 'sub', 'store.json');
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await rm(dir, { recursive: true, force: true });
38
+ });
39
+
40
+ function store() {
41
+ return createJsonFileStore<Item>({ file, load: lenientLoad });
42
+ }
43
+
44
+ it('returns an empty list when the file is missing (ENOENT)', async () => {
45
+ expect(await store().read()).toEqual([]);
46
+ });
47
+
48
+ it('persists { version: 1, items } pretty-printed and creates parent dirs', async () => {
49
+ const s = store();
50
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
51
+ const raw = await readFile(file, 'utf8');
52
+ expect(JSON.parse(raw)).toEqual({ version: 1, items: [{ id: 'a', n: 1 }] });
53
+ // pretty-printed with 2-space indent
54
+ expect(raw).toContain('\n "version": 1');
55
+ // a fresh instance reads the persisted state back
56
+ expect(await store().read()).toEqual([{ id: 'a', n: 1 }]);
57
+ });
58
+
59
+ it('get() finds by id and returns null for misses', async () => {
60
+ const s = store();
61
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }, { id: 'b', n: 2 }]);
62
+ expect(await s.get('b')).toEqual({ id: 'b', n: 2 });
63
+ expect(await s.get('zzz')).toBeNull();
64
+ });
65
+
66
+ it('read() and the mutator each get a fresh copy (caller cannot mutate cache)', async () => {
67
+ const s = store();
68
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
69
+
70
+ // Mutating the array returned by read() must not affect the store.
71
+ const snap = await s.read();
72
+ snap.push({ id: 'evil', n: 99 });
73
+ expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
74
+
75
+ // The mutator receives a fresh slice; pushing into it then returning a
76
+ // DIFFERENT array means the push is discarded (proves it's a copy, not the
77
+ // live cache).
78
+ await s.mutate((items) => {
79
+ items.push({ id: 'discarded', n: -1 });
80
+ return [{ id: 'a', n: 2 }];
81
+ });
82
+ expect(await s.read()).toEqual([{ id: 'a', n: 2 }]);
83
+ });
84
+
85
+ it('serializes concurrent mutate() calls so neither clobbers the other', async () => {
86
+ const s = store();
87
+ // Fire many overlapping appends. Without the mutex these read the same
88
+ // baseline and the last write wins, dropping entries.
89
+ await Promise.all(
90
+ Array.from({ length: 25 }, (_, i) =>
91
+ s.mutate((items) => [...items, { id: `k${i}`, n: i }]),
92
+ ),
93
+ );
94
+ const all = await s.read();
95
+ expect(all).toHaveLength(25);
96
+ expect(new Set(all.map((x) => x.id)).size).toBe(25);
97
+ // The on-disk file agrees (every write landed atomically).
98
+ const onDisk = JSON.parse(await readFile(file, 'utf8')) as { items: Item[] };
99
+ expect(onDisk.items).toHaveLength(25);
100
+ });
101
+
102
+ it('leaves the prior file intact when serializing the new state throws', async () => {
103
+ const s = store();
104
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
105
+ const before = await readFile(file, 'utf8');
106
+
107
+ // A value JSON.stringify cannot serialize (BigInt) makes the persist throw
108
+ // before any bytes hit disk; the previously-persisted file must survive.
109
+ await expect(
110
+ s.mutate(() => [{ id: 'bad', n: 1n as unknown as number }]),
111
+ ).rejects.toThrow();
112
+
113
+ expect(await readFile(file, 'utf8')).toBe(before);
114
+ const leftovers = (await readdir(join(dir, 'sub'))).filter((n) => n.includes('.tmp'));
115
+ expect(leftovers).toEqual([]);
116
+ });
117
+
118
+ it('leaves the prior file intact when the atomic rename fails mid-write', async () => {
119
+ const s = store();
120
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
121
+ const before = await readFile(file, 'utf8');
122
+
123
+ // Replace the parent dir's write path: make the TARGET a directory so the
124
+ // temp-file rename throws. We can't do that to `file` itself without losing
125
+ // the prior copy, so use a sibling store whose target is a directory and
126
+ // assert no temp file is orphaned. The original store's file is untouched.
127
+ const sib = join(dir, 'sub', 'sibling');
128
+ await mkdir(sib, { recursive: true });
129
+ const s2 = createJsonFileStore<Item>({ file: sib, load: lenientLoad });
130
+ await expect(s2.mutate((items) => [...items, { id: 'x', n: 1 }])).rejects.toThrow();
131
+
132
+ // No orphaned temp files, and the unrelated prior file is intact.
133
+ const leftovers = (await readdir(join(dir, 'sub'))).filter((n) => n.includes('.tmp'));
134
+ expect(leftovers).toEqual([]);
135
+ expect(await readFile(file, 'utf8')).toBe(before);
136
+ });
137
+
138
+ it('lenient load resets a corrupt file to empty (scheduler policy)', async () => {
139
+ await mkdir(join(dir, 'sub'), { recursive: true });
140
+ await writeFile(file, '{ not json', 'utf8');
141
+ const s = store();
142
+ expect(await s.read()).toEqual([]);
143
+ // The bad file is left in place until the next write.
144
+ expect(await readFile(file, 'utf8')).toBe('{ not json');
145
+ });
146
+
147
+ it('lenient load resets a schema-mismatched file to empty', async () => {
148
+ await mkdir(join(dir, 'sub'), { recursive: true });
149
+ await writeFile(file, JSON.stringify({ version: 99, items: [] }), 'utf8');
150
+ expect(await store().read()).toEqual([]);
151
+ });
152
+
153
+ it('onReadError lets a store refuse to operate on a non-ENOENT read error', async () => {
154
+ // Point the store at a directory so readFile throws EISDIR (not ENOENT).
155
+ const asDir = join(dir, 'as-dir');
156
+ await mkdir(asDir, { recursive: true });
157
+ const s = createJsonFileStore<Item>({
158
+ file: asDir,
159
+ load: lenientLoad,
160
+ onReadError: (err) => {
161
+ throw new Error(`refusing: ${(err as NodeJS.ErrnoException).code}`);
162
+ },
163
+ });
164
+ await expect(s.read()).rejects.toThrow(/refusing:/);
165
+ });
166
+
167
+ it('re-throws a non-ENOENT read error by default (no onReadError)', async () => {
168
+ const asDir = join(dir, 'as-dir2');
169
+ await mkdir(asDir, { recursive: true });
170
+ const s = createJsonFileStore<Item>({ file: asDir, load: lenientLoad });
171
+ await expect(s.read()).rejects.toThrow();
172
+ });
173
+
174
+ it('honors a custom itemsKey, fileFields, stringify and writeOptions', async () => {
175
+ const f = join(dir, 'custom.json');
176
+ const s = createJsonFileStore<Item>({
177
+ file: f,
178
+ itemsKey: 'rows',
179
+ fileFields: {},
180
+ stringify: (obj) => JSON.stringify(obj) + '\n',
181
+ writeOptions: { mode: 0o600 },
182
+ load: (raw) => {
183
+ if (raw === null) return [];
184
+ const obj = JSON.parse(raw) as { rows: Item[] };
185
+ return obj.rows;
186
+ },
187
+ });
188
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
189
+ const raw = await readFile(f, 'utf8');
190
+ // versionless, name-key `rows`, compact, trailing newline
191
+ expect(raw).toBe('{"rows":[{"id":"a","n":1}]}\n');
192
+ const { stat } = await import('node:fs/promises');
193
+ expect((await stat(f)).mode & 0o777).toBe(0o600);
194
+ });
195
+
196
+ it('invalidate() forces a re-read from disk', async () => {
197
+ const s = store();
198
+ await s.mutate((items) => [...items, { id: 'a', n: 1 }]);
199
+ // Write a second entry directly to disk behind the store's back.
200
+ await writeFile(file, JSON.stringify({ version: 1, items: [{ id: 'b', n: 2 }] }), 'utf8');
201
+ // Stale cache still shows the first entry…
202
+ expect(await s.read()).toEqual([{ id: 'a', n: 1 }]);
203
+ s.invalidate();
204
+ // …until invalidated.
205
+ expect(await s.read()).toEqual([{ id: 'b', n: 2 }]);
206
+ });
207
+ });
@@ -0,0 +1,144 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { createMutex, type Mutex } from './mutex.js';
3
+ import { writeFileAtomic, type WriteFileAtomicOptions } from './fs-utils.js';
4
+
5
+ /**
6
+ * Generic whole-file JSON collection store: the single home for the repeated
7
+ * "id-collection" persistence skeleton (invariant #5). It owns the in-memory
8
+ * cache, the per-instance write mutex, the read-modify-write `.slice()` copy,
9
+ * and the crash-atomic whole-file write via {@link writeFileAtomic} — so each
10
+ * domain store (scheduler, webhooks, …) no longer re-derives that atomicity
11
+ * by hand and can't silently regress it (forget the mutex, forget the copy,
12
+ * hand-roll a non-unique tmp).
13
+ *
14
+ * The store persists `{ [itemsKey]: T[], ...extraFileFields }` — by default
15
+ * `{ version: 1, items: [...] }`. The shape and key are configurable so a
16
+ * domain store keeps its exact on-disk format.
17
+ *
18
+ * Parsing/validation and corruption policy stay with the caller via
19
+ * {@link JsonFileStoreOptions.load}: it receives the raw file contents (or
20
+ * `null` when the file is absent) and returns the validated item array. This
21
+ * keeps each store's bespoke corrupt handling (silent reset vs. quarantine
22
+ * aside) byte-for-byte unchanged while centralizing the atomicity invariant.
23
+ *
24
+ * SDK-internal-dep-free: uses only node builtins + the SDK's own
25
+ * {@link createMutex} / {@link writeFileAtomic}.
26
+ */
27
+ export interface JsonFileStoreOptions<T extends { id: string }> {
28
+ /** Absolute path to the JSON file. */
29
+ readonly file: string;
30
+ /**
31
+ * Parse + validate the file contents into the item array. Called once per
32
+ * load with the raw UTF-8 string, or `null` when the file does not exist
33
+ * (ENOENT). Owns all corruption policy (quarantine, silent reset, throw).
34
+ * Any non-ENOENT read error is surfaced through {@link onReadError} first.
35
+ */
36
+ readonly load: (raw: string | null) => T[] | Promise<T[]>;
37
+ /**
38
+ * Called when reading the file fails with a non-ENOENT error (permissions,
39
+ * I/O, …). Return an item array to recover, or throw to refuse to operate
40
+ * (the safe direction when the file may hold the only copy of secrets).
41
+ * Defaults to re-throwing the original error.
42
+ */
43
+ readonly onReadError?: (err: unknown) => T[] | Promise<T[]>;
44
+ /**
45
+ * Property name the items array is stored under. Default `'items'`.
46
+ * (scheduler uses `'schedules'`, webhooks `'triggers'`.)
47
+ */
48
+ readonly itemsKey?: string;
49
+ /**
50
+ * Extra top-level fields written alongside the items. Default
51
+ * `{ version: 1 }`. Pass `{}` for a versionless file.
52
+ */
53
+ readonly fileFields?: Record<string, unknown>;
54
+ /**
55
+ * How the persisted object is serialized. Default: pretty 2-space JSON
56
+ * (matches the scheduler/webhooks format). Receives the full file object.
57
+ */
58
+ readonly stringify?: (fileObject: Record<string, unknown>) => string;
59
+ /** Atomic-write options forwarded to {@link writeFileAtomic} (e.g. `mode`). */
60
+ readonly writeOptions?: WriteFileAtomicOptions;
61
+ }
62
+
63
+ /**
64
+ * The collection store handle. Domain stores compose this and add their own
65
+ * id/createdAt minting and bespoke methods on top of {@link mutate}.
66
+ */
67
+ export interface JsonFileStore<T extends { id: string }> {
68
+ /** Loaded snapshot (a fresh shallow copy; safe to mutate by the caller). */
69
+ read(): Promise<T[]>;
70
+ /** Find a single item by id, or `null`. */
71
+ get(id: string): Promise<T | null>;
72
+ /**
73
+ * Read-modify-write under the write mutex. The mutator receives a fresh
74
+ * shallow copy of the current items; whatever it returns becomes the new
75
+ * state and is persisted atomically before {@link mutate} resolves.
76
+ */
77
+ mutate(fn: (items: T[]) => T[] | Promise<T[]>): Promise<void>;
78
+ /** Drop the cache so the next access re-reads from disk. */
79
+ invalidate(): void;
80
+ }
81
+
82
+ const prettyJson = (value: Record<string, unknown>): string => JSON.stringify(value, null, 2);
83
+
84
+ export function createJsonFileStore<T extends { id: string }>(
85
+ opts: JsonFileStoreOptions<T>,
86
+ ): JsonFileStore<T> {
87
+ const {
88
+ file,
89
+ load,
90
+ onReadError,
91
+ itemsKey = 'items',
92
+ fileFields = { version: 1 },
93
+ stringify = prettyJson,
94
+ writeOptions,
95
+ } = opts;
96
+
97
+ let cache: T[] | null = null;
98
+ const mutex: Mutex = createMutex();
99
+
100
+ async function ensureLoaded(): Promise<void> {
101
+ if (cache) return;
102
+ let raw: string | null;
103
+ try {
104
+ raw = await readFile(file, 'utf8');
105
+ } catch (err) {
106
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
107
+ raw = null;
108
+ } else if (onReadError) {
109
+ cache = await onReadError(err);
110
+ return;
111
+ } else {
112
+ throw err;
113
+ }
114
+ }
115
+ cache = await load(raw);
116
+ }
117
+
118
+ async function persist(items: T[]): Promise<void> {
119
+ const payload = stringify({ ...fileFields, [itemsKey]: items });
120
+ await writeFileAtomic(file, payload, writeOptions ?? {});
121
+ }
122
+
123
+ return {
124
+ async read(): Promise<T[]> {
125
+ await ensureLoaded();
126
+ return cache!.slice();
127
+ },
128
+ async get(id: string): Promise<T | null> {
129
+ await ensureLoaded();
130
+ return cache!.find((item) => item.id === id) ?? null;
131
+ },
132
+ async mutate(fn): Promise<void> {
133
+ await mutex.run(async () => {
134
+ await ensureLoaded();
135
+ const updated = await fn(cache!.slice());
136
+ cache = updated;
137
+ await persist(updated);
138
+ });
139
+ },
140
+ invalidate(): void {
141
+ cache = null;
142
+ },
143
+ };
144
+ }
package/src/mode.ts CHANGED
@@ -64,6 +64,16 @@ export interface ElisionSettings {
64
64
  export interface ModeContext {
65
65
  readonly sessionId: SessionId;
66
66
  readonly turnId: TurnId;
67
+ /**
68
+ * The session's working directory and environment, mirrored from
69
+ * {@link AppContext}. Threaded into the `dispatchToolCall` hook context so
70
+ * `onToolCall` hooks that gate on cwd/env (path-based policy/security hooks)
71
+ * see the real per-session values rather than empty placeholders. Tools
72
+ * themselves get cwd via the tool registry's default; this carries the same
73
+ * truth to the hook layer so the two never disagree.
74
+ */
75
+ readonly cwd: string;
76
+ readonly env: Readonly<Record<string, string | undefined>>;
67
77
  readonly model: string;
68
78
  readonly systemPrompt?: string;
69
79
  readonly provider: LLMProvider;
@@ -26,9 +26,12 @@ export async function* dispatchToolCall(
26
26
  try {
27
27
  const verdict = await ctx.hooks.dispatchToolCall({
28
28
  sessionId: ctx.sessionId,
29
- cwd: '',
29
+ // Hand the hook the session's real cwd/env (mirrored onto ModeContext by
30
+ // run-turn / the subagent runtime). Previously hardcoded '' / {}, which
31
+ // silently defeated path-based onToolCall policy/security hooks.
32
+ cwd: ctx.cwd,
30
33
  log: ctx.log,
31
- env: {},
34
+ env: ctx.env,
32
35
  turnId: ctx.turnId,
33
36
  iteration,
34
37
  call: { callId: asToolCallId(t.id), name: t.name, input: t.input },
@@ -2,9 +2,11 @@ import { describe, expectTypeOf, it } from 'vitest';
2
2
  import type {
3
3
  MoxxyEvent,
4
4
  MoxxyEventOfType,
5
+ PluginRegisteredEvent,
5
6
  ToolCallRequestedEvent,
6
7
  UserPromptEvent,
7
8
  } from './events.js';
9
+ import type { PluginKind } from './plugin.js';
8
10
 
9
11
  describe('event union narrowing', () => {
10
12
  it('MoxxyEventOfType narrows correctly', () => {
@@ -27,3 +29,14 @@ describe('event union narrowing', () => {
27
29
  expectTypeOf(handle).returns.toEqualTypeOf<string | null>();
28
30
  });
29
31
  });
32
+
33
+ describe('PluginRegisteredEvent.kind stays in sync with PluginKind', () => {
34
+ it('the inlined kind union equals PluginKind member-for-member', () => {
35
+ // events.ts inlines the kind union (to avoid an events↔plugin value cycle)
36
+ // with a "keep in sync with PluginKind" note. This bidirectional equality
37
+ // fails the build the moment PluginKind grows a member the inlined union
38
+ // lacks (or vice versa) — exactly the drift the note was meant to prevent.
39
+ type RegisteredKind = PluginRegisteredEvent['kind'][number];
40
+ expectTypeOf<RegisteredKind>().toEqualTypeOf<PluginKind>();
41
+ });
42
+ });