@cueloop/daemon 0.1.0-alpha.0 → 0.1.0-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/SDK.md +102 -0
- package/package.json +43 -16
- package/src/agent-tools.ts +98 -0
- package/src/agent-turn.test.ts +24 -0
- package/src/agent-turn.ts +34 -0
- package/src/api.test.ts +1081 -65
- package/src/api.ts +1425 -89
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +95 -0
- package/src/client-errors.ts +24 -0
- package/src/client-reliability.test.ts +189 -0
- package/src/client.test.ts +37 -0
- package/src/client.ts +820 -94
- package/src/curate.ts +109 -0
- package/src/daemon-sdk-effect.ts +113 -0
- package/src/daemon-sdk.test.ts +306 -0
- package/src/daemon-sdk.ts +397 -0
- package/src/daemon-version.test.ts +142 -0
- package/src/diff-hot-reload.test.ts +419 -0
- package/src/diff-watcher.test.ts +117 -0
- package/src/diff-watcher.ts +326 -0
- package/src/examples/daemon-sdk.ts +55 -0
- package/src/gh.test.ts +59 -0
- package/src/gh.ts +74 -0
- package/src/ghostty-thread-surface-store.test.ts +49 -0
- package/src/ghostty-thread-surface-store.ts +101 -0
- package/src/ghostty-thread-surface.test.ts +398 -0
- package/src/ghostty-thread-surface.ts +267 -0
- package/src/harness-delivery.test.ts +269 -0
- package/src/harness-diagnostics.test.ts +26 -0
- package/src/harness-diagnostics.ts +30 -0
- package/src/harness-state-store.test.ts +93 -0
- package/src/harness-state-store.ts +213 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +85 -0
- package/src/herdr-thread-surface-store.test.ts +81 -0
- package/src/herdr-thread-surface-store.ts +66 -0
- package/src/herdr-thread-surface.test.ts +465 -0
- package/src/herdr-thread-surface.ts +378 -0
- package/src/index.ts +8 -8
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/jj-working-tree.ts +131 -0
- package/src/main.ts +4 -1
- package/src/operation-receipts.test.ts +111 -0
- package/src/operation-receipts.ts +91 -0
- package/src/paths.ts +49 -0
- package/src/pr-poller.test.ts +102 -0
- package/src/pr-poller.ts +73 -0
- package/src/project-files.test.ts +134 -0
- package/src/project-files.ts +68 -0
- package/src/protocol.ts +78 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/sdk-identities.ts +35 -0
- package/src/server.test.ts +465 -40
- package/src/server.ts +601 -88
- package/src/share-blob.test.ts +119 -0
- package/src/share-blob.ts +57 -0
- package/src/single-instance.test.ts +162 -13
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +173 -0
- package/src/store.ts +267 -34
- package/src/testing/store-conformance.ts +192 -0
- package/src/thread-agent-validation.ts +119 -0
- package/src/thread-agent.test.ts +412 -0
- package/src/thread-agent.ts +713 -0
- package/src/thread-review.test.ts +483 -0
- package/src/thread-review.ts +382 -0
- package/src/thread-subscription.test.ts +174 -0
- package/src/thread-subscription.ts +129 -0
- package/src/thread-surface-config.test.ts +63 -0
- package/src/thread-surface-config.ts +61 -0
- package/src/validate.test.ts +366 -21
- package/src/validate.ts +458 -47
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +229 -0
- package/src/version.ts +6 -0
- package/src/working-tree.ts +150 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { loadGhosttyThreadSurface, loadHerdrThreadSurface } from "./thread-surface-config";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
let userPath: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-config-"));
|
|
12
|
+
userPath = join(dir, "user.toml");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("personal Ghostty Thread surface config", () => {
|
|
16
|
+
test("defaults to tab and accepts pane, window, or none", () => {
|
|
17
|
+
expect(loadGhosttyThreadSurface(userPath)).toBe("tab");
|
|
18
|
+
|
|
19
|
+
for (const mode of ["pane", "window", "none"] as const) {
|
|
20
|
+
writeFileSync(userPath, `[integrations.ghostty]\nthread_surface = "${mode}"\n`);
|
|
21
|
+
expect(loadGhosttyThreadSurface(userPath)).toBe(mode);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
writeFileSync(userPath, '[integrations.ghostty]\nthread_surface = "invalid"\n');
|
|
25
|
+
expect(loadGhosttyThreadSurface(userPath)).toBe("tab");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("ignores repository config", () => {
|
|
29
|
+
const repoConfig = join(dir, "repo", ".cueloop", "config.toml");
|
|
30
|
+
|
|
31
|
+
mkdirSync(join(dir, "repo", ".cueloop"), { recursive: true });
|
|
32
|
+
writeFileSync(userPath, '[integrations.ghostty]\nthread_surface = "window"\n');
|
|
33
|
+
writeFileSync(repoConfig, '[integrations.ghostty]\nthread_surface = "none"\n');
|
|
34
|
+
|
|
35
|
+
expect(loadGhosttyThreadSurface(userPath)).toBe("window");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("personal Herdr Thread surface config", () => {
|
|
44
|
+
test("defaults to tab and accepts pane or none", () => {
|
|
45
|
+
expect(loadHerdrThreadSurface(userPath)).toBe("tab");
|
|
46
|
+
writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "pane"\n');
|
|
47
|
+
expect(loadHerdrThreadSurface(userPath)).toBe("pane");
|
|
48
|
+
writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "none"\n');
|
|
49
|
+
expect(loadHerdrThreadSurface(userPath)).toBe("none");
|
|
50
|
+
writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "invalid"\n');
|
|
51
|
+
expect(loadHerdrThreadSurface(userPath)).toBe("tab");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("never reads a repository's terminal automation setting", () => {
|
|
55
|
+
const repoConfig = join(dir, "repo", ".cueloop", "config.toml");
|
|
56
|
+
|
|
57
|
+
mkdirSync(join(dir, "repo", ".cueloop"), { recursive: true });
|
|
58
|
+
writeFileSync(userPath, '[integrations.herdr]\nthread_surface = "pane"\n');
|
|
59
|
+
writeFileSync(repoConfig, '[integrations.herdr]\nthread_surface = "none"\n');
|
|
60
|
+
|
|
61
|
+
expect(loadHerdrThreadSurface(userPath)).toBe("pane");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse as parseToml } from "smol-toml";
|
|
5
|
+
import * as v from "valibot";
|
|
6
|
+
|
|
7
|
+
/** Placement of a cueloop Thread inside Herdr. */
|
|
8
|
+
export type HerdrThreadSurface = "tab" | "pane" | "none";
|
|
9
|
+
/** Placement of a cueloop Thread inside Ghostty. */
|
|
10
|
+
export type GhosttyThreadSurface = "tab" | "pane" | "window" | "none";
|
|
11
|
+
|
|
12
|
+
const PersonalThreadSurfaceSchema = v.object({
|
|
13
|
+
integrations: v.optional(
|
|
14
|
+
v.object({
|
|
15
|
+
herdr: v.optional(
|
|
16
|
+
v.object({
|
|
17
|
+
thread_surface: v.fallback(v.optional(v.picklist(["tab", "pane", "none"])), undefined),
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
ghostty: v.optional(
|
|
21
|
+
v.object({
|
|
22
|
+
thread_surface: v.fallback(
|
|
23
|
+
v.optional(v.picklist(["tab", "pane", "window", "none"])),
|
|
24
|
+
undefined,
|
|
25
|
+
),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Read Herdr placement from the personal TOML file only; invalid values use the tab default. */
|
|
33
|
+
export function loadHerdrThreadSurface(userConfigPath?: string): HerdrThreadSurface {
|
|
34
|
+
return (
|
|
35
|
+
loadPersonalThreadSurfaceConfig(userConfigPath)?.integrations?.herdr?.thread_surface ?? "tab"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read Ghostty placement from the personal TOML file only. */
|
|
40
|
+
export function loadGhosttyThreadSurface(userConfigPath?: string): GhosttyThreadSurface {
|
|
41
|
+
return (
|
|
42
|
+
loadPersonalThreadSurfaceConfig(userConfigPath)?.integrations?.ghostty?.thread_surface ?? "tab"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadPersonalThreadSurfaceConfig(userConfigPath?: string) {
|
|
47
|
+
const path =
|
|
48
|
+
userConfigPath ??
|
|
49
|
+
process.env.CUELOOP_CONFIG ??
|
|
50
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
51
|
+
|
|
52
|
+
if (!existsSync(path)) return null;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const parsed = v.safeParse(PersonalThreadSurfaceSchema, parseToml(readFileSync(path, "utf8")));
|
|
56
|
+
|
|
57
|
+
return parsed.success ? parsed.output : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/validate.test.ts
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import * as v from "valibot";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { DaemonCore } from "./api";
|
|
2
7
|
import { DaemonError } from "./errors";
|
|
3
|
-
import {
|
|
4
|
-
|
|
8
|
+
import {
|
|
9
|
+
AnchorSchema,
|
|
10
|
+
AnnotationSchema,
|
|
11
|
+
ArtifactMetaSchema,
|
|
12
|
+
ArtifactSchema,
|
|
13
|
+
IdentitySchema,
|
|
14
|
+
RevisionSchema,
|
|
15
|
+
ThreadRecordSchema,
|
|
16
|
+
MessageSchema,
|
|
17
|
+
WorkspaceSchema,
|
|
18
|
+
isKnownMethod,
|
|
19
|
+
parseParams,
|
|
20
|
+
validateThreadRecord,
|
|
21
|
+
} from "./validate";
|
|
22
|
+
import {
|
|
23
|
+
ARTIFACT_TYPES,
|
|
24
|
+
SCHEMA_VERSION,
|
|
25
|
+
type Anchor,
|
|
26
|
+
type Annotation,
|
|
27
|
+
type Artifact,
|
|
28
|
+
type ArtifactMeta,
|
|
29
|
+
type Identity,
|
|
30
|
+
type Thread,
|
|
31
|
+
type Revision,
|
|
32
|
+
type Message,
|
|
33
|
+
type WorkspaceKey,
|
|
34
|
+
} from "@cueloop/schema";
|
|
5
35
|
|
|
6
36
|
describe("method allowlist", () => {
|
|
7
37
|
test("known and unknown methods", () => {
|
|
@@ -12,11 +42,14 @@ describe("method allowlist", () => {
|
|
|
12
42
|
|
|
13
43
|
describe("parseParams", () => {
|
|
14
44
|
test("accepts a well-formed create and defaults meta", () => {
|
|
15
|
-
|
|
45
|
+
// Act
|
|
46
|
+
const params = parseParams("session.create", {
|
|
16
47
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
17
48
|
artifact: { type: "plan", content: "# P" },
|
|
18
49
|
});
|
|
19
|
-
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
52
|
+
expect(params.artifact.meta).toEqual({});
|
|
20
53
|
});
|
|
21
54
|
|
|
22
55
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -25,11 +58,48 @@ describe("parseParams", () => {
|
|
|
25
58
|
throw new Error("should have thrown");
|
|
26
59
|
} catch (err) {
|
|
27
60
|
expect(err).toBeInstanceOf(DaemonError);
|
|
28
|
-
|
|
29
|
-
|
|
61
|
+
if (err instanceof DaemonError) {
|
|
62
|
+
expect(err.code).toBe("invalid_params");
|
|
63
|
+
expect(err.message).toContain("session.create: workspace");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("accepts every artifact type in the schema union", () => {
|
|
69
|
+
for (const type of ARTIFACT_TYPES) {
|
|
70
|
+
const params = parseParams("session.create", {
|
|
71
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
72
|
+
artifact: { type, content: "body" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(params.artifact.type).toBe(type);
|
|
30
76
|
}
|
|
31
77
|
});
|
|
32
78
|
|
|
79
|
+
test.each([
|
|
80
|
+
[
|
|
81
|
+
[
|
|
82
|
+
{ start: 2, end: 4, quote: "ab" },
|
|
83
|
+
{ start: 1, end: 2, quote: "c" },
|
|
84
|
+
],
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
[
|
|
88
|
+
{ start: 1, end: 4, quote: "abc" },
|
|
89
|
+
{ start: 3, end: 5, quote: "de" },
|
|
90
|
+
],
|
|
91
|
+
],
|
|
92
|
+
[[{ start: 1, end: 4, quote: "ab" }]],
|
|
93
|
+
])("rejects malformed text Cut provenance", (textCuts) => {
|
|
94
|
+
expect(() =>
|
|
95
|
+
parseParams("session.setWorkingCopy", {
|
|
96
|
+
id: "ses_1",
|
|
97
|
+
workingCopy: "remaining",
|
|
98
|
+
textCuts,
|
|
99
|
+
}),
|
|
100
|
+
).toThrow("text Cuts must be ordered, non-overlapping, and match their quote lengths");
|
|
101
|
+
});
|
|
102
|
+
|
|
33
103
|
test("rejects an unknown artifact type", () => {
|
|
34
104
|
expect(() =>
|
|
35
105
|
parseParams("session.create", {
|
|
@@ -46,21 +116,40 @@ describe("parseParams", () => {
|
|
|
46
116
|
test("defaults and clamps the wait timeout", () => {
|
|
47
117
|
expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
|
|
48
118
|
expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
|
|
49
|
-
expect(() =>
|
|
119
|
+
expect(() =>
|
|
120
|
+
parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
|
|
121
|
+
).toThrow();
|
|
50
122
|
});
|
|
51
123
|
|
|
52
|
-
test("
|
|
53
|
-
expect(parseParams("session.
|
|
54
|
-
expect(() => parseParams("session.
|
|
124
|
+
test("message kinds are closed", () => {
|
|
125
|
+
expect(parseParams("session.sendMessage", { id: "s", outcome: "approved" }).summary).toBe("");
|
|
126
|
+
expect(() => parseParams("session.sendMessage", { id: "s", outcome: "lgtm" })).toThrow(
|
|
127
|
+
/outcome/,
|
|
128
|
+
);
|
|
55
129
|
});
|
|
56
130
|
|
|
57
131
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
58
|
-
|
|
132
|
+
// Act
|
|
133
|
+
const params = parseParams("session.annotate", {
|
|
59
134
|
id: "s",
|
|
60
135
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
61
136
|
});
|
|
62
|
-
|
|
63
|
-
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(params.annotation.kind).toBe("praise");
|
|
140
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
144
|
+
expect(
|
|
145
|
+
parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
|
|
146
|
+
.viewedPaths,
|
|
147
|
+
).toEqual(["src/a.ts", "src/b.ts"]);
|
|
148
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
149
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
|
|
150
|
+
/viewedPaths/,
|
|
151
|
+
);
|
|
152
|
+
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
64
153
|
});
|
|
65
154
|
|
|
66
155
|
test("null params are treated as empty", () => {
|
|
@@ -68,7 +157,7 @@ describe("parseParams", () => {
|
|
|
68
157
|
});
|
|
69
158
|
});
|
|
70
159
|
|
|
71
|
-
describe("
|
|
160
|
+
describe("validateThreadRecord", () => {
|
|
72
161
|
const record = {
|
|
73
162
|
schemaVersion: SCHEMA_VERSION,
|
|
74
163
|
id: "ses_1",
|
|
@@ -76,23 +165,279 @@ describe("validateSessionRecord", () => {
|
|
|
76
165
|
artifact: { type: "plan", content: "# P", meta: {} },
|
|
77
166
|
revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
|
|
78
167
|
annotations: [],
|
|
79
|
-
|
|
168
|
+
message: null,
|
|
80
169
|
status: "pending",
|
|
81
170
|
createdAt: "now",
|
|
82
171
|
};
|
|
83
172
|
|
|
84
173
|
test("accepts a valid record", () => {
|
|
85
|
-
expect(
|
|
174
|
+
expect(validateThreadRecord(record).ok).toBe(true);
|
|
86
175
|
});
|
|
87
176
|
|
|
88
177
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
178
|
+
// Act
|
|
179
|
+
const result = validateThreadRecord({ ...record, schemaVersion: "99" });
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(result.ok).toBe(false);
|
|
183
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
92
184
|
});
|
|
93
185
|
|
|
94
186
|
test("rejects a structurally broken record", () => {
|
|
95
|
-
|
|
96
|
-
|
|
187
|
+
// Act
|
|
188
|
+
const result = validateThreadRecord({ ...record, revisions: "nope" });
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
191
|
+
expect(result.ok).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
197
|
+
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
198
|
+
* check - the samples are fully populated and typed, so a new field in
|
|
199
|
+
* @cueloop/schema shows up here too.
|
|
200
|
+
*/
|
|
201
|
+
describe("wire pins", () => {
|
|
202
|
+
const keys = <Subject extends object>(subject: Subject) => Object.keys(subject).sort();
|
|
203
|
+
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
204
|
+
|
|
205
|
+
const fullMeta: Required<ArtifactMeta> = {
|
|
206
|
+
workflow: "plan",
|
|
207
|
+
cwd: "/repo",
|
|
208
|
+
vcs: "jj",
|
|
209
|
+
vcsChangeId: "change",
|
|
210
|
+
vcsRevisionId: "revision",
|
|
211
|
+
agent: "claude-code",
|
|
212
|
+
agentSessionId: "sess-1",
|
|
213
|
+
planPath: "/repo/plan.md",
|
|
214
|
+
prototypePath: "/repo/proto.html",
|
|
215
|
+
pr: "org/repo#1",
|
|
216
|
+
prBrief: "# PR",
|
|
217
|
+
prBaseSha: "base",
|
|
218
|
+
prHeadSha: "head",
|
|
219
|
+
prRefreshBaseSha: "next-base",
|
|
220
|
+
prRefreshHeadSha: "next",
|
|
221
|
+
prUrl: "https://github.com/org/repo/pull/1",
|
|
222
|
+
herdrPane: "%7",
|
|
223
|
+
title: "Plan",
|
|
224
|
+
workbench: false,
|
|
225
|
+
snapshot: false,
|
|
226
|
+
};
|
|
227
|
+
const fullAnchor: Required<Anchor> = {
|
|
228
|
+
quote: "q",
|
|
229
|
+
prefix: "p",
|
|
230
|
+
suffix: "s",
|
|
231
|
+
blockIndex: 0,
|
|
232
|
+
endBlockIndex: 0,
|
|
233
|
+
start: 0,
|
|
234
|
+
end: 1,
|
|
235
|
+
selector: "div.card",
|
|
236
|
+
};
|
|
237
|
+
const fullAnnotation: Required<Annotation> = {
|
|
238
|
+
id: "a1",
|
|
239
|
+
kind: "comment",
|
|
240
|
+
anchor: fullAnchor,
|
|
241
|
+
target: { kind: "file", path: "src/x.ts", rev: "worktree" },
|
|
242
|
+
body: "b",
|
|
243
|
+
orphan: false,
|
|
244
|
+
author: "SHA256:abc",
|
|
245
|
+
reviewComment: {
|
|
246
|
+
severity: "p1",
|
|
247
|
+
title: "Finding",
|
|
248
|
+
path: "src/x.ts",
|
|
249
|
+
line: 1,
|
|
250
|
+
startLine: 1,
|
|
251
|
+
startAnchor: fullAnchor,
|
|
252
|
+
side: "RIGHT",
|
|
253
|
+
suggestion: "replacement",
|
|
254
|
+
prompt: "fix it",
|
|
255
|
+
},
|
|
256
|
+
replyTo: "a0",
|
|
257
|
+
resolution: { revision: 2, source: "agent" },
|
|
258
|
+
createdAt: "now",
|
|
259
|
+
};
|
|
260
|
+
const fullArtifact: Required<Artifact> = {
|
|
261
|
+
type: "plan",
|
|
262
|
+
content: "# P",
|
|
263
|
+
meta: fullMeta,
|
|
264
|
+
files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
|
|
265
|
+
};
|
|
266
|
+
const fullMessage: Required<Message> = {
|
|
267
|
+
id: "msg_1",
|
|
268
|
+
outcome: "approved",
|
|
269
|
+
summary: "",
|
|
270
|
+
body: "",
|
|
271
|
+
annotations: [fullAnnotation],
|
|
272
|
+
sentAt: "now",
|
|
273
|
+
};
|
|
274
|
+
const fullRevision: Required<Revision> = {
|
|
275
|
+
revision: 1,
|
|
276
|
+
content: "# P",
|
|
277
|
+
submittedAt: "now",
|
|
278
|
+
source: { vcs: "jj", changeId: "change", revisionId: "revision" },
|
|
279
|
+
files: [{ path: "a.ts", oldContents: "a", newContents: "b", status: "modified" }],
|
|
280
|
+
};
|
|
281
|
+
const fullWorkspace: Required<WorkspaceKey> = {
|
|
282
|
+
repoRoot: "/repo",
|
|
283
|
+
branch: "main",
|
|
284
|
+
rootCommit: "11e7abbcf507a13ae5e4e7559c4b42dbb52237fe",
|
|
285
|
+
remote: "git@github.com:acme/repo.git",
|
|
286
|
+
};
|
|
287
|
+
const fullIdentity: Required<Identity> = {
|
|
288
|
+
id: "SHA256:abc",
|
|
289
|
+
provider: "ssh",
|
|
290
|
+
name: "Al",
|
|
291
|
+
handle: "abc",
|
|
292
|
+
};
|
|
293
|
+
const fullSession: Required<Thread> = {
|
|
294
|
+
schemaVersion: SCHEMA_VERSION,
|
|
295
|
+
id: "ses_1",
|
|
296
|
+
workspace: fullWorkspace,
|
|
297
|
+
artifact: fullArtifact,
|
|
298
|
+
revisions: [fullRevision],
|
|
299
|
+
annotations: [fullAnnotation],
|
|
300
|
+
history: {
|
|
301
|
+
entries: [
|
|
302
|
+
{
|
|
303
|
+
id: "e1",
|
|
304
|
+
parentId: null,
|
|
305
|
+
type: "revision",
|
|
306
|
+
by: "agent",
|
|
307
|
+
content: "# P",
|
|
308
|
+
createdAt: "now",
|
|
309
|
+
},
|
|
310
|
+
{ id: "e2", parentId: "e1", type: "comment", annotationId: "a1", createdAt: "now" },
|
|
311
|
+
{ id: "e3", parentId: "e2", type: "comment-removed", annotationId: "a1", createdAt: "now" },
|
|
312
|
+
{ id: "e4", parentId: "e3", type: "message", message: fullMessage, createdAt: "now" },
|
|
313
|
+
{
|
|
314
|
+
id: "e5",
|
|
315
|
+
parentId: "e4",
|
|
316
|
+
type: "branch-summary",
|
|
317
|
+
text: "tried a thing",
|
|
318
|
+
abandoned: ["e3"],
|
|
319
|
+
createdAt: "now",
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
tips: { main: "e5" },
|
|
323
|
+
branch: "main",
|
|
324
|
+
labels: { e1: "start" },
|
|
325
|
+
},
|
|
326
|
+
curation: [{ path: "src/a.ts", hunkIndex: 0, changeIndex: 1 }],
|
|
327
|
+
shelvedAnnotations: [fullAnnotation],
|
|
328
|
+
parentSessionId: "ses_0",
|
|
329
|
+
workingCopy: "# ",
|
|
330
|
+
textCuts: [{ start: 2, end: 3, quote: "P" }],
|
|
331
|
+
viewedPaths: ["src/a.ts"],
|
|
332
|
+
messageOperations: [{ operationId: "operation", fingerprint: "hash", result: fullMessage }],
|
|
333
|
+
message: fullMessage,
|
|
334
|
+
status: "pending",
|
|
335
|
+
createdAt: "now",
|
|
336
|
+
shares: [
|
|
337
|
+
{
|
|
338
|
+
id: "p_abc123xy",
|
|
339
|
+
name: "review link",
|
|
340
|
+
requireAuth: true,
|
|
341
|
+
allowlist: ["octocat"],
|
|
342
|
+
owner: "SHA256:owner",
|
|
343
|
+
shareBranch: "main",
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
participants: [fullIdentity],
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
test("a history whose tree is broken is refused even when every entry is well-formed", () => {
|
|
350
|
+
// Arrange: a parent cycle
|
|
351
|
+
const record = {
|
|
352
|
+
...fullSession,
|
|
353
|
+
history: {
|
|
354
|
+
entries: [
|
|
355
|
+
{ id: "a", parentId: "b", type: "revision", by: "agent", content: "x", createdAt: "now" },
|
|
356
|
+
{ id: "b", parentId: "a", type: "comment", annotationId: "a1", createdAt: "now" },
|
|
357
|
+
],
|
|
358
|
+
tips: { main: "b" },
|
|
359
|
+
branch: "main",
|
|
360
|
+
labels: {},
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// Act
|
|
365
|
+
const parsed = validateThreadRecord(record);
|
|
366
|
+
|
|
367
|
+
// Assert
|
|
368
|
+
expect(parsed.ok).toBe(false);
|
|
369
|
+
if (!parsed.ok) expect(parsed.error).toContain("history:");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("a persisted Thread with stale text Cut provenance is refused", () => {
|
|
373
|
+
const result = validateThreadRecord({
|
|
374
|
+
...fullSession,
|
|
375
|
+
workingCopy: "# P",
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(result.ok).toBe(false);
|
|
379
|
+
if (!result.ok) {
|
|
380
|
+
expect(result.error).toContain(
|
|
381
|
+
"text Cuts do not match the submitted artifact and working copy",
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("schema key sets match the schema types", () => {
|
|
387
|
+
expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
|
|
388
|
+
expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
|
|
389
|
+
expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
|
|
390
|
+
expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
|
|
391
|
+
// wire annotations arrive without createdAt; the daemon stamps it
|
|
392
|
+
const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
|
|
393
|
+
|
|
394
|
+
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
395
|
+
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
396
|
+
expect(entryKeys(MessageSchema)).toEqual(keys(fullMessage));
|
|
397
|
+
expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
|
|
398
|
+
expect(entryKeys(ThreadRecordSchema)).toEqual(keys(fullSession));
|
|
399
|
+
// persisted annotations carry the stamped createdAt
|
|
400
|
+
const stored = ThreadRecordSchema.entries.annotations.item;
|
|
401
|
+
|
|
402
|
+
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
406
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
// Arrange
|
|
410
|
+
const params = parseParams("session.create", {
|
|
411
|
+
workspace: fullWorkspace,
|
|
412
|
+
artifact: { type: "plan", content: "# P", meta: fullMeta },
|
|
413
|
+
});
|
|
414
|
+
const core = new DaemonCore(home);
|
|
415
|
+
|
|
416
|
+
// Act
|
|
417
|
+
const created = core.sessionCreate(params);
|
|
418
|
+
|
|
419
|
+
// Assert
|
|
420
|
+
expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
|
|
421
|
+
} finally {
|
|
422
|
+
rmSync(home, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe("IdentitySchema provider", () => {
|
|
428
|
+
test("accepts a verified github identity", () => {
|
|
429
|
+
const parsed = v.safeParse(IdentitySchema, {
|
|
430
|
+
id: "SHA256:abc",
|
|
431
|
+
provider: "github",
|
|
432
|
+
name: "markus",
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(parsed.success).toBe(true);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("rejects an unknown provider", () => {
|
|
439
|
+
const parsed = v.safeParse(IdentitySchema, { id: "SHA256:abc", provider: "email" });
|
|
440
|
+
|
|
441
|
+
expect(parsed.success).toBe(false);
|
|
97
442
|
});
|
|
98
443
|
});
|