@cueloop/daemon 0.1.0-alpha.6 → 0.1.0-alpha.61
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 +30 -22
- package/src/api.test.ts +292 -46
- package/src/api.ts +301 -52
- package/src/capabilities.test.ts +38 -0
- package/src/capabilities.ts +30 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +151 -38
- package/src/diff-hot-reload.test.ts +170 -0
- package/src/diff-watcher.ts +105 -0
- package/src/herdr-pane.test.ts +243 -0
- package/src/herdr-pane.ts +154 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +88 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +69 -0
- package/src/index.ts +8 -8
- package/src/main.ts +4 -1
- package/src/paths.ts +9 -0
- package/src/protocol.ts +51 -7
- package/src/retention.test.ts +182 -0
- package/src/retention.ts +122 -0
- package/src/review.test.ts +370 -0
- package/src/review.ts +289 -0
- package/src/server.test.ts +169 -20
- package/src/server.ts +193 -84
- package/src/share-blob.test.ts +62 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +38 -11
- package/src/store.ts +13 -5
- package/src/validate.test.ts +196 -14
- package/src/validate.ts +138 -38
- package/src/working-tree.ts +113 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { Artifact, WorkspaceKey } from "@cueloop/schema";
|
|
7
|
+
import { DaemonCore } from "./api";
|
|
8
|
+
import { MAX_BLOB_BYTES, packSessionBlob, unpackSessionBlob } from "./share-blob";
|
|
9
|
+
|
|
10
|
+
const WS: WorkspaceKey = { repoRoot: "/repo", branch: "main" };
|
|
11
|
+
const PLAN: Artifact = { type: "plan", content: "# Plan\n\nDo the thing.\n", meta: {} };
|
|
12
|
+
|
|
13
|
+
let home: string;
|
|
14
|
+
let core: DaemonCore;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-blob-"));
|
|
18
|
+
core = new DaemonCore(home);
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
|
21
|
+
|
|
22
|
+
describe("share blob roundtrip", () => {
|
|
23
|
+
test("pack then unpack restores the session with its annotations", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
26
|
+
const annotated = core.sessionAnnotate(session.id, {
|
|
27
|
+
id: "a_1",
|
|
28
|
+
kind: "comment",
|
|
29
|
+
anchor: { quote: "the thing", prefix: "", suffix: "" },
|
|
30
|
+
body: "why this?",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Act
|
|
34
|
+
const restored = unpackSessionBlob(packSessionBlob(annotated));
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(restored).toEqual(annotated);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("unpack rejects bad input", () => {
|
|
42
|
+
test("caps the decompressed size, so a compression bomb throws", () => {
|
|
43
|
+
// Arrange: tiny gzip, huge inflation - the decompression-bomb shape
|
|
44
|
+
const bomb = gzipSync(Buffer.alloc(MAX_BLOB_BYTES + 1, 0x61));
|
|
45
|
+
|
|
46
|
+
// Act / Assert
|
|
47
|
+
expect(() => unpackSessionBlob(bomb)).toThrow(/exceeds|maxOutputLength/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("throws on bytes that are not gzip", () => {
|
|
51
|
+
// Act / Assert
|
|
52
|
+
expect(() => unpackSessionBlob(Buffer.from("not gzip at all"))).toThrow(/gzip/i);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("throws on a shape that is not a session", () => {
|
|
56
|
+
// Arrange
|
|
57
|
+
const notASession = gzipSync(Buffer.from(JSON.stringify({ hello: "world" })));
|
|
58
|
+
|
|
59
|
+
// Act / Assert
|
|
60
|
+
expect(() => unpackSessionBlob(notASession)).toThrow(/not a valid session/i);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share-blob wire format: how a ReviewSession travels from `cueloop share`
|
|
3
|
+
* to the gateway. The planner's CLI packs (this is the only thing it can do -
|
|
4
|
+
* it holds no encryption key); the gateway unpacks, then encrypts for R2.
|
|
5
|
+
*
|
|
6
|
+
* Format is gzip(JSON). Unpack is the untrusted boundary - the bytes come off
|
|
7
|
+
* an SSH channel from anyone who can reach the `share` user - so it caps the
|
|
8
|
+
* decompressed size (a decompression-bomb guard) and runs the same record
|
|
9
|
+
* validator the daemon store uses, never trusting the shape.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
13
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
14
|
+
import { validateSessionRecord } from "./validate";
|
|
15
|
+
|
|
16
|
+
/** Decompressed ceiling for one shared session (ADR 0004's payload cap). */
|
|
17
|
+
export const MAX_BLOB_BYTES = 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
/** The SSH username `cueloop share` connects as to upload; the gateway routes on it. */
|
|
20
|
+
export const SHARE_UPLOAD_USER = "share";
|
|
21
|
+
|
|
22
|
+
/** Where shares live, in one place: the client uploads here, the gateway prints it. */
|
|
23
|
+
export const DEFAULT_SHARE_HOST = "cueloop.dev";
|
|
24
|
+
export const DEFAULT_SHARE_PORT = 22;
|
|
25
|
+
|
|
26
|
+
/** Serialise + compress a session for upload. */
|
|
27
|
+
export function packSessionBlob(session: ReviewSession): Buffer {
|
|
28
|
+
return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Decompress + validate an uploaded blob, or throw a precise reason. */
|
|
32
|
+
export function unpackSessionBlob(bytes: Uint8Array): ReviewSession {
|
|
33
|
+
let json: string;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
json = gunzipSync(bytes, { maxOutputLength: MAX_BLOB_BYTES }).toString("utf8");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`blob is not valid gzip or exceeds ${MAX_BLOB_BYTES} bytes: ${err instanceof Error ? err.message : String(err)}`,
|
|
40
|
+
{ cause: err },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
let raw: unknown;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
raw = JSON.parse(json);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error("blob is not valid JSON");
|
|
49
|
+
}
|
|
50
|
+
const parsed = validateSessionRecord(raw);
|
|
51
|
+
|
|
52
|
+
if (!parsed.ok) throw new Error(`blob is not a valid session: ${parsed.error}`);
|
|
53
|
+
|
|
54
|
+
return parsed.value as ReviewSession;
|
|
55
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Single-instance guarantee
|
|
2
|
+
* Single-instance guarantee: one daemon per home. Two daemons over one
|
|
3
3
|
* state directory means divergent in-memory sessions and clients seeing
|
|
4
4
|
* whichever half they connect to.
|
|
5
5
|
*/
|
|
@@ -23,33 +23,44 @@ beforeEach(() => {
|
|
|
23
23
|
home = mkdtempSync(join(tmpdir(), "cueloop-lock-"));
|
|
24
24
|
});
|
|
25
25
|
afterEach(() => {
|
|
26
|
-
for (const
|
|
26
|
+
for (const runningServer of servers.splice(0)) runningServer.stop();
|
|
27
27
|
rmSync(home, { recursive: true, force: true });
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
function server(): DaemonServer {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const daemonServer = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
|
|
33
|
+
servers.push(daemonServer);
|
|
34
|
+
|
|
35
|
+
return daemonServer;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
describe("one daemon per home", () => {
|
|
37
39
|
test("the second start is refused instead of stealing the socket", () => {
|
|
40
|
+
// Arrange
|
|
38
41
|
const first = server();
|
|
39
42
|
const path = first.start();
|
|
43
|
+
|
|
40
44
|
expect(path).toBe(socketPath(home));
|
|
41
45
|
const second = server();
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
42
48
|
expect(second.start()).toBeNull();
|
|
43
49
|
// the winner's socket survives untouched
|
|
44
50
|
expect(existsSync(path!)).toBe(true);
|
|
45
51
|
});
|
|
46
52
|
|
|
47
53
|
test("clients keep talking to the one live daemon after a refused start", async () => {
|
|
54
|
+
// Arrange
|
|
48
55
|
const first = server();
|
|
56
|
+
|
|
49
57
|
first.start();
|
|
50
58
|
const created = first.core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
59
|
+
|
|
51
60
|
server().start(); // refused
|
|
52
61
|
const client = await DaemonClient.connect({ home });
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
53
64
|
try {
|
|
54
65
|
// no split brain: the session created on the winner is visible
|
|
55
66
|
expect((await client.sessionGet(created.id)).id).toBe(created.id);
|
|
@@ -60,40 +71,56 @@ describe("one daemon per home", () => {
|
|
|
60
71
|
});
|
|
61
72
|
|
|
62
73
|
test("a stale lock from a crashed daemon is reclaimed", () => {
|
|
74
|
+
// Arrange
|
|
63
75
|
// a pid that cannot be running (init is never a cueloop daemon, and this
|
|
64
76
|
// simulates the record a crashed process leaves behind)
|
|
65
77
|
writeFileSync(lockPath(home), "999999");
|
|
66
|
-
const
|
|
67
|
-
|
|
78
|
+
const daemonServer = server();
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
81
|
+
expect(daemonServer.start()).toBe(socketPath(home));
|
|
68
82
|
});
|
|
69
83
|
|
|
70
84
|
test("stopping releases the lock so a restart works", () => {
|
|
85
|
+
// Arrange
|
|
71
86
|
const first = server();
|
|
87
|
+
|
|
72
88
|
first.start();
|
|
89
|
+
|
|
90
|
+
// Act
|
|
73
91
|
first.stop();
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
74
94
|
expect(existsSync(lockPath(home))).toBe(false);
|
|
75
95
|
const second = server();
|
|
96
|
+
|
|
76
97
|
expect(second.start()).toBe(socketPath(home));
|
|
77
98
|
});
|
|
78
99
|
|
|
79
100
|
test("concurrent autostarts converge on a single daemon", async () => {
|
|
101
|
+
// Act
|
|
80
102
|
// several clients race to autostart; all must end up on the same daemon
|
|
81
103
|
const clients = await Promise.all([
|
|
82
104
|
DaemonClient.connect({ home, autostart: true }),
|
|
83
105
|
DaemonClient.connect({ home, autostart: true }),
|
|
84
106
|
DaemonClient.connect({ home, autostart: true }),
|
|
85
107
|
]);
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
86
110
|
try {
|
|
87
|
-
const pids = await Promise.all(clients.map((
|
|
88
|
-
const unique = new Set(pids.map((
|
|
111
|
+
const pids = await Promise.all(clients.map((client) => client.ping()));
|
|
112
|
+
const unique = new Set(pids.map((ping) => ping.pid));
|
|
113
|
+
|
|
89
114
|
expect(unique.size).toBe(1);
|
|
90
115
|
// and state is shared: one client's session is visible to the others
|
|
91
|
-
const
|
|
92
|
-
|
|
116
|
+
const session = await clients[0]!.sessionCreate(WS, PLAN);
|
|
117
|
+
|
|
118
|
+
expect((await clients[2]!.sessionGet(session.id)).id).toBe(session.id);
|
|
93
119
|
} finally {
|
|
94
120
|
for (const c of clients) c.close();
|
|
95
121
|
// the autostarted daemon is not one of `servers` - shut it down
|
|
96
122
|
const admin = await DaemonClient.connect({ home });
|
|
123
|
+
|
|
97
124
|
await admin.shutdown();
|
|
98
125
|
admin.close();
|
|
99
126
|
}
|
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session persistence
|
|
2
|
+
* Session persistence: one JSON document per session, every write
|
|
3
3
|
* through a temp file + atomic rename so a crash mid-write can never leave
|
|
4
4
|
* a corrupt record. Recovery is a read-only scan; records that fail to
|
|
5
5
|
* parse are skipped and reported, never deleted.
|
|
@@ -28,19 +28,23 @@ export class SessionStore {
|
|
|
28
28
|
/** Read-only scan of the state directory; called once on boot. */
|
|
29
29
|
recover(): RecoveryReport {
|
|
30
30
|
const report: RecoveryReport = { recovered: [], skipped: [] };
|
|
31
|
+
|
|
31
32
|
for (const file of readdirSync(this.dir)) {
|
|
32
33
|
if (!file.endsWith(".json")) continue;
|
|
33
34
|
try {
|
|
34
35
|
const raw = readFileSync(join(this.dir, file), "utf8");
|
|
35
36
|
const parsed = validateSessionRecord(JSON.parse(raw));
|
|
37
|
+
|
|
36
38
|
if (!parsed.ok) throw new Error(`invalid record - ${parsed.error}`);
|
|
37
39
|
const session = parsed.value as ReviewSession;
|
|
40
|
+
|
|
38
41
|
this.sessions.set(session.id, session);
|
|
39
42
|
report.recovered.push(session.id);
|
|
40
43
|
} catch (err) {
|
|
41
44
|
report.skipped.push({ file, error: err instanceof Error ? err.message : String(err) });
|
|
42
45
|
}
|
|
43
46
|
}
|
|
47
|
+
|
|
44
48
|
return report;
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -49,20 +53,24 @@ export class SessionStore {
|
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
list(): ReviewSession[] {
|
|
52
|
-
return [...this.sessions.values()].sort((
|
|
56
|
+
return [...this.sessions.values()].sort((left, right) =>
|
|
57
|
+
left.createdAt.localeCompare(right.createdAt),
|
|
58
|
+
);
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
upsert(session: ReviewSession): void {
|
|
56
62
|
this.sessions.set(session.id, session);
|
|
57
63
|
const path = join(this.dir, `${session.id}.json`);
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
const tempPath = path + ".tmp";
|
|
65
|
+
|
|
66
|
+
writeFileSync(tempPath, JSON.stringify(session, null, 2));
|
|
67
|
+
renameSync(tempPath, path);
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
delete(id: string): boolean {
|
|
64
71
|
if (!this.sessions.delete(id)) return false;
|
|
65
72
|
rmSync(join(this.dir, `${id}.json`), { force: true });
|
|
73
|
+
|
|
66
74
|
return true;
|
|
67
75
|
}
|
|
68
76
|
}
|
package/src/validate.test.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
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
|
+
IdentitySchema,
|
|
13
|
+
RevisionSchema,
|
|
14
|
+
SessionRecordSchema,
|
|
15
|
+
VerdictSchema,
|
|
16
|
+
WorkspaceSchema,
|
|
17
|
+
isKnownMethod,
|
|
18
|
+
parseParams,
|
|
19
|
+
validateSessionRecord,
|
|
20
|
+
} from "./validate";
|
|
21
|
+
import {
|
|
22
|
+
SCHEMA_VERSION,
|
|
23
|
+
type Anchor,
|
|
24
|
+
type Annotation,
|
|
25
|
+
type Artifact,
|
|
26
|
+
type ArtifactMeta,
|
|
27
|
+
type Identity,
|
|
28
|
+
type ReviewSession,
|
|
29
|
+
type Revision,
|
|
30
|
+
type Verdict,
|
|
31
|
+
type WorkspaceKey,
|
|
32
|
+
} from "@cueloop/schema";
|
|
5
33
|
|
|
6
34
|
describe("method allowlist", () => {
|
|
7
35
|
test("known and unknown methods", () => {
|
|
@@ -12,11 +40,14 @@ describe("method allowlist", () => {
|
|
|
12
40
|
|
|
13
41
|
describe("parseParams", () => {
|
|
14
42
|
test("accepts a well-formed create and defaults meta", () => {
|
|
15
|
-
|
|
43
|
+
// Act
|
|
44
|
+
const params = parseParams("session.create", {
|
|
16
45
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
17
46
|
artifact: { type: "plan", content: "# P" },
|
|
18
47
|
});
|
|
19
|
-
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(params.artifact.meta).toEqual({});
|
|
20
51
|
});
|
|
21
52
|
|
|
22
53
|
test("rejects a missing workspace with a pathed message", () => {
|
|
@@ -30,6 +61,15 @@ describe("parseParams", () => {
|
|
|
30
61
|
}
|
|
31
62
|
});
|
|
32
63
|
|
|
64
|
+
test("accepts the reply artifact type", () => {
|
|
65
|
+
const params = parseParams("session.create", {
|
|
66
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
67
|
+
artifact: { type: "reply", content: "# R" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(params.artifact.type).toBe("reply");
|
|
71
|
+
});
|
|
72
|
+
|
|
33
73
|
test("rejects an unknown artifact type", () => {
|
|
34
74
|
expect(() =>
|
|
35
75
|
parseParams("session.create", {
|
|
@@ -46,21 +86,40 @@ describe("parseParams", () => {
|
|
|
46
86
|
test("defaults and clamps the wait timeout", () => {
|
|
47
87
|
expect(parseParams("session.wait", { id: "ses_1" }).timeoutMs).toBe(60_000);
|
|
48
88
|
expect(() => parseParams("session.wait", { id: "ses_1", timeoutMs: -5 })).toThrow();
|
|
49
|
-
expect(() =>
|
|
89
|
+
expect(() =>
|
|
90
|
+
parseParams("session.wait", { id: "ses_1", timeoutMs: 99 * 60 * 60 * 1000 }),
|
|
91
|
+
).toThrow();
|
|
50
92
|
});
|
|
51
93
|
|
|
52
94
|
test("verdict kinds are closed", () => {
|
|
53
95
|
expect(parseParams("session.resolve", { id: "s", verdictKind: "approve" }).summary).toBe("");
|
|
54
|
-
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
|
|
96
|
+
expect(() => parseParams("session.resolve", { id: "s", verdictKind: "lgtm" })).toThrow(
|
|
97
|
+
/verdictKind/,
|
|
98
|
+
);
|
|
55
99
|
});
|
|
56
100
|
|
|
57
101
|
test("annotation kinds stay open (extension kinds are allowed)", () => {
|
|
58
|
-
|
|
102
|
+
// Act
|
|
103
|
+
const params = parseParams("session.annotate", {
|
|
59
104
|
id: "s",
|
|
60
105
|
annotation: { id: "a1", kind: "praise", anchor: { quote: "x" }, body: "nice" },
|
|
61
106
|
});
|
|
62
|
-
|
|
63
|
-
|
|
107
|
+
|
|
108
|
+
// Assert
|
|
109
|
+
expect(params.annotation.kind).toBe("praise");
|
|
110
|
+
expect(params.annotation.anchor.prefix).toBe("");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("setViewed takes a full path list and rejects non-string entries", () => {
|
|
114
|
+
expect(
|
|
115
|
+
parseParams("session.setViewed", { id: "s", viewedPaths: ["src/a.ts", "src/b.ts"] })
|
|
116
|
+
.viewedPaths,
|
|
117
|
+
).toEqual(["src/a.ts", "src/b.ts"]);
|
|
118
|
+
expect(parseParams("session.setViewed", { id: "s", viewedPaths: [] }).viewedPaths).toEqual([]);
|
|
119
|
+
expect(() => parseParams("session.setViewed", { id: "s", viewedPaths: [1] })).toThrow(
|
|
120
|
+
/viewedPaths/,
|
|
121
|
+
);
|
|
122
|
+
expect(() => parseParams("session.setViewed", { id: "s" })).toThrow(/viewedPaths/);
|
|
64
123
|
});
|
|
65
124
|
|
|
66
125
|
test("null params are treated as empty", () => {
|
|
@@ -86,13 +145,136 @@ describe("validateSessionRecord", () => {
|
|
|
86
145
|
});
|
|
87
146
|
|
|
88
147
|
test("rejects a foreign schema version with a readable reason", () => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
148
|
+
// Act
|
|
149
|
+
const result = validateSessionRecord({ ...record, schemaVersion: "99" });
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(result.ok).toBe(false);
|
|
153
|
+
if (!result.ok) expect(result.error).toContain("schemaVersion");
|
|
92
154
|
});
|
|
93
155
|
|
|
94
156
|
test("rejects a structurally broken record", () => {
|
|
95
|
-
|
|
96
|
-
|
|
157
|
+
// Act
|
|
158
|
+
const result = validateSessionRecord({ ...record, revisions: "nope" });
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
161
|
+
expect(result.ok).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wire pins: every schema's entries must cover exactly the keys of the
|
|
167
|
+
* schema type it mirrors. Runtime complement to the compile-time EntriesOf
|
|
168
|
+
* check - the samples are fully populated and typed, so a new field in
|
|
169
|
+
* @cueloop/schema shows up here too.
|
|
170
|
+
*/
|
|
171
|
+
describe("wire pins", () => {
|
|
172
|
+
const keys = (subject: object) => Object.keys(subject).sort();
|
|
173
|
+
const entryKeys = (schema: { entries: object }) => Object.keys(schema.entries).sort();
|
|
174
|
+
|
|
175
|
+
const fullMeta: Required<ArtifactMeta> = {
|
|
176
|
+
cwd: "/repo",
|
|
177
|
+
agent: "claude-code",
|
|
178
|
+
agentSessionId: "sess-1",
|
|
179
|
+
planPath: "/repo/plan.md",
|
|
180
|
+
prototypePath: "/repo/proto.html",
|
|
181
|
+
pr: "org/repo#1",
|
|
182
|
+
herdrPane: "%7",
|
|
183
|
+
title: "Plan",
|
|
184
|
+
};
|
|
185
|
+
const fullAnchor: Required<Anchor> = {
|
|
186
|
+
quote: "q",
|
|
187
|
+
prefix: "p",
|
|
188
|
+
suffix: "s",
|
|
189
|
+
blockIndex: 0,
|
|
190
|
+
start: 0,
|
|
191
|
+
end: 1,
|
|
192
|
+
selector: "div.card",
|
|
193
|
+
};
|
|
194
|
+
const fullAnnotation: Required<Annotation> = {
|
|
195
|
+
id: "a1",
|
|
196
|
+
kind: "comment",
|
|
197
|
+
anchor: fullAnchor,
|
|
198
|
+
body: "b",
|
|
199
|
+
orphan: false,
|
|
200
|
+
author: "SHA256:abc",
|
|
201
|
+
resolution: { revision: 2, source: "agent" },
|
|
202
|
+
createdAt: "now",
|
|
203
|
+
};
|
|
204
|
+
const fullArtifact: Required<Artifact> = {
|
|
205
|
+
type: "plan",
|
|
206
|
+
content: "# P",
|
|
207
|
+
meta: fullMeta,
|
|
208
|
+
files: [{ path: "src/a.ts", oldContents: "old\n", newContents: "new\n", status: "modified" }],
|
|
209
|
+
};
|
|
210
|
+
const fullVerdict: Required<Verdict> = {
|
|
211
|
+
kind: "approve",
|
|
212
|
+
summary: "",
|
|
213
|
+
feedback: "",
|
|
214
|
+
resolvedAt: "now",
|
|
215
|
+
};
|
|
216
|
+
const fullRevision: Required<Revision> = { revision: 1, content: "# P", submittedAt: "now" };
|
|
217
|
+
const fullWorkspace: Required<WorkspaceKey> = { repoRoot: "/repo", branch: "main" };
|
|
218
|
+
const fullIdentity: Required<Identity> = {
|
|
219
|
+
id: "SHA256:abc",
|
|
220
|
+
provider: "ssh",
|
|
221
|
+
name: "Al",
|
|
222
|
+
handle: "abc",
|
|
223
|
+
};
|
|
224
|
+
const fullSession: Required<ReviewSession> = {
|
|
225
|
+
schemaVersion: SCHEMA_VERSION,
|
|
226
|
+
id: "ses_1",
|
|
227
|
+
workspace: fullWorkspace,
|
|
228
|
+
artifact: fullArtifact,
|
|
229
|
+
revisions: [fullRevision],
|
|
230
|
+
annotations: [fullAnnotation],
|
|
231
|
+
workingCopy: "# P edited",
|
|
232
|
+
viewedPaths: ["src/a.ts"],
|
|
233
|
+
verdict: fullVerdict,
|
|
234
|
+
status: "pending",
|
|
235
|
+
createdAt: "now",
|
|
236
|
+
shareId: "p_abc123xy",
|
|
237
|
+
owner: "SHA256:owner",
|
|
238
|
+
participants: [fullIdentity],
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
test("schema key sets match the schema types", () => {
|
|
242
|
+
expect(entryKeys(WorkspaceSchema)).toEqual(keys(fullWorkspace));
|
|
243
|
+
expect(entryKeys(ArtifactMetaSchema)).toEqual(keys(fullMeta));
|
|
244
|
+
expect(entryKeys(ArtifactSchema)).toEqual(keys(fullArtifact));
|
|
245
|
+
expect(entryKeys(AnchorSchema)).toEqual(keys(fullAnchor));
|
|
246
|
+
// wire annotations arrive without createdAt; the daemon stamps it
|
|
247
|
+
const { createdAt: _stamped, ...wireAnnotation } = fullAnnotation;
|
|
248
|
+
|
|
249
|
+
expect(entryKeys(AnnotationSchema)).toEqual(keys(wireAnnotation));
|
|
250
|
+
expect(entryKeys(RevisionSchema)).toEqual(keys(fullRevision));
|
|
251
|
+
expect(entryKeys(VerdictSchema)).toEqual(keys(fullVerdict));
|
|
252
|
+
expect(entryKeys(IdentitySchema)).toEqual(keys(fullIdentity));
|
|
253
|
+
expect(entryKeys(SessionRecordSchema)).toEqual(keys(fullSession));
|
|
254
|
+
// persisted annotations carry the stamped createdAt
|
|
255
|
+
const stored = SessionRecordSchema.entries.annotations.item;
|
|
256
|
+
|
|
257
|
+
expect(entryKeys(stored)).toEqual(keys(fullAnnotation));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("a fully-populated meta survives validation and DaemonCore unchanged", () => {
|
|
261
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-val-"));
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// Arrange
|
|
265
|
+
const params = parseParams("session.create", {
|
|
266
|
+
workspace: fullWorkspace,
|
|
267
|
+
artifact: { type: "plan", content: "# P", meta: fullMeta },
|
|
268
|
+
});
|
|
269
|
+
const core = new DaemonCore(home);
|
|
270
|
+
|
|
271
|
+
// Act
|
|
272
|
+
const created = core.sessionCreate(params);
|
|
273
|
+
|
|
274
|
+
// Assert
|
|
275
|
+
expect(core.sessionGet(created.id).artifact.meta).toEqual(fullMeta);
|
|
276
|
+
} finally {
|
|
277
|
+
rmSync(home, { recursive: true, force: true });
|
|
278
|
+
}
|
|
97
279
|
});
|
|
98
280
|
});
|