@cueloop/daemon 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +12 -5
- package/src/api.test.ts +58 -44
- package/src/api.ts +62 -48
- package/src/client.ts +33 -25
- package/src/herdr-pane.test.ts +122 -0
- package/src/herdr-pane.ts +94 -0
- package/src/index.ts +8 -8
- package/src/protocol.ts +44 -7
- package/src/review.test.ts +184 -0
- package/src/review.ts +208 -0
- package/src/server.test.ts +49 -20
- package/src/server.ts +58 -48
- package/src/single-instance.test.ts +11 -11
- package/src/store.ts +5 -5
- package/src/validate.test.ts +126 -12
- package/src/validate.ts +59 -31
package/src/validate.test.ts
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { DaemonCore } from "./api";
|
|
2
6
|
import { DaemonError } from "./errors";
|
|
3
|
-
import {
|
|
4
|
-
|
|
7
|
+
import {
|
|
8
|
+
AnchorSchema,
|
|
9
|
+
AnnotationSchema,
|
|
10
|
+
ArtifactMetaSchema,
|
|
11
|
+
ArtifactSchema,
|
|
12
|
+
RevisionSchema,
|
|
13
|
+
SessionRecordSchema,
|
|
14
|
+
VerdictSchema,
|
|
15
|
+
WorkspaceSchema,
|
|
16
|
+
isKnownMethod,
|
|
17
|
+
parseParams,
|
|
18
|
+
validateSessionRecord,
|
|
19
|
+
} from "./validate";
|
|
20
|
+
import {
|
|
21
|
+
SCHEMA_VERSION,
|
|
22
|
+
type Anchor,
|
|
23
|
+
type Annotation,
|
|
24
|
+
type Artifact,
|
|
25
|
+
type ArtifactMeta,
|
|
26
|
+
type ReviewSession,
|
|
27
|
+
type Revision,
|
|
28
|
+
type Verdict,
|
|
29
|
+
type WorkspaceKey,
|
|
30
|
+
} from "@cueloop/schema";
|
|
5
31
|
|
|
6
32
|
describe("method allowlist", () => {
|
|
7
33
|
test("known and unknown methods", () => {
|
|
@@ -12,11 +38,11 @@ describe("method allowlist", () => {
|
|
|
12
38
|
|
|
13
39
|
describe("parseParams", () => {
|
|
14
40
|
test("accepts a well-formed create and defaults meta", () => {
|
|
15
|
-
const
|
|
41
|
+
const params = parseParams("session.create", {
|
|
16
42
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
17
43
|
artifact: { type: "plan", content: "# P" },
|
|
18
44
|
});
|
|
19
|
-
expect(
|
|
45
|
+
expect(params.artifact.meta).toEqual({});
|
|
20
46
|
});
|
|
21
47
|
|
|
22
48
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -55,12 +81,22 @@ describe("parseParams", () => {
|
|
|
55
81
|
});
|
|
56
82
|
|
|
57
83
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
58
|
-
const
|
|
84
|
+
const params = parseParams("session.annotate", {
|
|
59
85
|
id: "s",
|
|
60
86
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
61
87
|
});
|
|
62
|
-
expect(
|
|
63
|
-
expect(
|
|
88
|
+
expect(params.annotation.kind).toBe("praise");
|
|
89
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
93
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] }).viewedPaths).toEqual([
|
|
94
|
+
"src/a.ts",
|
|
95
|
+
"src/b.ts",
|
|
96
|
+
]);
|
|
97
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
98
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(/viewedPaths/);
|
|
99
|
+
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
64
100
|
});
|
|
65
101
|
|
|
66
102
|
test("null params are treated as empty", () => {
|
|
@@ -86,13 +122,91 @@ describe("validateSessionRecord", () => {
|
|
|
86
122
|
});
|
|
87
123
|
|
|
88
124
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
89
|
-
const
|
|
90
|
-
expect(
|
|
91
|
-
if (!
|
|
125
|
+
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
92
128
|
});
|
|
93
129
|
|
|
94
130
|
test("rejects a structurally broken record", () => {
|
|
95
|
-
const
|
|
96
|
-
expect(
|
|
131
|
+
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
132
|
+
expect(result.ok).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
138
|
+
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
139
|
+
* check - the samples are fully populated and typed, so a new field in
|
|
140
|
+
* @cueloop/schema shows up here too.
|
|
141
|
+
*/
|
|
142
|
+
describe("wire pins", () => {
|
|
143
|
+
const keys = (subject: object) => Object.keys(subject).sort();
|
|
144
|
+
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
145
|
+
|
|
146
|
+
const fullMeta: Required<ArtifactMeta> = {
|
|
147
|
+
cwd: "/repo",
|
|
148
|
+
agent: "claude-code",
|
|
149
|
+
agentSessionId: "sess-1",
|
|
150
|
+
planPath: "/repo/plan.md",
|
|
151
|
+
pr: "org/repo#1",
|
|
152
|
+
herdrPane: "%7",
|
|
153
|
+
title: "Plan",
|
|
154
|
+
};
|
|
155
|
+
const fullAnchor: Required<Anchor> = { quote: "q", prefix: "p", suffix: "s", blockIndex: 0, start: 0, end: 1 };
|
|
156
|
+
const fullAnnotation: Required<Annotation> = {
|
|
157
|
+
id: "a1",
|
|
158
|
+
kind: "comment",
|
|
159
|
+
anchor: fullAnchor,
|
|
160
|
+
body: "b",
|
|
161
|
+
orphan: false,
|
|
162
|
+
createdAt: "now",
|
|
163
|
+
};
|
|
164
|
+
const fullArtifact: Required<Artifact> = { type: "plan", content: "# P", meta: fullMeta };
|
|
165
|
+
const fullVerdict: Required<Verdict> = { kind: "approve", summary: "", feedback: "", resolvedAt: "now" };
|
|
166
|
+
const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
|
|
167
|
+
const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
|
|
168
|
+
const fullSession: Required<ReviewSession> = {
|
|
169
|
+
schemaVersion: SCHEMA_VERSION,
|
|
170
|
+
id: "ses_1",
|
|
171
|
+
workspace: fullWorkspace,
|
|
172
|
+
artifact: fullArtifact,
|
|
173
|
+
revisions: [fullRevision],
|
|
174
|
+
annotations: [fullAnnotation],
|
|
175
|
+
workingCopy: "# P edited",
|
|
176
|
+
viewedPaths: ["src/a.ts"],
|
|
177
|
+
verdict: fullVerdict,
|
|
178
|
+
status: "pending",
|
|
179
|
+
createdAt: "now",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
test("schema key sets match the schema types", () => {
|
|
183
|
+
expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
|
|
184
|
+
expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
|
|
185
|
+
expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
|
|
186
|
+
expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
|
|
187
|
+
// wire annotations arrive without createdAt; the daemon stamps it
|
|
188
|
+
const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
|
|
189
|
+
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
190
|
+
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
191
|
+
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
192
|
+
expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
|
|
193
|
+
// persisted annotations carry the stamped createdAt
|
|
194
|
+
const stored = SessionRecordSchema.entries.annotations.item;
|
|
195
|
+
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
199
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
200
|
+
try {
|
|
201
|
+
const params = parseParams("session.create", {
|
|
202
|
+
workspace: fullWorkspace,
|
|
203
|
+
artifact: { type: "plan", content: "# P", meta: fullMeta },
|
|
204
|
+
});
|
|
205
|
+
const core = new DaemonCore(home);
|
|
206
|
+
const created = core.sessionCreate(params);
|
|
207
|
+
expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
|
|
208
|
+
} finally {
|
|
209
|
+
rmSync(home, { recursive: true, force: true });
|
|
210
|
+
}
|
|
97
211
|
});
|
|
98
212
|
});
|
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Runtime validation of the wire boundary
|
|
2
|
+
* Runtime validation of the wire boundary. Everything arriving over the
|
|
3
3
|
* socket is untrusted JSON: adapters, the CLI, extensions, and any script a
|
|
4
4
|
* user writes all speak this protocol. Validating here means DaemonCore only
|
|
5
5
|
* ever sees well-formed input, and a malformed request gets a precise error
|
|
@@ -10,31 +10,50 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as v from "valibot";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
SCHEMA_VERSION,
|
|
15
|
+
type Anchor,
|
|
16
|
+
type Annotation,
|
|
17
|
+
type Artifact,
|
|
18
|
+
type ArtifactMeta,
|
|
19
|
+
type ReviewSession,
|
|
20
|
+
type Revision,
|
|
21
|
+
type Verdict,
|
|
22
|
+
type WorkspaceKey,
|
|
23
|
+
} from "@cueloop/schema";
|
|
14
24
|
import { DaemonError } from "./errors";
|
|
15
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Drift guard for every hand-mirrored shape below. v.object strips keys it
|
|
28
|
+
* does not know, so a schema missing a field silently drops that field on
|
|
29
|
+
* the way into DaemonCore. Each entries object is checked with
|
|
30
|
+
* `satisfies EntriesOf<T>`: adding a field to the schema types without
|
|
31
|
+
* mirroring it here (or mirroring a key that does not exist) fails typecheck.
|
|
32
|
+
*/
|
|
33
|
+
type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
|
|
34
|
+
|
|
16
35
|
const NonEmpty = v.pipe(v.string(), v.minLength(1));
|
|
17
36
|
|
|
18
37
|
export const WorkspaceSchema = v.object({
|
|
19
38
|
repoRoot: NonEmpty,
|
|
20
39
|
branch: NonEmpty,
|
|
21
|
-
});
|
|
40
|
+
} satisfies EntriesOf<WorkspaceKey>);
|
|
41
|
+
|
|
42
|
+
export const ArtifactMetaSchema = v.object({
|
|
43
|
+
cwd: v.optional(v.string()),
|
|
44
|
+
agent: v.optional(v.string()),
|
|
45
|
+
agentSessionId: v.optional(v.string()),
|
|
46
|
+
planPath: v.optional(v.string()),
|
|
47
|
+
pr: v.optional(v.string()),
|
|
48
|
+
herdrPane: v.optional(v.string()),
|
|
49
|
+
title: v.optional(v.string()),
|
|
50
|
+
} satisfies EntriesOf<ArtifactMeta>);
|
|
22
51
|
|
|
23
52
|
export const ArtifactSchema = v.object({
|
|
24
53
|
type: v.picklist(["plan", "diff"]),
|
|
25
54
|
content: v.string(),
|
|
26
|
-
meta: v.optional(
|
|
27
|
-
|
|
28
|
-
cwd: v.optional(v.string()),
|
|
29
|
-
agent: v.optional(v.string()),
|
|
30
|
-
agentSessionId: v.optional(v.string()),
|
|
31
|
-
planPath: v.optional(v.string()),
|
|
32
|
-
title: v.optional(v.string()),
|
|
33
|
-
pr: v.optional(v.string()),
|
|
34
|
-
}),
|
|
35
|
-
{},
|
|
36
|
-
),
|
|
37
|
-
});
|
|
55
|
+
meta: v.optional(ArtifactMetaSchema, {}),
|
|
56
|
+
} satisfies EntriesOf<Artifact>);
|
|
38
57
|
|
|
39
58
|
export const AnchorSchema = v.object({
|
|
40
59
|
quote: v.string(),
|
|
@@ -43,16 +62,17 @@ export const AnchorSchema = v.object({
|
|
|
43
62
|
blockIndex: v.optional(v.number()),
|
|
44
63
|
start: v.optional(v.number()),
|
|
45
64
|
end: v.optional(v.number()),
|
|
46
|
-
});
|
|
65
|
+
} satisfies EntriesOf<Anchor>);
|
|
47
66
|
|
|
67
|
+
/** Wire annotations arrive without createdAt - the daemon stamps it. */
|
|
48
68
|
export const AnnotationSchema = v.object({
|
|
49
69
|
id: NonEmpty,
|
|
50
|
-
/** Open kind set
|
|
70
|
+
/** Open kind set: built-ins are comment and suggestion. */
|
|
51
71
|
kind: NonEmpty,
|
|
52
72
|
anchor: AnchorSchema,
|
|
53
73
|
body: v.string(),
|
|
54
74
|
orphan: v.optional(v.boolean()),
|
|
55
|
-
});
|
|
75
|
+
} satisfies EntriesOf<Omit<Annotation, "createdAt">>);
|
|
56
76
|
|
|
57
77
|
const SessionId = NonEmpty;
|
|
58
78
|
|
|
@@ -75,6 +95,7 @@ export const Params = {
|
|
|
75
95
|
"session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
|
|
76
96
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
77
97
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
98
|
+
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
78
99
|
"session.resolve": v.object({
|
|
79
100
|
id: SessionId,
|
|
80
101
|
verdictKind: v.picklist(["comment", "approve", "request_changes"]),
|
|
@@ -97,37 +118,44 @@ export function parseParams<M extends MethodName>(method: M, params: unknown): v
|
|
|
97
118
|
const result = v.safeParse(Params[method], params ?? {});
|
|
98
119
|
if (!result.success) {
|
|
99
120
|
const issue = result.issues[0]!;
|
|
100
|
-
const path = issue.path?.map((
|
|
121
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
101
122
|
throw new DaemonError("invalid_params", `${method}: ${path ? path + " - " : ""}${issue.message}`);
|
|
102
123
|
}
|
|
103
124
|
return result.output;
|
|
104
125
|
}
|
|
105
126
|
|
|
127
|
+
export const RevisionSchema = v.object({
|
|
128
|
+
revision: v.number(),
|
|
129
|
+
content: v.string(),
|
|
130
|
+
submittedAt: v.string(),
|
|
131
|
+
} satisfies EntriesOf<Revision>);
|
|
132
|
+
|
|
133
|
+
export const VerdictSchema = v.object({
|
|
134
|
+
kind: v.picklist(["comment", "approve", "request_changes"]),
|
|
135
|
+
summary: v.string(),
|
|
136
|
+
feedback: v.string(),
|
|
137
|
+
resolvedAt: v.string(),
|
|
138
|
+
} satisfies EntriesOf<Verdict>);
|
|
139
|
+
|
|
106
140
|
/** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
|
|
107
141
|
export const SessionRecordSchema = v.object({
|
|
108
142
|
schemaVersion: v.literal(SCHEMA_VERSION),
|
|
109
143
|
id: NonEmpty,
|
|
110
144
|
workspace: WorkspaceSchema,
|
|
111
145
|
artifact: ArtifactSchema,
|
|
112
|
-
revisions: v.array(
|
|
113
|
-
annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() })),
|
|
146
|
+
revisions: v.array(RevisionSchema),
|
|
147
|
+
annotations: v.array(v.object({ ...AnnotationSchema.entries, createdAt: v.string() } satisfies EntriesOf<Annotation>)),
|
|
114
148
|
workingCopy: v.optional(v.string()),
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
kind: v.picklist(["comment", "approve", "request_changes"]),
|
|
118
|
-
summary: v.string(),
|
|
119
|
-
feedback: v.string(),
|
|
120
|
-
resolvedAt: v.string(),
|
|
121
|
-
}),
|
|
122
|
-
),
|
|
149
|
+
viewedPaths: v.optional(v.array(v.string())),
|
|
150
|
+
verdict: v.nullable(VerdictSchema),
|
|
123
151
|
status: v.picklist(["pending", "resolved"]),
|
|
124
152
|
createdAt: v.string(),
|
|
125
|
-
});
|
|
153
|
+
} satisfies EntriesOf<ReviewSession>);
|
|
126
154
|
|
|
127
155
|
export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
128
156
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
129
157
|
if (result.success) return { ok: true, value: result.output };
|
|
130
158
|
const issue = result.issues[0]!;
|
|
131
|
-
const path = issue.path?.map((
|
|
159
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
132
160
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
133
161
|
}
|