cueloop 0.1.0-alpha.23 → 0.1.0-alpha.25
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 +4 -4
- package/src/main.ts +20 -13
- package/src/share-command.test.ts +96 -4
- package/src/share-command.ts +49 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cueloop",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.25",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
".": "./src/main.ts"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
15
|
-
"@cueloop/client": "0.1.0-alpha.
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.25",
|
|
14
|
+
"@cueloop/daemon": "0.1.0-alpha.25",
|
|
15
|
+
"@cueloop/client": "0.1.0-alpha.25"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
package/src/main.ts
CHANGED
|
@@ -74,13 +74,11 @@ async function main(): Promise<number> {
|
|
|
74
74
|
}
|
|
75
75
|
case "share": {
|
|
76
76
|
const { positional, flags } = parseArgs(argv.slice(1));
|
|
77
|
-
const { shareCommand } = await import("./share-command");
|
|
77
|
+
const { shareCommand, sharePullCommand } = await import("./share-command");
|
|
78
78
|
const port = stringFlag(flags, "port");
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
port: port !== undefined ? Number(port) : undefined,
|
|
83
|
-
});
|
|
79
|
+
const target = { host: stringFlag(flags, "host"), port: port !== undefined ? Number(port) : undefined };
|
|
80
|
+
if (positional[0] === "pull") return sharePullCommand({ ...target, sessionId: positional[1] });
|
|
81
|
+
return shareCommand({ ...target, sessionId: positional[0] });
|
|
84
82
|
}
|
|
85
83
|
case "review":
|
|
86
84
|
return reviewEntry(argv.slice(1));
|
|
@@ -210,22 +208,31 @@ function printHelp(): void {
|
|
|
210
208
|
[
|
|
211
209
|
"cueloop - review surface for coding agents",
|
|
212
210
|
"",
|
|
213
|
-
"usage:",
|
|
211
|
+
"usage: cueloop [command] [options]",
|
|
212
|
+
"",
|
|
213
|
+
"common commands:",
|
|
214
214
|
" cueloop open the inbox",
|
|
215
|
-
" cueloop <session-id> open one session",
|
|
216
215
|
" cueloop plan [id|title] open the latest pending plan review (or one by id/title)",
|
|
217
|
-
" cueloop diff [id|title]
|
|
218
|
-
" with a
|
|
219
|
-
" cueloop review [id|title] open the latest pending PR review (or one by id/title)",
|
|
216
|
+
" cueloop diff [id|title] review your working tree (untracked files included);",
|
|
217
|
+
" with a clean tree, open the latest pending diff review",
|
|
220
218
|
" cueloop review <pr> review a pull request (--no-tui prints the session)",
|
|
221
|
-
"
|
|
222
|
-
"
|
|
219
|
+
"",
|
|
220
|
+
"share:",
|
|
223
221
|
" cueloop serve [session-id] share over ssh: observers are read-only,",
|
|
224
222
|
" you stay the controller (--port 2222, --host 127.0.0.1;",
|
|
225
223
|
" password-less - share the address deliberately)",
|
|
226
224
|
" cueloop share [session-id] hand a plan to a teammate: copies one",
|
|
227
225
|
" ssh line (--host cueloop.dev, --port 22)",
|
|
226
|
+
" cueloop share pull [session-id] pull a teammate's annotations back into the plan",
|
|
227
|
+
"",
|
|
228
|
+
"open a specific review:",
|
|
229
|
+
" cueloop <session-id> open one session",
|
|
230
|
+
" cueloop review [id|title] open the latest pending PR review (or one by id/title)",
|
|
231
|
+
" cueloop <plan|diff|review> --latest open the latest pending review of that type",
|
|
232
|
+
"",
|
|
233
|
+
"scripting:",
|
|
228
234
|
" cueloop session <verb> [flags] script the daemon (create|get|list|wait|annotate|resolve|submit-revision)",
|
|
235
|
+
" cueloop review-post <id> <pr> post a resolved session's verdict back to the PR",
|
|
229
236
|
" cueloop daemon run the daemon in the foreground",
|
|
230
237
|
].join("\n"),
|
|
231
238
|
);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { SCHEMA_VERSION, type ReviewSession } from "@cueloop/schema";
|
|
2
|
+
import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
3
3
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
|
-
import { shareSession, type ShareDeps } from "./share-command";
|
|
4
|
+
import { pullSession, shareSession, type PullDeps, type ShareDeps } from "./share-command";
|
|
5
5
|
|
|
6
|
-
function sessionFixture(id: string): ReviewSession {
|
|
6
|
+
function sessionFixture(id: string, overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
7
7
|
return {
|
|
8
8
|
schemaVersion: SCHEMA_VERSION,
|
|
9
9
|
id,
|
|
@@ -14,14 +14,31 @@ function sessionFixture(id: string): ReviewSession {
|
|
|
14
14
|
verdict: null,
|
|
15
15
|
status: "pending",
|
|
16
16
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
17
|
+
...overrides,
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
function annotationFixture(id: string, author?: string): Annotation {
|
|
22
|
+
const base: Annotation = { id, kind: "comment", anchor: { quote: "Plan", prefix: "", suffix: "" }, body: "note", createdAt: "2026-01-01T00:00:00.000Z" };
|
|
23
|
+
return author ? { ...base, author } : base;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A SessionClient that answers get/list from a fixed list and records the share/merge verbs. */
|
|
21
27
|
function fakeClient(sessions: ReviewSession[]): SessionClient {
|
|
22
28
|
return {
|
|
23
29
|
sessionGet: async (id: string) => sessions.find((session) => session.id === id)!,
|
|
24
30
|
sessionList: async () => sessions,
|
|
31
|
+
sessionSetShareId: mock(async (id: string, shareId: string) => {
|
|
32
|
+
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
33
|
+
session.shareId = shareId;
|
|
34
|
+
return session;
|
|
35
|
+
}),
|
|
36
|
+
sessionMergeAnnotations: mock(async (id: string, incoming: Annotation[]) => {
|
|
37
|
+
const session = sessions.find((candidate) => candidate.id === id)!;
|
|
38
|
+
const known = new Set(session.annotations.map((annotation) => annotation.id));
|
|
39
|
+
for (const annotation of incoming) if (!known.has(annotation.id)) session.annotations.push(annotation);
|
|
40
|
+
return session;
|
|
41
|
+
}),
|
|
25
42
|
} as unknown as SessionClient;
|
|
26
43
|
}
|
|
27
44
|
|
|
@@ -84,4 +101,79 @@ describe(shareSession, () => {
|
|
|
84
101
|
expect(deps.lines[0]).toContain("no plan to share");
|
|
85
102
|
expect(deps.publish).not.toHaveBeenCalled();
|
|
86
103
|
});
|
|
104
|
+
|
|
105
|
+
test("stamps the share id on the session so a later pull can find it", async () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
const session = sessionFixture("ses_1");
|
|
108
|
+
const client = fakeClient([session]);
|
|
109
|
+
const deps = depsSpy();
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
await shareSession(client, { sessionId: "ses_1" }, deps);
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function pullDepsSpy(remote: ReviewSession): PullDeps & { lines: string[] } {
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
return { pull: mock(async () => remote), out: (message: string) => void lines.push(message), lines };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe(pullSession, () => {
|
|
125
|
+
test("unions collaborator notes in, ignoring the planner's own unauthored notes", async () => {
|
|
126
|
+
// Arrange - the planner's own note carries no author; the collaborator's does
|
|
127
|
+
const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [annotationFixture("mine")] });
|
|
128
|
+
const client = fakeClient([local]);
|
|
129
|
+
const remote = sessionFixture("ses_1", { annotations: [annotationFixture("mine"), annotationFixture("theirs", "SHA256:mate")] });
|
|
130
|
+
const deps = pullDepsSpy(remote);
|
|
131
|
+
|
|
132
|
+
// Act
|
|
133
|
+
const code = await pullSession(client, { sessionId: "ses_1" }, deps);
|
|
134
|
+
|
|
135
|
+
// Assert
|
|
136
|
+
expect(code).toBe(0);
|
|
137
|
+
expect(deps.pull).toHaveBeenCalledWith("p_abc123xy", { host: undefined, port: undefined });
|
|
138
|
+
expect(local.annotations.map((annotation) => annotation.id)).toEqual(["mine", "theirs"]);
|
|
139
|
+
expect(deps.lines).toEqual(["pulled 1 new annotation"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("does not resurrect a note the planner deleted locally after sharing", async () => {
|
|
143
|
+
// Arrange - the planner deleted "mine" locally; the share blob still carries it, unauthored
|
|
144
|
+
const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [] });
|
|
145
|
+
const deps = pullDepsSpy(sessionFixture("ses_1", { annotations: [annotationFixture("mine")] }));
|
|
146
|
+
|
|
147
|
+
// Act
|
|
148
|
+
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
151
|
+
expect(local.annotations).toEqual([]);
|
|
152
|
+
expect(deps.lines).toEqual(["no new annotations"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("reports when nothing new came back", async () => {
|
|
156
|
+
// Arrange
|
|
157
|
+
const local = sessionFixture("ses_1", { shareId: "p_abc123xy", annotations: [annotationFixture("theirs", "SHA256:mate")] });
|
|
158
|
+
const deps = pullDepsSpy(sessionFixture("ses_1", { annotations: [annotationFixture("theirs", "SHA256:mate")] }));
|
|
159
|
+
|
|
160
|
+
// Act
|
|
161
|
+
await pullSession(fakeClient([local]), { sessionId: "ses_1" }, deps);
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
expect(deps.lines).toEqual(["no new annotations"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("refuses to pull a plan that was never shared", async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const deps = pullDepsSpy(sessionFixture("ses_1"));
|
|
170
|
+
|
|
171
|
+
// Act
|
|
172
|
+
const code = await pullSession(fakeClient([sessionFixture("ses_1")]), { sessionId: "ses_1" }, deps);
|
|
173
|
+
|
|
174
|
+
// Assert
|
|
175
|
+
expect(code).toBe(1);
|
|
176
|
+
expect(deps.lines[0]).toContain("no shared plan");
|
|
177
|
+
expect(deps.pull).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
87
179
|
});
|
package/src/share-command.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { DaemonClient, type SessionClient } from "@cueloop/daemon/client";
|
|
10
|
-
import { publishShare, type ShareResult, type ShareTarget } from "@cueloop/client";
|
|
10
|
+
import { collaboratorAnnotations, publishShare, pullShare, shareIdFromLine, type ShareResult, type ShareTarget } from "@cueloop/client";
|
|
11
11
|
import type { ReviewSession } from "@cueloop/schema";
|
|
12
12
|
|
|
13
13
|
export interface ShareParams {
|
|
@@ -22,7 +22,13 @@ export interface ShareDeps {
|
|
|
22
22
|
out: (message: string) => void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface PullDeps {
|
|
26
|
+
pull: (shareId: string, target: ShareTarget) => Promise<ReviewSession>;
|
|
27
|
+
out: (message: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
const defaultDeps: ShareDeps = { publish: publishShare, out: (message) => console.log(message) };
|
|
31
|
+
const defaultPullDeps: PullDeps = { pull: pullShare, out: (message) => console.log(message) };
|
|
26
32
|
|
|
27
33
|
/** Connect to the local daemon, then share the chosen session. */
|
|
28
34
|
export async function shareCommand(params: ShareParams, deps: ShareDeps = defaultDeps): Promise<number> {
|
|
@@ -34,7 +40,17 @@ export async function shareCommand(params: ShareParams, deps: ShareDeps = defaul
|
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
/**
|
|
43
|
+
/** Connect to the local daemon, then pull collaborator notes for the plan. */
|
|
44
|
+
export async function sharePullCommand(params: ShareParams, deps: PullDeps = defaultPullDeps): Promise<number> {
|
|
45
|
+
const client = await DaemonClient.connect({ home: params.home, autostart: true });
|
|
46
|
+
try {
|
|
47
|
+
return await pullSession(client, params, deps);
|
|
48
|
+
} finally {
|
|
49
|
+
client.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The share orchestration, over any SessionClient - the seam the tests drive. */
|
|
38
54
|
export async function shareSession(client: SessionClient, params: ShareParams, deps: ShareDeps): Promise<number> {
|
|
39
55
|
const session = await pickSession(client, params.sessionId);
|
|
40
56
|
if (!session) {
|
|
@@ -42,13 +58,44 @@ export async function shareSession(client: SessionClient, params: ShareParams, d
|
|
|
42
58
|
return 1;
|
|
43
59
|
}
|
|
44
60
|
const { line, copied } = await deps.publish(session, { host: params.host, port: params.port });
|
|
61
|
+
const shareId = shareIdFromLine(line);
|
|
62
|
+
if (shareId) await client.sessionSetShareId(session.id, shareId);
|
|
45
63
|
deps.out(copied ? `share link copied - ${line}` : line);
|
|
46
64
|
return 0;
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Pull the share's current session and union its annotations into the local
|
|
69
|
+
* plan. Union-by-id keeps the planner's own notes; only collaborators' new
|
|
70
|
+
* ones land. The gateway lets only the fingerprint that shared it pull.
|
|
71
|
+
*/
|
|
72
|
+
export async function pullSession(client: SessionClient, params: ShareParams, deps: PullDeps): Promise<number> {
|
|
73
|
+
const session = await pickSharedSession(client, params.sessionId);
|
|
74
|
+
if (!session) {
|
|
75
|
+
deps.out("no shared plan to pull - run `cueloop share` first");
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
const remote = await deps.pull(session.shareId!, { host: params.host, port: params.port });
|
|
79
|
+
const before = session.annotations.length;
|
|
80
|
+
const merged = await client.sessionMergeAnnotations(session.id, collaboratorAnnotations(remote));
|
|
81
|
+
const added = merged.annotations.length - before;
|
|
82
|
+
deps.out(added > 0 ? `pulled ${added} new annotation${added === 1 ? "" : "s"}` : "no new annotations");
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
/** The named session, or the most recent one when no id is given. */
|
|
50
87
|
async function pickSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
|
|
51
88
|
if (sessionId) return client.sessionGet(sessionId);
|
|
52
89
|
const sessions = await client.sessionList();
|
|
53
90
|
return sessions.at(-1) ?? null;
|
|
54
91
|
}
|
|
92
|
+
|
|
93
|
+
/** The named session (if shared), or the most recent shared one. */
|
|
94
|
+
async function pickSharedSession(client: SessionClient, sessionId?: string): Promise<ReviewSession | null> {
|
|
95
|
+
if (sessionId) {
|
|
96
|
+
const session = await client.sessionGet(sessionId);
|
|
97
|
+
return session.shareId ? session : null;
|
|
98
|
+
}
|
|
99
|
+
const sessions = await client.sessionList();
|
|
100
|
+
return sessions.filter((session) => session.shareId).at(-1) ?? null;
|
|
101
|
+
}
|