@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
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,61 @@
|
|
|
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 DiffFileContents,
|
|
20
|
+
type Identity,
|
|
21
|
+
type ReviewSession,
|
|
22
|
+
type Revision,
|
|
23
|
+
type Verdict,
|
|
24
|
+
type WorkspaceKey,
|
|
25
|
+
} from "@cueloop/schema";
|
|
14
26
|
import { DaemonError } from "./errors";
|
|
15
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Drift guard for every hand-mirrored shape below. v.object strips keys it
|
|
30
|
+
* does not know, so a schema missing a field silently drops that field on
|
|
31
|
+
* the way into DaemonCore. Each entries object is checked with
|
|
32
|
+
* `satisfies EntriesOf<T>`: adding a field to the schema types without
|
|
33
|
+
* mirroring it here (or mirroring a key that does not exist) fails typecheck.
|
|
34
|
+
*/
|
|
35
|
+
type EntriesOf<T> = { [K in keyof T]-?: v.GenericSchema<any, any> };
|
|
36
|
+
|
|
16
37
|
const NonEmpty = v.pipe(v.string(), v.minLength(1));
|
|
17
38
|
|
|
18
39
|
export const WorkspaceSchema = v.object({
|
|
19
40
|
repoRoot: NonEmpty,
|
|
20
41
|
branch: NonEmpty,
|
|
21
|
-
});
|
|
42
|
+
} satisfies EntriesOf<WorkspaceKey>);
|
|
43
|
+
|
|
44
|
+
export const ArtifactMetaSchema = v.object({
|
|
45
|
+
cwd: v.optional(v.string()),
|
|
46
|
+
agent: v.optional(v.string()),
|
|
47
|
+
agentSessionId: v.optional(v.string()),
|
|
48
|
+
planPath: v.optional(v.string()),
|
|
49
|
+
prototypePath: v.optional(v.string()),
|
|
50
|
+
pr: v.optional(v.string()),
|
|
51
|
+
herdrPane: v.optional(v.string()),
|
|
52
|
+
title: v.optional(v.string()),
|
|
53
|
+
} satisfies EntriesOf<ArtifactMeta>);
|
|
54
|
+
|
|
55
|
+
export const DiffFileContentsSchema = v.object({
|
|
56
|
+
path: NonEmpty,
|
|
57
|
+
oldContents: v.string(),
|
|
58
|
+
newContents: v.string(),
|
|
59
|
+
status: v.picklist(["added", "modified", "deleted"]),
|
|
60
|
+
} satisfies EntriesOf<DiffFileContents>);
|
|
22
61
|
|
|
23
62
|
export const ArtifactSchema = v.object({
|
|
24
|
-
type: v.picklist(["plan", "diff"]),
|
|
63
|
+
type: v.picklist(["plan", "diff", "prototype", "reply"]),
|
|
25
64
|
content: v.string(),
|
|
26
|
-
meta: v.optional(
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
});
|
|
65
|
+
meta: v.optional(ArtifactMetaSchema, {}),
|
|
66
|
+
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
67
|
+
} satisfies EntriesOf<Artifact>);
|
|
38
68
|
|
|
39
69
|
export const AnchorSchema = v.object({
|
|
40
70
|
quote: v.string(),
|
|
@@ -43,19 +73,41 @@ export const AnchorSchema = v.object({
|
|
|
43
73
|
blockIndex: v.optional(v.number()),
|
|
44
74
|
start: v.optional(v.number()),
|
|
45
75
|
end: v.optional(v.number()),
|
|
46
|
-
|
|
76
|
+
selector: v.optional(v.string()),
|
|
77
|
+
} satisfies EntriesOf<Anchor>);
|
|
47
78
|
|
|
79
|
+
/** Wire annotations arrive without createdAt - the daemon stamps it. */
|
|
48
80
|
export const AnnotationSchema = v.object({
|
|
49
81
|
id: NonEmpty,
|
|
50
|
-
/** Open kind set
|
|
82
|
+
/** Open kind set: the built-in is comment. */
|
|
51
83
|
kind: NonEmpty,
|
|
52
84
|
anchor: AnchorSchema,
|
|
53
85
|
body: v.string(),
|
|
54
86
|
orphan: v.optional(v.boolean()),
|
|
55
|
-
|
|
87
|
+
author: v.optional(v.string()),
|
|
88
|
+
resolution: v.optional(
|
|
89
|
+
v.object({
|
|
90
|
+
revision: v.number(),
|
|
91
|
+
source: v.picklist(["agent", "drift"]),
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
} satisfies EntriesOf<Omit<Annotation, "createdAt">>);
|
|
56
95
|
|
|
57
96
|
const SessionId = NonEmpty;
|
|
58
97
|
|
|
98
|
+
/** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
|
|
99
|
+
export const FullAnnotationSchema = v.object({
|
|
100
|
+
...AnnotationSchema.entries,
|
|
101
|
+
createdAt: v.string(),
|
|
102
|
+
} satisfies EntriesOf<Annotation>);
|
|
103
|
+
|
|
104
|
+
export const IdentitySchema = v.object({
|
|
105
|
+
id: NonEmpty,
|
|
106
|
+
provider: v.literal("ssh"),
|
|
107
|
+
name: v.optional(v.string()),
|
|
108
|
+
handle: v.optional(v.string()),
|
|
109
|
+
} satisfies EntriesOf<Identity>);
|
|
110
|
+
|
|
59
111
|
export const Params = {
|
|
60
112
|
"session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
|
|
61
113
|
"session.get": v.object({ id: SessionId }),
|
|
@@ -70,20 +122,45 @@ export const Params = {
|
|
|
70
122
|
"session.wait": v.object({
|
|
71
123
|
id: SessionId,
|
|
72
124
|
// clamped: a negative or absurd timeout is a client bug, not a daemon one
|
|
73
|
-
timeoutMs: v.optional(
|
|
125
|
+
timeoutMs: v.optional(
|
|
126
|
+
v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
|
|
127
|
+
60_000,
|
|
128
|
+
),
|
|
129
|
+
}),
|
|
130
|
+
"session.annotate": v.object({
|
|
131
|
+
id: SessionId,
|
|
132
|
+
annotation: AnnotationSchema,
|
|
133
|
+
authorName: v.optional(v.string()),
|
|
74
134
|
}),
|
|
75
|
-
"session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
|
|
76
135
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
77
136
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
137
|
+
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
138
|
+
"session.refreshDiff": v.object({ id: SessionId }),
|
|
139
|
+
"session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
|
|
140
|
+
"session.delete": v.object({ id: SessionId }),
|
|
141
|
+
"session.mergeShared": v.object({
|
|
142
|
+
id: SessionId,
|
|
143
|
+
annotations: v.array(FullAnnotationSchema),
|
|
144
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
145
|
+
}),
|
|
78
146
|
"session.resolve": v.object({
|
|
79
147
|
id: SessionId,
|
|
80
148
|
verdictKind: v.picklist(["comment", "approve", "request_changes"]),
|
|
81
149
|
summary: v.optional(v.string(), ""),
|
|
82
150
|
}),
|
|
83
|
-
"session.submitRevision": v.object({
|
|
151
|
+
"session.submitRevision": v.object({
|
|
152
|
+
id: SessionId,
|
|
153
|
+
content: v.string(),
|
|
154
|
+
/** Annotation ids the agent acted on; each is marked addressed. */
|
|
155
|
+
addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
|
|
156
|
+
}),
|
|
84
157
|
"events.subscribe": v.object({}),
|
|
85
158
|
"daemon.ping": v.object({}),
|
|
159
|
+
"daemon.hello": v.object({ role: v.picklist(["owner", "collaborator", "agent"]) }),
|
|
86
160
|
"daemon.shutdown": v.object({}),
|
|
161
|
+
// herdr adapter scratch: the review's opened tab, kept off the session record.
|
|
162
|
+
"herdr.getTab": v.object({ id: SessionId }),
|
|
163
|
+
"herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
|
|
87
164
|
} as const;
|
|
88
165
|
|
|
89
166
|
export type MethodName = keyof typeof Params;
|
|
@@ -93,41 +170,64 @@ export function isKnownMethod(method: string): method is MethodName {
|
|
|
93
170
|
}
|
|
94
171
|
|
|
95
172
|
/** Validate params for a method, or throw a DaemonError the client can read. */
|
|
96
|
-
export function parseParams<M extends MethodName>(
|
|
173
|
+
export function parseParams<M extends MethodName>(
|
|
174
|
+
method: M,
|
|
175
|
+
params: unknown,
|
|
176
|
+
): v.InferOutput<(typeof Params)[M]> {
|
|
97
177
|
const result = v.safeParse(Params[method], params ?? {});
|
|
178
|
+
|
|
98
179
|
if (!result.success) {
|
|
99
180
|
const issue = result.issues[0]!;
|
|
100
|
-
const path = issue.path?.map((
|
|
101
|
-
|
|
181
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
182
|
+
|
|
183
|
+
throw new DaemonError(
|
|
184
|
+
"invalid_params",
|
|
185
|
+
`${method}: ${path ? path + " - " : ""}${issue.message}`,
|
|
186
|
+
);
|
|
102
187
|
}
|
|
188
|
+
|
|
103
189
|
return result.output;
|
|
104
190
|
}
|
|
105
191
|
|
|
192
|
+
export const RevisionSchema = v.object({
|
|
193
|
+
revision: v.number(),
|
|
194
|
+
content: v.string(),
|
|
195
|
+
submittedAt: v.string(),
|
|
196
|
+
} satisfies EntriesOf<Revision>);
|
|
197
|
+
|
|
198
|
+
export const VerdictSchema = v.object({
|
|
199
|
+
kind: v.picklist(["comment", "approve", "request_changes"]),
|
|
200
|
+
summary: v.string(),
|
|
201
|
+
feedback: v.string(),
|
|
202
|
+
resolvedAt: v.string(),
|
|
203
|
+
} satisfies EntriesOf<Verdict>);
|
|
204
|
+
|
|
106
205
|
/** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
|
|
107
206
|
export const SessionRecordSchema = v.object({
|
|
108
207
|
schemaVersion: v.literal(SCHEMA_VERSION),
|
|
109
208
|
id: NonEmpty,
|
|
110
209
|
workspace: WorkspaceSchema,
|
|
111
210
|
artifact: ArtifactSchema,
|
|
112
|
-
revisions: v.array(
|
|
113
|
-
annotations: v.array(
|
|
211
|
+
revisions: v.array(RevisionSchema),
|
|
212
|
+
annotations: v.array(FullAnnotationSchema),
|
|
114
213
|
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
|
-
),
|
|
214
|
+
viewedPaths: v.optional(v.array(v.string())),
|
|
215
|
+
verdict: v.nullable(VerdictSchema),
|
|
123
216
|
status: v.picklist(["pending", "resolved"]),
|
|
124
217
|
createdAt: v.string(),
|
|
125
|
-
|
|
218
|
+
shareId: v.optional(v.string()),
|
|
219
|
+
owner: v.optional(v.string()),
|
|
220
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
221
|
+
} satisfies EntriesOf<ReviewSession>);
|
|
126
222
|
|
|
127
|
-
export function validateSessionRecord(
|
|
223
|
+
export function validateSessionRecord(
|
|
224
|
+
raw: unknown,
|
|
225
|
+
): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
128
226
|
const result = v.safeParse(SessionRecordSchema, raw);
|
|
227
|
+
|
|
129
228
|
if (result.success) return { ok: true, value: result.output };
|
|
130
229
|
const issue = result.issues[0]!;
|
|
131
|
-
const path = issue.path?.map((
|
|
230
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
231
|
+
|
|
132
232
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
133
233
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
5
|
+
|
|
6
|
+
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
7
|
+
const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
8
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
9
|
+
|
|
10
|
+
if ((await gitProcess.exited) !== 0) return null;
|
|
11
|
+
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
|
|
16
|
+
async function headContents(path: string, cwd: string): Promise<string> {
|
|
17
|
+
const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
|
|
18
|
+
cwd,
|
|
19
|
+
stdout: "pipe",
|
|
20
|
+
stderr: "ignore",
|
|
21
|
+
});
|
|
22
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
23
|
+
|
|
24
|
+
return (await gitProcess.exited) === 0 ? stdout : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The working-tree file, or "" when it is gone (deleted). */
|
|
28
|
+
async function workingContents(path: string, cwd: string): Promise<string> {
|
|
29
|
+
try {
|
|
30
|
+
return await Bun.file(join(cwd, path)).text();
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A NUL byte marks content git would treat as binary - not text-curatable. */
|
|
37
|
+
function looksBinary(text: string): boolean {
|
|
38
|
+
for (let index = 0; index < text.length; index++) {
|
|
39
|
+
if (text.charCodeAt(index) === 0) return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Full old/new contents for every tracked change against HEAD, classified by
|
|
47
|
+
* git's own status letter. Renames/copies and binary files are omitted: a
|
|
48
|
+
* path+text pair cannot express a rename header or a binary blob, so curation
|
|
49
|
+
* would emit a patch that drops the old path or corrupts the file - leaving them
|
|
50
|
+
* out keeps those changes in the patch but simply not curatable. The
|
|
51
|
+
* NUL-delimited stream survives paths with spaces or unicode.
|
|
52
|
+
*/
|
|
53
|
+
async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
54
|
+
const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
|
|
55
|
+
const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
|
|
56
|
+
const files: DiffFileContents[] = [];
|
|
57
|
+
let cursor = 0;
|
|
58
|
+
|
|
59
|
+
while (cursor < tokens.length) {
|
|
60
|
+
const code = tokens[cursor++]![0];
|
|
61
|
+
|
|
62
|
+
if (code === "R" || code === "C") {
|
|
63
|
+
cursor += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const path = tokens[cursor++]!;
|
|
67
|
+
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
68
|
+
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
69
|
+
|
|
70
|
+
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
71
|
+
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
72
|
+
|
|
73
|
+
files.push({ path, oldContents, newContents, status });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface WorkingTreeDiff {
|
|
80
|
+
patch: string;
|
|
81
|
+
/** Full file contents per curatable changed file, keeping curation applyable. */
|
|
82
|
+
files: DiffFileContents[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
|
|
86
|
+
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
87
|
+
const files = await trackedFileContents(cwd);
|
|
88
|
+
// untracked files included by default
|
|
89
|
+
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
90
|
+
let untracked = "";
|
|
91
|
+
|
|
92
|
+
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
93
|
+
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
94
|
+
const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
95
|
+
cwd,
|
|
96
|
+
stdout: "pipe",
|
|
97
|
+
stderr: "ignore",
|
|
98
|
+
});
|
|
99
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
100
|
+
const code = await gitProcess.exited;
|
|
101
|
+
|
|
102
|
+
if ((code === 0 || code === 1) && stdout.trim()) {
|
|
103
|
+
untracked += stdout.trim() + "\n";
|
|
104
|
+
const newContents = await workingContents(file, cwd);
|
|
105
|
+
|
|
106
|
+
if (!looksBinary(newContents)) {
|
|
107
|
+
files.push({ path: file, oldContents: "", newContents, status: "added" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
|
|
113
|
+
}
|