@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.90
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 +17 -17
- package/src/anchor.test.ts +340 -40
- package/src/anchor.ts +378 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +224 -54
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +132 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +401 -0
- package/src/history.ts +425 -0
- package/src/index.ts +9 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +333 -84
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +206 -0
- package/src/path-view.ts +90 -0
- package/src/share-access.test.ts +20 -0
- package/src/share-access.ts +13 -0
- package/src/share-links.test.ts +63 -0
- package/src/share-links.ts +29 -0
- package/src/thread-surface.test.ts +6 -0
- package/src/thread-surface.ts +6 -0
- package/src/types.test.ts +58 -5
- package/src/types.ts +341 -24
- package/src/working-copy.test.ts +209 -24
- package/src/working-copy.ts +148 -12
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendEntry,
|
|
4
|
+
createBranch,
|
|
5
|
+
historyFromLinear,
|
|
6
|
+
navigateTo,
|
|
7
|
+
switchBranch,
|
|
8
|
+
type SessionHistory,
|
|
9
|
+
} from "./history";
|
|
10
|
+
import { applyPathView, viewFollowing, viewOfPath } from "./path-view";
|
|
11
|
+
import type { Annotation, Thread } from "./types";
|
|
12
|
+
|
|
13
|
+
const AT = "2026-09-01T10:00:00.000Z";
|
|
14
|
+
|
|
15
|
+
function annotation(id: string): Annotation {
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
kind: "comment",
|
|
19
|
+
anchor: { quote: "q", prefix: "", suffix: "" },
|
|
20
|
+
body: id,
|
|
21
|
+
createdAt: AT,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** rev1 (agent) -> comment a1 -> reviewer edit -> comment a2, all on main. */
|
|
26
|
+
function threaded(): SessionHistory {
|
|
27
|
+
let history = historyFromLinear({
|
|
28
|
+
id: "ses_1",
|
|
29
|
+
revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
|
|
30
|
+
annotations: [],
|
|
31
|
+
message: null,
|
|
32
|
+
createdAt: AT,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
|
|
36
|
+
history = appendEntry(history, {
|
|
37
|
+
type: "revision",
|
|
38
|
+
by: "reviewer",
|
|
39
|
+
content: "Plan v1 edited",
|
|
40
|
+
textCuts: [{ start: 5, end: 8, quote: "v1 " }],
|
|
41
|
+
createdAt: AT,
|
|
42
|
+
}).history;
|
|
43
|
+
history = appendEntry(history, { type: "comment", annotationId: "a2", createdAt: AT }).history;
|
|
44
|
+
|
|
45
|
+
return history;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("viewOfPath", () => {
|
|
49
|
+
test("the artifact is the agent's last revision and the reviewer's head is the working copy", () => {
|
|
50
|
+
// Act
|
|
51
|
+
const view = viewOfPath(threaded(), [annotation("a1"), annotation("a2")]);
|
|
52
|
+
|
|
53
|
+
// Assert
|
|
54
|
+
expect(view.content).toBe("Plan v1");
|
|
55
|
+
expect(view.workingCopy).toBe("Plan v1 edited");
|
|
56
|
+
expect(view.textCuts).toEqual([{ start: 5, end: 8, quote: "v1 " }]);
|
|
57
|
+
expect(view.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
58
|
+
expect(view.shelvedAnnotations).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("navigating back shelves the comments the path no longer reaches, and forward again unshelves them", () => {
|
|
62
|
+
// Arrange: a branch that stays at the full path, main moved back to the first comment
|
|
63
|
+
let history = createBranch(threaded(), "full");
|
|
64
|
+
const known = [annotation("a1"), annotation("a2")];
|
|
65
|
+
|
|
66
|
+
history = switchBranch(history, "main");
|
|
67
|
+
history = navigateTo(history, history.entries[1]!.id);
|
|
68
|
+
|
|
69
|
+
// Act
|
|
70
|
+
const back = viewOfPath(history, known);
|
|
71
|
+
const forward = viewOfPath(
|
|
72
|
+
switchBranch(history, "full"),
|
|
73
|
+
back.annotations.concat(back.shelvedAnnotations),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
expect(back.workingCopy).toBeUndefined();
|
|
78
|
+
expect(back.textCuts).toBeUndefined();
|
|
79
|
+
expect(back.annotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
80
|
+
expect(back.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a2"]);
|
|
81
|
+
expect(forward.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
82
|
+
expect(forward.textCuts).toEqual([{ start: 5, end: 8, quote: "v1 " }]);
|
|
83
|
+
expect(forward.shelvedAnnotations).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a removed comment stays shelved, not deleted", () => {
|
|
87
|
+
// Arrange
|
|
88
|
+
let history = threaded();
|
|
89
|
+
|
|
90
|
+
history = appendEntry(history, {
|
|
91
|
+
type: "comment-removed",
|
|
92
|
+
annotationId: "a1",
|
|
93
|
+
createdAt: AT,
|
|
94
|
+
}).history;
|
|
95
|
+
|
|
96
|
+
// Act
|
|
97
|
+
const view = viewOfPath(history, [annotation("a1"), annotation("a2")]);
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
100
|
+
expect(view.annotations.map((entry) => entry.id)).toEqual(["a2"]);
|
|
101
|
+
expect(view.shelvedAnnotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("applyPathView", () => {
|
|
106
|
+
test("writes the view into the record and removes the fields the view leaves empty", () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const session: Thread = {
|
|
109
|
+
schemaVersion: "1",
|
|
110
|
+
id: "ses_1",
|
|
111
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
112
|
+
artifact: { type: "plan", content: "old", meta: {} },
|
|
113
|
+
revisions: [],
|
|
114
|
+
annotations: [annotation("a2")],
|
|
115
|
+
shelvedAnnotations: [annotation("a1")],
|
|
116
|
+
workingCopy: "edited",
|
|
117
|
+
message: null,
|
|
118
|
+
status: "pending",
|
|
119
|
+
createdAt: AT,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Act
|
|
123
|
+
applyPathView(session, {
|
|
124
|
+
content: "Plan v1",
|
|
125
|
+
workingCopy: undefined,
|
|
126
|
+
textCuts: undefined,
|
|
127
|
+
annotations: [annotation("a1"), annotation("a2")],
|
|
128
|
+
shelvedAnnotations: [],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Assert
|
|
132
|
+
expect(session.artifact.content).toBe("Plan v1");
|
|
133
|
+
expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "a2"]);
|
|
134
|
+
expect("workingCopy" in session).toBe(false);
|
|
135
|
+
expect("shelvedAnnotations" in session).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("viewFollowing", () => {
|
|
140
|
+
test("a share carries one branch's path: its entries, tip, labels, and open comments, nothing shelved", () => {
|
|
141
|
+
// Arrange: main has a1 and an edit; a branch off a1 has its own comment; the owner stands on the branch
|
|
142
|
+
let history = threaded();
|
|
143
|
+
|
|
144
|
+
history = navigateTo(history, history.entries[1]!.id);
|
|
145
|
+
history = createBranch(history, "alt");
|
|
146
|
+
history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
|
|
147
|
+
const session: Thread = {
|
|
148
|
+
schemaVersion: "1",
|
|
149
|
+
id: "ses_1",
|
|
150
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
151
|
+
artifact: { type: "plan", content: "Plan v1", meta: {} },
|
|
152
|
+
revisions: [],
|
|
153
|
+
annotations: [annotation("a1"), annotation("b1")],
|
|
154
|
+
shelvedAnnotations: [annotation("a2")],
|
|
155
|
+
workingCopy: "Plan v1 edited",
|
|
156
|
+
history,
|
|
157
|
+
message: null,
|
|
158
|
+
status: "pending",
|
|
159
|
+
createdAt: AT,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Act
|
|
163
|
+
const shared = viewFollowing(session);
|
|
164
|
+
|
|
165
|
+
// Assert: main's path only, back at the first comment, no working copy, no shelf, the branch named
|
|
166
|
+
expect(shared.shareBranch).toBe("main");
|
|
167
|
+
expect(shared.history!.tips).toEqual({ main: history.entries[1]!.id });
|
|
168
|
+
expect(shared.history!.entries.map((entry) => entry.type)).toEqual(["revision", "comment"]);
|
|
169
|
+
expect(shared.annotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
170
|
+
expect("shelvedAnnotations" in shared).toBe(false);
|
|
171
|
+
expect("workingCopy" in shared).toBe(false);
|
|
172
|
+
// the owner's record is untouched
|
|
173
|
+
expect(session.annotations.map((entry) => entry.id)).toEqual(["a1", "b1"]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("a followed branch that no longer exists falls back to main", () => {
|
|
177
|
+
// Arrange: a plain main-only history, asked to follow a branch that is not there
|
|
178
|
+
const history = historyFromLinear({
|
|
179
|
+
id: "ses_1",
|
|
180
|
+
revisions: [{ revision: 1, content: "Plan v1", submittedAt: AT }],
|
|
181
|
+
annotations: [],
|
|
182
|
+
message: null,
|
|
183
|
+
createdAt: AT,
|
|
184
|
+
});
|
|
185
|
+
const session: Thread = {
|
|
186
|
+
schemaVersion: "1",
|
|
187
|
+
id: "ses_1",
|
|
188
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
189
|
+
artifact: { type: "plan", content: "Plan v1", meta: {} },
|
|
190
|
+
revisions: [],
|
|
191
|
+
annotations: [],
|
|
192
|
+
shareBranch: "gone",
|
|
193
|
+
history,
|
|
194
|
+
message: null,
|
|
195
|
+
status: "pending",
|
|
196
|
+
createdAt: AT,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Act
|
|
200
|
+
const shared = viewFollowing(session);
|
|
201
|
+
|
|
202
|
+
// Assert: it does not throw, and falls back to main
|
|
203
|
+
expect(shared.shareBranch).toBe("main");
|
|
204
|
+
expect(shared.history!.branch).toBe("main");
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/path-view.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The linear view of a session's active path. Every client reads the artifact
|
|
3
|
+
* text, the working copy, and the comments straight off the record, so the
|
|
4
|
+
* record keeps them materialized; this module recomputes them from the path
|
|
5
|
+
* whenever a tip moves. Comments the path does not reach are shelved, never
|
|
6
|
+
* dropped, so a later move can show them again.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { derivePath, followBranch, MAIN_BRANCH, pathOf, type SessionHistory } from "./history";
|
|
10
|
+
import type { Annotation, Thread, TextCut } from "./types";
|
|
11
|
+
|
|
12
|
+
export interface PathView {
|
|
13
|
+
/** The last agent revision on the path: what the artifact shows. */
|
|
14
|
+
content: string;
|
|
15
|
+
/** The reviewer's edits over it, when the path's head is a reviewer revision. */
|
|
16
|
+
workingCopy: string | undefined;
|
|
17
|
+
/** Exact character Cuts attached to the reviewer revision at the path head. */
|
|
18
|
+
textCuts: TextCut[] | undefined;
|
|
19
|
+
/** The comments open on the path, in path order. */
|
|
20
|
+
annotations: Annotation[];
|
|
21
|
+
/** Every other comment the session knows. */
|
|
22
|
+
shelvedAnnotations: Annotation[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fold the active path over every comment the session holds, on or off the
|
|
27
|
+
* path. Comments on the path whose objects are gone stay gone.
|
|
28
|
+
*/
|
|
29
|
+
export function viewOfPath(history: SessionHistory, known: Annotation[]): PathView {
|
|
30
|
+
const derived = derivePath(history);
|
|
31
|
+
const agentHead = pathOf(history)
|
|
32
|
+
.filter((entry) => entry.type === "revision" && entry.by === "agent")
|
|
33
|
+
.at(-1);
|
|
34
|
+
const content = agentHead?.type === "revision" ? agentHead.content : derived.head.content;
|
|
35
|
+
const byId = new Map(known.map((annotation) => [annotation.id, annotation]));
|
|
36
|
+
const annotations: Annotation[] = [];
|
|
37
|
+
|
|
38
|
+
for (const id of derived.annotationIds) {
|
|
39
|
+
const annotation = byId.get(id);
|
|
40
|
+
|
|
41
|
+
if (annotation) {
|
|
42
|
+
annotations.push(annotation);
|
|
43
|
+
byId.delete(id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content,
|
|
49
|
+
workingCopy: derived.head.content === content ? undefined : derived.head.content,
|
|
50
|
+
textCuts: derived.head.by === "reviewer" ? derived.head.textCuts : undefined,
|
|
51
|
+
annotations,
|
|
52
|
+
shelvedAnnotations: [...byId.values()],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write a path view back into a record, in place; absent fields are removed. */
|
|
57
|
+
export function applyPathView(session: Thread, view: PathView): void {
|
|
58
|
+
session.artifact = { ...session.artifact, content: view.content };
|
|
59
|
+
session.annotations = view.annotations;
|
|
60
|
+
if (view.textCuts?.length) session.textCuts = view.textCuts;
|
|
61
|
+
else delete session.textCuts;
|
|
62
|
+
if (view.workingCopy === undefined) delete session.workingCopy;
|
|
63
|
+
else session.workingCopy = view.workingCopy;
|
|
64
|
+
if (view.shelvedAnnotations.length === 0) delete session.shelvedAnnotations;
|
|
65
|
+
else session.shelvedAnnotations = view.shelvedAnnotations;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The record as a share carries it: the followed branch's path and what it
|
|
70
|
+
* shows, with no other branch, no shelved comment, and no working copy of the
|
|
71
|
+
* owner's. A followed branch that no longer exists falls back to main. A record
|
|
72
|
+
* without a history travels as it is.
|
|
73
|
+
*/
|
|
74
|
+
export function viewFollowing(session: Thread, branch?: string): Thread {
|
|
75
|
+
const asked = branch ?? session.shareBranch ?? MAIN_BRANCH;
|
|
76
|
+
// a branch that no longer exists falls back to main, matching the daemon
|
|
77
|
+
const followed =
|
|
78
|
+
session.history && session.history.tips[asked] === undefined ? MAIN_BRANCH : asked;
|
|
79
|
+
const shared: Thread = { ...session, shareBranch: followed };
|
|
80
|
+
|
|
81
|
+
if (!session.history) return shared;
|
|
82
|
+
const history = followBranch(session.history, followed);
|
|
83
|
+
const known = [...session.annotations, ...(session.shelvedAnnotations ?? [])];
|
|
84
|
+
|
|
85
|
+
shared.history = history;
|
|
86
|
+
applyPathView(shared, viewOfPath(history, known));
|
|
87
|
+
delete shared.shelvedAnnotations;
|
|
88
|
+
|
|
89
|
+
return shared;
|
|
90
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isShareViewerAllowed } from "./share-access";
|
|
3
|
+
|
|
4
|
+
describe("isShareViewerAllowed", () => {
|
|
5
|
+
test("an unauthenticated viewer is refused", () => {
|
|
6
|
+
expect(isShareViewerAllowed({ githubLogins: ["octocat"] }, undefined)).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("a member is admitted, case-insensitively", () => {
|
|
10
|
+
expect(isShareViewerAllowed({ githubLogins: ["OctoCat"] }, "octocat")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("a non-member is refused", () => {
|
|
14
|
+
expect(isShareViewerAllowed({ githubLogins: ["octocat"] }, "hubot")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("an empty allowlist admits no one", () => {
|
|
18
|
+
expect(isShareViewerAllowed({ githubLogins: [] }, "octocat")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Whether a github login may view a private share; membership is case-insensitive and an unauthenticated viewer is never allowed. */
|
|
2
|
+
|
|
3
|
+
import type { ShareAccess } from "./types";
|
|
4
|
+
|
|
5
|
+
export function isShareViewerAllowed(
|
|
6
|
+
access: ShareAccess,
|
|
7
|
+
githubLogin: string | undefined,
|
|
8
|
+
): boolean {
|
|
9
|
+
if (githubLogin === undefined) return false;
|
|
10
|
+
const login = githubLogin.toLowerCase();
|
|
11
|
+
|
|
12
|
+
return access.githubLogins.some((allowed) => allowed.toLowerCase() === login);
|
|
13
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type Thread } from "./types";
|
|
3
|
+
import { threadShareLinks, withShareLinks } from "./share-links";
|
|
4
|
+
|
|
5
|
+
function thread(overrides: Partial<Thread> = {}): Thread {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: SCHEMA_VERSION,
|
|
8
|
+
id: "ses_1",
|
|
9
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
10
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
11
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
12
|
+
annotations: [],
|
|
13
|
+
message: null,
|
|
14
|
+
status: "pending",
|
|
15
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("share-link migration", () => {
|
|
21
|
+
test("a never-shared thread has no links", () => {
|
|
22
|
+
expect(threadShareLinks(thread())).toBeUndefined();
|
|
23
|
+
expect(withShareLinks(thread()).shares).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a legacy private share migrates into a one-element list", () => {
|
|
27
|
+
const legacy = thread({
|
|
28
|
+
shareId: "p_abc123xy",
|
|
29
|
+
access: { githubLogins: ["octocat"] },
|
|
30
|
+
owner: "SHA256:owner",
|
|
31
|
+
shareBranch: "feature",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(withShareLinks(legacy).shares).toEqual([
|
|
35
|
+
{
|
|
36
|
+
id: "p_abc123xy",
|
|
37
|
+
requireAuth: true,
|
|
38
|
+
allowlist: ["octocat"],
|
|
39
|
+
owner: "SHA256:owner",
|
|
40
|
+
shareBranch: "feature",
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a legacy public share migrates as requireAuth false with an empty allowlist", () => {
|
|
46
|
+
const legacy = thread({ shareId: "p_public00", owner: "SHA256:owner" });
|
|
47
|
+
const [link] = withShareLinks(legacy).shares!;
|
|
48
|
+
|
|
49
|
+
expect(link).toMatchObject({ id: "p_public00", requireAuth: false, allowlist: [] });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a thread that already has shares is returned untouched", () => {
|
|
53
|
+
const modern = thread({
|
|
54
|
+
shares: [{ id: "p_modern00", name: "team", requireAuth: false, allowlist: [] }],
|
|
55
|
+
// stale legacy fields must not override the explicit shares
|
|
56
|
+
shareId: "p_legacy00",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(withShareLinks(modern).shares).toEqual([
|
|
60
|
+
{ id: "p_modern00", name: "team", requireAuth: false, allowlist: [] },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ShareLink, Thread } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A thread's share links, migrating a legacy single share (scalar shareId +
|
|
5
|
+
* access + owner + shareBranch) into a one-element list. Undefined when the
|
|
6
|
+
* thread was never shared. Records already carrying `shares` are returned as-is.
|
|
7
|
+
*/
|
|
8
|
+
export function threadShareLinks(session: Thread): ShareLink[] | undefined {
|
|
9
|
+
if (session.shares !== undefined) return session.shares;
|
|
10
|
+
if (session.shareId === undefined) return undefined;
|
|
11
|
+
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
id: session.shareId,
|
|
15
|
+
requireAuth: session.access !== undefined,
|
|
16
|
+
allowlist: session.access?.githubLogins ?? [],
|
|
17
|
+
owner: session.owner,
|
|
18
|
+
shareBranch: session.shareBranch,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The thread with `shares` populated from the legacy fields when absent; a never-shared thread is unchanged. */
|
|
24
|
+
export function withShareLinks(session: Thread): Thread {
|
|
25
|
+
if (session.shares !== undefined) return session;
|
|
26
|
+
const shares = threadShareLinks(session);
|
|
27
|
+
|
|
28
|
+
return shares ? { ...session, shares } : session;
|
|
29
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { manualThreadOpenCommand } from "./thread-surface";
|
|
3
|
+
|
|
4
|
+
test("manual Thread command uses the shared harness wording", () => {
|
|
5
|
+
expect(manualThreadOpenCommand("ses_123")).toBe("Open cueloop threads: cueloop ses_123");
|
|
6
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type ThreadSurfaceOpenStatus = "opened" | "focused" | "disabled" | "unavailable" | "failed";
|
|
2
|
+
|
|
3
|
+
/** Give a harness the same manual command when any terminal integration cannot open. */
|
|
4
|
+
export function manualThreadOpenCommand(threadId: string): string {
|
|
5
|
+
return `Open cueloop threads: cueloop ${threadId}`;
|
|
6
|
+
}
|
package/src/types.test.ts
CHANGED
|
@@ -1,17 +1,70 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
annotationTarget,
|
|
4
|
+
ARTIFACT_TYPES,
|
|
5
|
+
WORKFLOW_KINDS,
|
|
6
|
+
isArtifactType,
|
|
7
|
+
isMarkdownArtifact,
|
|
8
|
+
newAnnotationId,
|
|
9
|
+
messageAllows,
|
|
10
|
+
} from "./types";
|
|
3
11
|
|
|
4
|
-
describe("
|
|
12
|
+
describe("annotationTarget", () => {
|
|
13
|
+
test("an annotation with no target means the reviewed artifact", () => {
|
|
14
|
+
expect(annotationTarget({ target: undefined })).toEqual({ kind: "artifact" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a stamped target is returned as-is", () => {
|
|
18
|
+
const target = { kind: "file", path: "src/x.ts", rev: "worktree" } as const;
|
|
19
|
+
|
|
20
|
+
expect(annotationTarget({ target })).toBe(target);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("ARTIFACT_TYPES", () => {
|
|
25
|
+
test("names every primitive exactly once", () => {
|
|
26
|
+
expect([...ARTIFACT_TYPES].sort()).toEqual(["diff", "plan", "prototype", "reply"]);
|
|
27
|
+
expect(new Set(ARTIFACT_TYPES).size).toBe(ARTIFACT_TYPES.length);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("isArtifactType accepts every member and rejects strangers", () => {
|
|
31
|
+
for (const type of ARTIFACT_TYPES) expect(isArtifactType(type)).toBe(true);
|
|
32
|
+
expect(isArtifactType("blueprint")).toBe(false);
|
|
33
|
+
expect(isArtifactType("")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("WORKFLOW_KINDS", () => {
|
|
38
|
+
test("review and refine are workflows, not artifact types", () => {
|
|
39
|
+
expect(WORKFLOW_KINDS).toEqual(["plan", "reply", "prototype", "diff", "review", "refine"]);
|
|
40
|
+
expect(isArtifactType("review")).toBe(false);
|
|
41
|
+
expect(isArtifactType("refine")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("messageAllows", () => {
|
|
5
46
|
test("only approve maps to allow", () => {
|
|
6
|
-
expect(
|
|
7
|
-
expect(
|
|
8
|
-
expect(
|
|
47
|
+
expect(messageAllows("approved")).toBe(true);
|
|
48
|
+
expect(messageAllows("comment")).toBe(false);
|
|
49
|
+
expect(messageAllows("changes_requested")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("isMarkdownArtifact", () => {
|
|
54
|
+
test("plan, reply, and prototype are markdown; diff is not", () => {
|
|
55
|
+
expect(isMarkdownArtifact("plan")).toBe(true);
|
|
56
|
+
expect(isMarkdownArtifact("reply")).toBe(true);
|
|
57
|
+
expect(isMarkdownArtifact("prototype")).toBe(true);
|
|
58
|
+
expect(isMarkdownArtifact("diff")).toBe(false);
|
|
9
59
|
});
|
|
10
60
|
});
|
|
11
61
|
|
|
12
62
|
describe("newAnnotationId", () => {
|
|
13
63
|
test("unique by construction within a process, even on one millisecond", () => {
|
|
64
|
+
// Act
|
|
14
65
|
const ids = new Set(Array.from({ length: 10_000 }, () => newAnnotationId()));
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
15
68
|
expect(ids.size).toBe(10_000);
|
|
16
69
|
for (const id of ids) expect(id).toMatch(/^a_[0-9a-z]+_[0-9a-z]+$/);
|
|
17
70
|
});
|