@cueloop/daemon 0.1.0-alpha.62 → 0.1.0-alpha.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/api.ts +1 -1
- package/src/capabilities.ts +6 -3
- package/src/client.ts +60 -28
- package/src/diff-watcher.ts +1 -4
- package/src/herdr-pane.test.ts +3 -6
- package/src/herdr-pane.ts +20 -7
- package/src/herdr-split.test.ts +1 -1
- package/src/herdr-split.ts +8 -4
- package/src/herdr-tab-store.ts +10 -13
- package/src/protocol.ts +25 -0
- package/src/retention.test.ts +3 -3
- package/src/retention.ts +1 -1
- package/src/review.test.ts +24 -0
- package/src/review.ts +6 -2
- package/src/server.test.ts +29 -52
- package/src/server.ts +12 -6
- package/src/share-blob.ts +1 -1
- package/src/store.ts +1 -1
- package/src/validate.test.ts +14 -9
- package/src/validate.ts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.64",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"./review": "./src/review.ts",
|
|
24
24
|
"./working-tree": "./src/working-tree.ts",
|
|
25
25
|
"./store": "./src/store.ts",
|
|
26
|
+
"./validate": "./src/validate.ts",
|
|
26
27
|
"./paths": "./src/paths.ts",
|
|
27
28
|
"./retention": "./src/retention.ts",
|
|
28
29
|
"./herdr-pane": "./src/herdr-pane.ts",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"access": "public"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
37
|
+
"@cueloop/schema": "0.1.0-alpha.64",
|
|
37
38
|
"valibot": "^1.4.2"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
package/src/api.ts
CHANGED
|
@@ -442,7 +442,7 @@ function isLiveDiffSession(session: ReviewSession): boolean {
|
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
/** Convenience for adapters: map a resolved session to the agent contract. */
|
|
445
|
-
export function verdictResponse(session: ReviewSession)
|
|
445
|
+
export function verdictResponse(session: ReviewSession) {
|
|
446
446
|
if (!session.verdict) throw new DaemonError("pending", "session has no verdict");
|
|
447
447
|
|
|
448
448
|
return { allow: verdictAllows(session.verdict.kind), feedback: session.verdict.feedback };
|
package/src/capabilities.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* This is the single source of truth the server dispatch enforces.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
|
|
9
11
|
export type DaemonRole = "owner" | "collaborator" | "agent";
|
|
10
12
|
|
|
11
13
|
/** Methods a non-owner role may call: read the session and annotate, plus connection plumbing. */
|
|
@@ -24,7 +26,8 @@ export function roleAllowsMethod(role: DaemonRole, method: string): boolean {
|
|
|
24
26
|
return role === "owner" || NON_OWNER_METHODS.has(method);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
export function asDaemonRole(value: Parameters<typeof v.safeParse>[1]): DaemonRole {
|
|
30
|
+
const result = v.safeParse(v.picklist(["owner", "collaborator"]), value);
|
|
31
|
+
|
|
32
|
+
return result.success ? result.output : "agent";
|
|
30
33
|
}
|
package/src/client.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, rmSync } from "node:fs";
|
|
8
|
+
import * as v from "valibot";
|
|
8
9
|
import type {
|
|
9
10
|
Annotation,
|
|
10
11
|
Artifact,
|
|
@@ -13,10 +14,18 @@ import type {
|
|
|
13
14
|
VerdictKind,
|
|
14
15
|
WorkspaceKey,
|
|
15
16
|
} from "@cueloop/schema";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BackpressureWriter,
|
|
19
|
+
LineBuffer,
|
|
20
|
+
parseInboundFrame,
|
|
21
|
+
type EventFrame,
|
|
22
|
+
type Request,
|
|
23
|
+
type Response,
|
|
24
|
+
} from "./protocol";
|
|
17
25
|
import type { DaemonRole } from "./capabilities";
|
|
18
26
|
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
19
27
|
import { cueloopHome, socketPath } from "./paths";
|
|
28
|
+
import { SessionRecordSchema } from "./validate";
|
|
20
29
|
|
|
21
30
|
export type { EventFrame } from "./protocol";
|
|
22
31
|
|
|
@@ -28,7 +37,15 @@ export interface ConnectOptions {
|
|
|
28
37
|
role?: DaemonRole;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
type PendingRequest = {
|
|
40
|
+
type PendingRequest = {
|
|
41
|
+
resolve: (value: Response["result"]) => void;
|
|
42
|
+
reject: (error: Error) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const EmptyResultSchema = v.object({});
|
|
46
|
+
const PingResultSchema = v.object({ pid: v.number() });
|
|
47
|
+
const RefreshDiffResultSchema = v.object({ changed: v.boolean() });
|
|
48
|
+
const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.string() }));
|
|
32
49
|
|
|
33
50
|
/**
|
|
34
51
|
* The session verbs the review controller drives. DaemonClient is the local
|
|
@@ -128,9 +145,10 @@ export class DaemonClient implements SessionClient {
|
|
|
128
145
|
this.writer = new BackpressureWriter(this.socket);
|
|
129
146
|
// Verify liveness: a dead socket file accepts connects on some platforms
|
|
130
147
|
// only to fail later, so a ping is the actual handshake.
|
|
131
|
-
await this.request("daemon.ping", {}, 2_000);
|
|
148
|
+
await this.request("daemon.ping", {}, PingResultSchema, 2_000);
|
|
132
149
|
// Cap this connection's role for the daemon's capability gate (owner is the default).
|
|
133
|
-
if (this.role !== "owner")
|
|
150
|
+
if (this.role !== "owner")
|
|
151
|
+
await this.request("daemon.hello", { role: this.role }, EmptyResultSchema, 2_000);
|
|
134
152
|
}
|
|
135
153
|
|
|
136
154
|
onEvent(listener: (event: EventFrame) => void): () => void {
|
|
@@ -140,10 +158,10 @@ export class DaemonClient implements SessionClient {
|
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
private routeInboundFrame(line: string): void {
|
|
143
|
-
let frame
|
|
161
|
+
let frame;
|
|
144
162
|
|
|
145
163
|
try {
|
|
146
|
-
frame =
|
|
164
|
+
frame = parseInboundFrame(line);
|
|
147
165
|
} catch {
|
|
148
166
|
return;
|
|
149
167
|
}
|
|
@@ -161,11 +179,16 @@ export class DaemonClient implements SessionClient {
|
|
|
161
179
|
else pendingRequest.resolve(frame.result);
|
|
162
180
|
}
|
|
163
181
|
|
|
164
|
-
request<
|
|
182
|
+
request<TOutput>(
|
|
183
|
+
method: string,
|
|
184
|
+
params: Request["params"],
|
|
185
|
+
resultSchema: v.GenericSchema<unknown, TOutput>,
|
|
186
|
+
timeoutMs = 30_000,
|
|
187
|
+
): Promise<TOutput> {
|
|
165
188
|
if (this.closed || !this.socket) return Promise.reject(new Error("not connected"));
|
|
166
189
|
const id = this.nextId++;
|
|
167
190
|
|
|
168
|
-
return new Promise<
|
|
191
|
+
return new Promise<TOutput>((resolve, reject) => {
|
|
169
192
|
const timer = setTimeout(() => {
|
|
170
193
|
this.pending.delete(id);
|
|
171
194
|
reject(new Error(`request ${method} timed out`));
|
|
@@ -174,7 +197,7 @@ export class DaemonClient implements SessionClient {
|
|
|
174
197
|
this.pending.set(id, {
|
|
175
198
|
resolve: (value) => {
|
|
176
199
|
clearTimeout(timer);
|
|
177
|
-
resolve(value
|
|
200
|
+
resolve(v.parse(resultSchema, value));
|
|
178
201
|
},
|
|
179
202
|
reject: (error) => {
|
|
180
203
|
clearTimeout(timer);
|
|
@@ -192,79 +215,88 @@ export class DaemonClient implements SessionClient {
|
|
|
192
215
|
|
|
193
216
|
// ── typed verbs ─────────────────────────────
|
|
194
217
|
ping(): Promise<{ pid: number }> {
|
|
195
|
-
return this.request("daemon.ping", {});
|
|
218
|
+
return this.request("daemon.ping", {}, PingResultSchema);
|
|
196
219
|
}
|
|
197
220
|
subscribe(): Promise<void> {
|
|
198
|
-
return this.request("events.subscribe", {});
|
|
221
|
+
return this.request("events.subscribe", {}, EmptyResultSchema).then(() => undefined);
|
|
199
222
|
}
|
|
200
223
|
sessionCreate(workspace: WorkspaceKey, artifact: Artifact): Promise<ReviewSession> {
|
|
201
|
-
return this.request("session.create", { workspace, artifact });
|
|
224
|
+
return this.request("session.create", { workspace, artifact }, SessionRecordSchema);
|
|
202
225
|
}
|
|
203
226
|
sessionGet(id: string): Promise<ReviewSession> {
|
|
204
|
-
return this.request("session.get", { id });
|
|
227
|
+
return this.request("session.get", { id }, SessionRecordSchema);
|
|
205
228
|
}
|
|
206
229
|
sessionList(filter?: { status?: "pending" | "resolved" }): Promise<ReviewSession[]> {
|
|
207
|
-
return this.request("session.list", { filter });
|
|
230
|
+
return this.request("session.list", { filter }, v.array(SessionRecordSchema));
|
|
208
231
|
}
|
|
209
232
|
/** Long-poll; null = still pending after timeoutMs (re-poll to collect). */
|
|
210
233
|
sessionWait(id: string, timeoutMs: number): Promise<ReviewSession | null> {
|
|
211
|
-
return this.request(
|
|
234
|
+
return this.request(
|
|
235
|
+
"session.wait",
|
|
236
|
+
{ id, timeoutMs },
|
|
237
|
+
v.nullable(SessionRecordSchema),
|
|
238
|
+
timeoutMs + 10_000,
|
|
239
|
+
);
|
|
212
240
|
}
|
|
213
241
|
sessionAnnotate(
|
|
214
242
|
id: string,
|
|
215
243
|
annotation: Omit<Annotation, "createdAt">,
|
|
216
244
|
authorName?: string,
|
|
217
245
|
): Promise<ReviewSession> {
|
|
218
|
-
return this.request("session.annotate", { id, annotation, authorName });
|
|
246
|
+
return this.request("session.annotate", { id, annotation, authorName }, SessionRecordSchema);
|
|
219
247
|
}
|
|
220
248
|
sessionRemoveAnnotation(id: string, annotationId: string): Promise<ReviewSession> {
|
|
221
|
-
return this.request("session.removeAnnotation", { id, annotationId });
|
|
249
|
+
return this.request("session.removeAnnotation", { id, annotationId }, SessionRecordSchema);
|
|
222
250
|
}
|
|
223
251
|
sessionSetWorkingCopy(id: string, workingCopy: string | undefined): Promise<ReviewSession> {
|
|
224
|
-
return this.request("session.setWorkingCopy", { id, workingCopy });
|
|
252
|
+
return this.request("session.setWorkingCopy", { id, workingCopy }, SessionRecordSchema);
|
|
225
253
|
}
|
|
226
254
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
227
|
-
return this.request("session.setViewed", { id, viewedPaths });
|
|
255
|
+
return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
|
|
228
256
|
}
|
|
229
257
|
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
230
258
|
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
231
|
-
return this.request("session.refreshDiff", { id });
|
|
259
|
+
return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
|
|
232
260
|
}
|
|
233
261
|
sessionSetShareId(id: string, shareId: string): Promise<ReviewSession> {
|
|
234
|
-
return this.request("session.setShareId", { id, shareId });
|
|
262
|
+
return this.request("session.setShareId", { id, shareId }, SessionRecordSchema);
|
|
235
263
|
}
|
|
236
264
|
sessionMergeShared(
|
|
237
265
|
id: string,
|
|
238
266
|
incoming: { annotations: Annotation[]; participants?: Identity[] },
|
|
239
267
|
): Promise<ReviewSession> {
|
|
240
|
-
return this.request("session.mergeShared", { id, ...incoming });
|
|
268
|
+
return this.request("session.mergeShared", { id, ...incoming }, SessionRecordSchema);
|
|
241
269
|
}
|
|
242
270
|
sessionDelete(id: string): Promise<void> {
|
|
243
|
-
return this.request("session.delete", { id });
|
|
271
|
+
return this.request("session.delete", { id }, EmptyResultSchema).then(() => undefined);
|
|
244
272
|
}
|
|
245
273
|
/** Local sessions have no collaborator self-name; the share client owns this. */
|
|
246
274
|
sessionSetSelfName(id: string, _name: string): Promise<ReviewSession> {
|
|
247
275
|
return this.sessionGet(id);
|
|
248
276
|
}
|
|
249
277
|
sessionResolve(id: string, verdictKind: VerdictKind, summary: string): Promise<ReviewSession> {
|
|
250
|
-
return this.request("session.resolve", { id, verdictKind, summary });
|
|
278
|
+
return this.request("session.resolve", { id, verdictKind, summary }, SessionRecordSchema);
|
|
251
279
|
}
|
|
252
280
|
sessionSubmitRevision(
|
|
253
281
|
id: string,
|
|
254
282
|
content: string,
|
|
255
283
|
addressedAnnotationIds: string[] = [],
|
|
256
284
|
): Promise<ReviewSession> {
|
|
257
|
-
return this.request(
|
|
285
|
+
return this.request(
|
|
286
|
+
"session.submitRevision",
|
|
287
|
+
{ id, content, addressedAnnotationIds },
|
|
288
|
+
SessionRecordSchema,
|
|
289
|
+
);
|
|
258
290
|
}
|
|
259
291
|
/** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
|
|
260
292
|
herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
|
|
261
|
-
return this.request("herdr.getTab", { id });
|
|
293
|
+
return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
|
|
262
294
|
}
|
|
263
295
|
async herdrSetTab(id: string, handle: HerdrTabHandle): Promise<void> {
|
|
264
|
-
await this.request("herdr.setTab", { id, ...handle });
|
|
296
|
+
await this.request("herdr.setTab", { id, ...handle }, EmptyResultSchema);
|
|
265
297
|
}
|
|
266
298
|
shutdown(): Promise<void> {
|
|
267
|
-
return this.request("daemon.shutdown", {});
|
|
299
|
+
return this.request("daemon.shutdown", {}, EmptyResultSchema).then(() => undefined);
|
|
268
300
|
}
|
|
269
301
|
}
|
|
270
302
|
|
package/src/diff-watcher.ts
CHANGED
|
@@ -64,10 +64,7 @@ export class DiffWatcher {
|
|
|
64
64
|
}
|
|
65
65
|
// a watcher error (the root is deleted mid-review) must not take down the
|
|
66
66
|
// daemon; the inherited EventEmitter `on` is absent from Bun's FSWatcher type
|
|
67
|
-
|
|
68
|
-
"error",
|
|
69
|
-
() => {},
|
|
70
|
-
);
|
|
67
|
+
handle.on("error", () => {});
|
|
71
68
|
this.repoWatches.set(repoRoot, { handle, sessionIds: new Set([sessionId]), debounce: null });
|
|
72
69
|
}
|
|
73
70
|
|
package/src/herdr-pane.test.ts
CHANGED
|
@@ -16,7 +16,7 @@ const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-pane-"));
|
|
|
16
16
|
* pane_id and tab_id (herdr 0.8.2 shape), and answers `pane get` alive or dead
|
|
17
17
|
* per `paneAlive` so the liveness branch can be exercised.
|
|
18
18
|
*/
|
|
19
|
-
function makeStub(name: string, paneAlive = false)
|
|
19
|
+
function makeStub(name: string, paneAlive = false) {
|
|
20
20
|
const logPath = join(dir, `${name}.log`);
|
|
21
21
|
const binPath = join(dir, `${name}.sh`);
|
|
22
22
|
const paneGet = paneAlive ? `printf '{"result":{"pane":{"pane_id":"w1:p2"}}}'` : "exit 1";
|
|
@@ -44,16 +44,13 @@ function readLines(logPath: string): string[] {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/** In-memory persistence double: records what openHerdrPaneForReview saves. */
|
|
47
|
-
function fakePersistence(initial: HerdrTabHandle | null = null)
|
|
48
|
-
persistence: HerdrTabPersistence;
|
|
49
|
-
saved: () => HerdrTabHandle | null;
|
|
50
|
-
} {
|
|
47
|
+
function fakePersistence(initial: HerdrTabHandle | null = null) {
|
|
51
48
|
let stored = initial;
|
|
52
49
|
|
|
53
50
|
return {
|
|
54
51
|
persistence: {
|
|
55
52
|
herdrGetTab: async () => stored,
|
|
56
|
-
herdrSetTab: async (_sessionId, handle) => {
|
|
53
|
+
herdrSetTab: async (_sessionId: string, handle: HerdrTabHandle) => {
|
|
57
54
|
stored = handle;
|
|
58
55
|
},
|
|
59
56
|
},
|
package/src/herdr-pane.ts
CHANGED
|
@@ -7,9 +7,22 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { detectHerdr, type HerdrEnv, type ReviewSession } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
10
11
|
import type { HerdrTabHandle } from "./herdr-tab-store";
|
|
11
12
|
|
|
12
13
|
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
14
|
+
const CreatedTabSchema = v.object({
|
|
15
|
+
result: v.optional(
|
|
16
|
+
v.object({
|
|
17
|
+
root_pane: v.optional(
|
|
18
|
+
v.object({ pane_id: v.optional(v.string()), tab_id: v.optional(v.string()) }),
|
|
19
|
+
),
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
});
|
|
23
|
+
const PaneResultSchema = v.object({
|
|
24
|
+
result: v.optional(v.object({ pane: v.optional(v.unknown()) })),
|
|
25
|
+
});
|
|
13
26
|
|
|
14
27
|
export interface OpenHerdrPaneOptions {
|
|
15
28
|
sessionId: string;
|
|
@@ -40,11 +53,11 @@ export function openHerdrPane(options: OpenHerdrPaneOptions): HerdrTabHandle | n
|
|
|
40
53
|
);
|
|
41
54
|
|
|
42
55
|
if (created.exitCode !== 0) return null;
|
|
43
|
-
const parsed = JSON.parse(created.stdout.toString())
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const paneId = parsed.result?.root_pane?.pane_id;
|
|
47
|
-
const tabId = parsed.result?.root_pane?.tab_id;
|
|
56
|
+
const parsed = v.safeParse(CreatedTabSchema, JSON.parse(created.stdout.toString()));
|
|
57
|
+
|
|
58
|
+
if (!parsed.success) return null;
|
|
59
|
+
const paneId = parsed.output.result?.root_pane?.pane_id;
|
|
60
|
+
const tabId = parsed.output.result?.root_pane?.tab_id;
|
|
48
61
|
|
|
49
62
|
if (!paneId || !tabId) return null;
|
|
50
63
|
// A fresh tab hosts a plain shell, so the review is typed in like a human.
|
|
@@ -77,9 +90,9 @@ function herdrPaneAlive(binPath: string, paneId: string): boolean {
|
|
|
77
90
|
});
|
|
78
91
|
|
|
79
92
|
if (got.exitCode !== 0) return false;
|
|
80
|
-
const parsed = JSON.parse(got.stdout.toString())
|
|
93
|
+
const parsed = v.safeParse(PaneResultSchema, JSON.parse(got.stdout.toString()));
|
|
81
94
|
|
|
82
|
-
return parsed.result?.pane != null;
|
|
95
|
+
return parsed.success && parsed.output.result?.pane != null;
|
|
83
96
|
} catch {
|
|
84
97
|
return false;
|
|
85
98
|
}
|
package/src/herdr-split.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { launchHarnessInSplit } from "./herdr-split";
|
|
|
9
9
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-split-"));
|
|
10
10
|
|
|
11
11
|
/** A stub herdr binary that logs argv and prints the pane-split result (pane_id of the new split). */
|
|
12
|
-
function makeStub(name: string, splitOk = true)
|
|
12
|
+
function makeStub(name: string, splitOk = true) {
|
|
13
13
|
const logPath = join(dir, `${name}.log`);
|
|
14
14
|
const binPath = join(dir, `${name}.sh`);
|
|
15
15
|
const splitResult = splitOk
|
package/src/herdr-split.ts
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { detectHerdr, type HerdrEnv } from "@cueloop/schema";
|
|
10
|
+
import * as v from "valibot";
|
|
10
11
|
|
|
11
12
|
const HERDR_SPAWN_TIMEOUT_MS = 2000;
|
|
13
|
+
const SplitPaneSchema = v.object({
|
|
14
|
+
result: v.optional(v.object({ pane: v.optional(v.object({ pane_id: v.optional(v.string()) })) })),
|
|
15
|
+
});
|
|
12
16
|
|
|
13
17
|
/** How much of the current pane the launched harness split takes (herdr --ratio). */
|
|
14
18
|
const HARNESS_SPLIT_RATIO = "0.4";
|
|
@@ -55,10 +59,10 @@ export function launchHarnessInSplit(
|
|
|
55
59
|
);
|
|
56
60
|
|
|
57
61
|
if (split.exitCode !== 0) return false;
|
|
58
|
-
const parsed = JSON.parse(split.stdout.toString())
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const paneId = parsed.result?.pane?.pane_id;
|
|
62
|
+
const parsed = v.safeParse(SplitPaneSchema, JSON.parse(split.stdout.toString()));
|
|
63
|
+
|
|
64
|
+
if (!parsed.success) return false;
|
|
65
|
+
const paneId = parsed.output.result?.pane?.pane_id;
|
|
62
66
|
|
|
63
67
|
if (!paneId) return false;
|
|
64
68
|
sendText(herdr.binPath, paneId, options.command);
|
package/src/herdr-tab-store.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
import * as v from "valibot";
|
|
9
10
|
import { herdrTabsPath } from "./paths";
|
|
10
11
|
|
|
11
12
|
/** The tab and its root pane: pane id checks liveness, tab id focuses it. */
|
|
@@ -14,15 +15,11 @@ export interface HerdrTabHandle {
|
|
|
14
15
|
paneId: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
typeof (value as HerdrTabHandle).tabId === "string" &&
|
|
23
|
-
typeof (value as HerdrTabHandle).paneId === "string"
|
|
24
|
-
);
|
|
25
|
-
}
|
|
18
|
+
const HerdrTabHandleSchema = v.object({
|
|
19
|
+
tabId: v.string(),
|
|
20
|
+
paneId: v.string(),
|
|
21
|
+
});
|
|
22
|
+
const HerdrTabsSchema = v.record(v.string(), v.unknown());
|
|
26
23
|
|
|
27
24
|
export class HerdrTabStore {
|
|
28
25
|
private tabs = new Map<string, HerdrTabHandle>();
|
|
@@ -35,12 +32,12 @@ export class HerdrTabStore {
|
|
|
35
32
|
|
|
36
33
|
private load(): void {
|
|
37
34
|
try {
|
|
38
|
-
const parsed
|
|
35
|
+
const parsed = v.parse(HerdrTabsSchema, JSON.parse(readFileSync(this.path, "utf8")));
|
|
39
36
|
|
|
40
|
-
if (typeof parsed !== "object" || parsed === null) return;
|
|
41
37
|
for (const [sessionId, value] of Object.entries(parsed)) {
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
const handle = v.safeParse(HerdrTabHandleSchema, value);
|
|
39
|
+
|
|
40
|
+
if (handle.success) this.tabs.set(sessionId, handle.output);
|
|
44
41
|
}
|
|
45
42
|
} catch {
|
|
46
43
|
// absent, unreadable, or malformed: start empty - a stale tab just reopens on resubmit
|
package/src/protocol.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Events (push, after events.subscribe): { event, sessionId }
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import * as v from "valibot";
|
|
8
|
+
|
|
7
9
|
export interface Request {
|
|
8
10
|
id: number;
|
|
9
11
|
method: string;
|
|
@@ -23,6 +25,29 @@ export interface EventFrame {
|
|
|
23
25
|
|
|
24
26
|
export type Frame = Response | EventFrame;
|
|
25
27
|
|
|
28
|
+
const RequestSchema = v.object({
|
|
29
|
+
id: v.number(),
|
|
30
|
+
method: v.string(),
|
|
31
|
+
params: v.optional(v.unknown()),
|
|
32
|
+
});
|
|
33
|
+
const ResponseSchema = v.object({
|
|
34
|
+
id: v.number(),
|
|
35
|
+
result: v.optional(v.unknown()),
|
|
36
|
+
error: v.optional(v.object({ code: v.string(), message: v.string() })),
|
|
37
|
+
});
|
|
38
|
+
const EventFrameSchema = v.object({
|
|
39
|
+
event: v.string(),
|
|
40
|
+
sessionId: v.string(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export function parseRequestFrame(line: string): Request {
|
|
44
|
+
return v.parse(RequestSchema, JSON.parse(line));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseInboundFrame(line: string): Frame {
|
|
48
|
+
return v.parse(v.union([ResponseSchema, EventFrameSchema]), JSON.parse(line));
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
/** Incremental NDJSON splitter; tolerates partial writes. */
|
|
27
52
|
export class LineBuffer {
|
|
28
53
|
private buffered = "";
|
package/src/retention.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
48
48
|
const missing = join(tempDir("cueloop-retention-"), "config.toml");
|
|
49
49
|
|
|
50
50
|
// Act
|
|
51
|
-
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing }
|
|
51
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: missing });
|
|
52
52
|
|
|
53
53
|
// Assert
|
|
54
54
|
expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
|
|
@@ -61,7 +61,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
61
61
|
writeFileSync(path, "[cleanup]\nperiod_days = 7\n");
|
|
62
62
|
|
|
63
63
|
// Act
|
|
64
|
-
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path }
|
|
64
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
|
|
65
65
|
|
|
66
66
|
// Assert
|
|
67
67
|
expect(days).toBe(7);
|
|
@@ -74,7 +74,7 @@ describe("resolveCleanupPeriodDays", () => {
|
|
|
74
74
|
writeFileSync(path, '[cleanup]\nperiod_days = "soon"\n');
|
|
75
75
|
|
|
76
76
|
// Act
|
|
77
|
-
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path }
|
|
77
|
+
const days = resolveCleanupPeriodDays({ CUELOOP_CONFIG: path });
|
|
78
78
|
|
|
79
79
|
// Assert
|
|
80
80
|
expect(days).toBe(DEFAULT_CLEANUP_PERIOD_DAYS);
|
package/src/retention.ts
CHANGED
|
@@ -20,7 +20,7 @@ const RefineStateSchema = v.object({
|
|
|
20
20
|
|
|
21
21
|
export type RefineState = v.InferOutput<typeof RefineStateSchema>;
|
|
22
22
|
|
|
23
|
-
export function parseRefineState(raw:
|
|
23
|
+
export function parseRefineState(raw: Parameters<typeof v.safeParse>[1]): Map<string, string> {
|
|
24
24
|
const parsed = v.safeParse(RefineStateSchema, raw);
|
|
25
25
|
|
|
26
26
|
if (!parsed.success) return new Map();
|
package/src/review.test.ts
CHANGED
|
@@ -159,6 +159,30 @@ describe("openReview", () => {
|
|
|
159
159
|
expect(other.id).not.toBe(first.id);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
test("the same agentSessionId with a different type opens a new session, never a type mismatch", async () => {
|
|
163
|
+
// Arrange - the agent session's plan review is open
|
|
164
|
+
const plan = await openReview(client, {
|
|
165
|
+
type: "plan",
|
|
166
|
+
content: PLAN,
|
|
167
|
+
cwd: home,
|
|
168
|
+
agentSessionId: "agent-cross",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Act - the same agent session now submits a reply for review
|
|
172
|
+
const reply = await openReview(client, {
|
|
173
|
+
type: "reply",
|
|
174
|
+
content: "# Findings\n\nThe cache is fine.\n",
|
|
175
|
+
cwd: home,
|
|
176
|
+
agentSessionId: "agent-cross",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Assert - a fresh reply session; the plan session keeps its type and history
|
|
180
|
+
expect(reply.id).not.toBe(plan.id);
|
|
181
|
+
expect(reply.session.artifact.type).toBe("reply");
|
|
182
|
+
expect(reply.session.revisions.length).toBe(1);
|
|
183
|
+
expect((await client.sessionGet(plan.id)).artifact.type).toBe("plan");
|
|
184
|
+
});
|
|
185
|
+
|
|
162
186
|
test("per-file notes land as note annotations anchored at the file path", async () => {
|
|
163
187
|
// Act
|
|
164
188
|
const review = await openReview(client, {
|
package/src/review.ts
CHANGED
|
@@ -245,10 +245,14 @@ export async function openReview(
|
|
|
245
245
|
const cwd = options.cwd ?? process.cwd();
|
|
246
246
|
const workspace = options.workspace ?? (await resolveWorkspace(cwd));
|
|
247
247
|
|
|
248
|
-
// Resubmits from the same agent session become revisions, not new sessions
|
|
248
|
+
// Resubmits from the same agent session become revisions, not new sessions -
|
|
249
|
+
// but only within the same primitive: a different artifact type is a new
|
|
250
|
+
// review, never a silent type mismatch on the old session.
|
|
249
251
|
if (options.agentSessionId !== undefined) {
|
|
250
252
|
const existing = (await client.sessionList()).find(
|
|
251
|
-
(candidate) =>
|
|
253
|
+
(candidate) =>
|
|
254
|
+
candidate.artifact.meta.agentSessionId === options.agentSessionId &&
|
|
255
|
+
candidate.artifact.type === options.type,
|
|
252
256
|
);
|
|
253
257
|
|
|
254
258
|
if (existing !== undefined) {
|
package/src/server.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import * as v from "valibot";
|
|
5
6
|
import { DaemonServer } from "./server";
|
|
6
7
|
import { DaemonClient, DaemonClientError } from "./client";
|
|
7
8
|
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
@@ -54,12 +55,8 @@ describe("socket round-trip", () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
test("errors carry codes across the wire", async () => {
|
|
57
|
-
expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
58
|
-
|
|
59
|
-
await client.sessionGet("nope");
|
|
60
|
-
} catch (error) {
|
|
61
|
-
expect((error as DaemonClientError).code).toBe("not_found");
|
|
62
|
-
}
|
|
58
|
+
await expect(client.sessionGet("nope")).rejects.toBeInstanceOf(DaemonClientError);
|
|
59
|
+
await expect(client.sessionGet("nope")).rejects.toHaveProperty("code", "not_found");
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
test("events push to subscribed connections only", async () => {
|
|
@@ -85,38 +82,27 @@ describe("socket round-trip", () => {
|
|
|
85
82
|
|
|
86
83
|
test("malformed requests get structured errors and never wedge the daemon", async () => {
|
|
87
84
|
// missing required params
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
} catch (error) {
|
|
92
|
-
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
93
|
-
}
|
|
85
|
+
await expect(
|
|
86
|
+
client.request("session.create", { artifact: { type: "plan", content: "x" } }, v.object({})),
|
|
87
|
+
).rejects.toHaveProperty("code", "invalid_params");
|
|
94
88
|
// wrong types
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
100
|
-
}
|
|
89
|
+
await expect(client.request("session.wait", { id: 42 }, v.object({}))).rejects.toHaveProperty(
|
|
90
|
+
"code",
|
|
91
|
+
"invalid_params",
|
|
92
|
+
);
|
|
101
93
|
// unknown method
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const result of prototypeMethods) {
|
|
115
|
-
expect(result.status).toBe("rejected");
|
|
116
|
-
expect(((result as PromiseRejectedResult).reason as DaemonClientError).code).toBe(
|
|
117
|
-
"unknown_method",
|
|
118
|
-
);
|
|
119
|
-
}
|
|
94
|
+
await expect(client.request("session.nuke", {}, v.object({}))).rejects.toHaveProperty(
|
|
95
|
+
"code",
|
|
96
|
+
"unknown_method",
|
|
97
|
+
);
|
|
98
|
+
await Promise.all(
|
|
99
|
+
["__proto__", "toString", "constructor"].map((method) =>
|
|
100
|
+
expect(client.request(method, {}, v.object({}))).rejects.toHaveProperty(
|
|
101
|
+
"code",
|
|
102
|
+
"unknown_method",
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
);
|
|
120
106
|
// the daemon is still fully alive afterwards
|
|
121
107
|
const session = await client.sessionCreate(WS, PLAN);
|
|
122
108
|
|
|
@@ -181,15 +167,11 @@ describe("socket round-trip", () => {
|
|
|
181
167
|
// Given a session
|
|
182
168
|
const session = await client.sessionCreate(WS, PLAN);
|
|
183
169
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
} catch (error) {
|
|
190
|
-
// Then it is rejected at the wire boundary
|
|
191
|
-
expect((error as DaemonClientError).code).toBe("invalid_params");
|
|
192
|
-
}
|
|
170
|
+
// When a malformed annotation is merged, it is rejected at the wire boundary
|
|
171
|
+
// @ts-expect-error intentionally malformed annotation (empty id, missing fields)
|
|
172
|
+
const merge = client.sessionMergeShared(session.id, { annotations: [{ id: "" }] });
|
|
173
|
+
|
|
174
|
+
await expect(merge).rejects.toHaveProperty("code", "invalid_params");
|
|
193
175
|
});
|
|
194
176
|
|
|
195
177
|
test("session.delete removes a session over the wire", async () => {
|
|
@@ -200,13 +182,8 @@ describe("socket round-trip", () => {
|
|
|
200
182
|
await client.sessionDelete(session.id);
|
|
201
183
|
|
|
202
184
|
// Then it is gone, and deleting an unknown id is a not_found error
|
|
203
|
-
expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
204
|
-
|
|
205
|
-
await client.sessionDelete("ses_missing");
|
|
206
|
-
throw new Error("should have thrown");
|
|
207
|
-
} catch (error) {
|
|
208
|
-
expect((error as DaemonClientError).code).toBe("not_found");
|
|
209
|
-
}
|
|
185
|
+
await expect(client.sessionGet(session.id)).rejects.toBeInstanceOf(DaemonClientError);
|
|
186
|
+
await expect(client.sessionDelete("ses_missing")).rejects.toHaveProperty("code", "not_found");
|
|
210
187
|
});
|
|
211
188
|
|
|
212
189
|
test("two clients see the same state (thin-renderer model)", async () => {
|
package/src/server.ts
CHANGED
|
@@ -18,7 +18,13 @@ import { DaemonCore, type DaemonEvent } from "./api";
|
|
|
18
18
|
import { DaemonError } from "./errors";
|
|
19
19
|
import { roleAllowsMethod, type DaemonRole } from "./capabilities";
|
|
20
20
|
import { isKnownMethod, parseParams, type MethodName } from "./validate";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
BackpressureWriter,
|
|
23
|
+
LineBuffer,
|
|
24
|
+
parseRequestFrame,
|
|
25
|
+
type Request,
|
|
26
|
+
type Response,
|
|
27
|
+
} from "./protocol";
|
|
22
28
|
import { cueloopHome, lockPath, pidPath, socketPath } from "./paths";
|
|
23
29
|
|
|
24
30
|
interface Connection {
|
|
@@ -28,7 +34,7 @@ interface Connection {
|
|
|
28
34
|
role: DaemonRole;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
type MethodHandler = (connection: Connection, request: Request) =>
|
|
37
|
+
type MethodHandler = (connection: Connection, request: Request) => Response["result"];
|
|
32
38
|
|
|
33
39
|
export interface DaemonOptions {
|
|
34
40
|
home?: string;
|
|
@@ -217,7 +223,7 @@ export class DaemonServer {
|
|
|
217
223
|
let request: Request;
|
|
218
224
|
|
|
219
225
|
try {
|
|
220
|
-
request =
|
|
226
|
+
request = parseRequestFrame(line);
|
|
221
227
|
} catch {
|
|
222
228
|
connection.write(
|
|
223
229
|
JSON.stringify({ id: -1, error: { code: "bad_json", message: "unparseable request" } }) +
|
|
@@ -340,10 +346,10 @@ export class DaemonServer {
|
|
|
340
346
|
},
|
|
341
347
|
};
|
|
342
348
|
|
|
343
|
-
private async dispatch(connection: Connection, request: Request): Promise<
|
|
349
|
+
private async dispatch(connection: Connection, request: Request): Promise<Response["result"]> {
|
|
344
350
|
// The wire is untrusted JSON: validate before DaemonCore sees anything.
|
|
345
|
-
if (
|
|
346
|
-
throw new DaemonError("unknown_method", `unknown method ${
|
|
351
|
+
if (!isKnownMethod(request.method)) {
|
|
352
|
+
throw new DaemonError("unknown_method", `unknown method ${request.method}`);
|
|
347
353
|
}
|
|
348
354
|
// Capability gate: a capped role (a review-side agent) cannot escalate past
|
|
349
355
|
// read + annotate, whatever verb it sends.
|
package/src/share-blob.ts
CHANGED
package/src/store.ts
CHANGED
|
@@ -36,7 +36,7 @@ export class SessionStore {
|
|
|
36
36
|
const parsed = validateSessionRecord(JSON.parse(raw));
|
|
37
37
|
|
|
38
38
|
if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
|
|
39
|
-
const session = parsed.value
|
|
39
|
+
const session: ReviewSession = parsed.value;
|
|
40
40
|
|
|
41
41
|
this.sessions.set(session.id, session);
|
|
42
42
|
report.recovered.push(session.id);
|
package/src/validate.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
validateSessionRecord,
|
|
20
20
|
} from "./validate";
|
|
21
21
|
import {
|
|
22
|
+
ARTIFACT_TYPES,
|
|
22
23
|
SCHEMA_VERSION,
|
|
23
24
|
type Anchor,
|
|
24
25
|
type Annotation,
|
|
@@ -56,18 +57,22 @@ describe("parseParams", () => {
|
|
|
56
57
|
throw new Error("should have thrown");
|
|
57
58
|
} catch (err) {
|
|
58
59
|
expect(err).toBeInstanceOf(DaemonError);
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
if (err instanceof DaemonError) {
|
|
61
|
+
expect(err.code).toBe("invalid_params");
|
|
62
|
+
expect(err.message).toContain("session.create: workspace");
|
|
63
|
+
}
|
|
61
64
|
}
|
|
62
65
|
});
|
|
63
66
|
|
|
64
|
-
test("accepts
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
test("accepts every artifact type in the schema union", () => {
|
|
68
|
+
for (const type of ARTIFACT_TYPES) {
|
|
69
|
+
const params = parseParams("session.create", {
|
|
70
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
71
|
+
artifact: { type, content: "body" },
|
|
72
|
+
});
|
|
69
73
|
|
|
70
|
-
|
|
74
|
+
expect(params.artifact.type).toBe(type);
|
|
75
|
+
}
|
|
71
76
|
});
|
|
72
77
|
|
|
73
78
|
test("rejects an unknown artifact type", () => {
|
|
@@ -169,7 +174,7 @@ describe("validateSessionRecord", () => {
|
|
|
169
174
|
* @cueloop/schema shows up here too.
|
|
170
175
|
*/
|
|
171
176
|
describe("wire pins", () => {
|
|
172
|
-
const keys = (subject:
|
|
177
|
+
const keys = <Subject extends object>(subject: Subject) => Object.keys(subject).sort();
|
|
173
178
|
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
174
179
|
|
|
175
180
|
const fullMeta: Required<ArtifactMeta> = {
|
package/src/validate.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as v from "valibot";
|
|
13
13
|
import {
|
|
14
|
+
ARTIFACT_TYPES,
|
|
14
15
|
SCHEMA_VERSION,
|
|
15
16
|
type Anchor,
|
|
16
17
|
type Annotation,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
type WorkspaceKey,
|
|
25
26
|
} from "@cueloop/schema";
|
|
26
27
|
import { DaemonError } from "./errors";
|
|
28
|
+
import type { Request } from "./protocol";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Drift guard for every hand-mirrored shape below. v.object strips keys it
|
|
@@ -60,7 +62,9 @@ export const DiffFileContentsSchema = v.object({
|
|
|
60
62
|
} satisfies EntriesOf<DiffFileContents>);
|
|
61
63
|
|
|
62
64
|
export const ArtifactSchema = v.object({
|
|
63
|
-
|
|
65
|
+
// Derived from the schema's runtime union: a new primitive extends the wire
|
|
66
|
+
// contract without touching this file.
|
|
67
|
+
type: v.picklist(ARTIFACT_TYPES),
|
|
64
68
|
content: v.string(),
|
|
65
69
|
meta: v.optional(ArtifactMetaSchema, {}),
|
|
66
70
|
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
@@ -172,7 +176,7 @@ export function isKnownMethod(method: string): method is MethodName {
|
|
|
172
176
|
/** Validate params for a method, or throw a DaemonError the client can read. */
|
|
173
177
|
export function parseParams<M extends MethodName>(
|
|
174
178
|
method: M,
|
|
175
|
-
params:
|
|
179
|
+
params: Request["params"],
|
|
176
180
|
): v.InferOutput<(typeof Params)[M]> {
|
|
177
181
|
const result = v.safeParse(Params[method], params ?? {});
|
|
178
182
|
|
|
@@ -221,7 +225,7 @@ export const SessionRecordSchema = v.object({
|
|
|
221
225
|
} satisfies EntriesOf<ReviewSession>);
|
|
222
226
|
|
|
223
227
|
export function validateSessionRecord(
|
|
224
|
-
raw:
|
|
228
|
+
raw: Parameters<typeof v.safeParse>[1],
|
|
225
229
|
): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
226
230
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
227
231
|
|