@cueloop/daemon 0.1.0-alpha.8 → 0.1.0-alpha.80
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 +34 -22
- package/src/api.test.ts +925 -65
- package/src/api.ts +1060 -88
- package/src/capabilities.test.ts +57 -0
- package/src/capabilities.ts +84 -0
- package/src/client.test.ts +24 -0
- package/src/client.ts +526 -70
- package/src/curate.ts +109 -0
- package/src/daemon-version.test.ts +93 -0
- package/src/diff-hot-reload.test.ts +272 -0
- package/src/diff-watcher.ts +274 -0
- package/src/gh.ts +37 -0
- package/src/harness-delivery.test.ts +193 -0
- package/src/harness-state-store.test.ts +65 -0
- package/src/harness-state-store.ts +182 -0
- package/src/herdr-pane.test.ts +240 -0
- package/src/herdr-pane.ts +167 -0
- package/src/herdr-split.test.ts +106 -0
- package/src/herdr-split.ts +92 -0
- package/src/herdr-tab-store.test.ts +96 -0
- package/src/herdr-tab-store.ts +66 -0
- package/src/index.ts +8 -9
- package/src/interruptible-wait.test.ts +127 -0
- package/src/interruptible-wait.ts +66 -0
- package/src/main.ts +4 -1
- package/src/paths.ts +44 -0
- package/src/pr-poller.test.ts +76 -0
- package/src/pr-poller.ts +74 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/protocol.ts +79 -7
- package/src/retention.test.ts +191 -0
- package/src/retention.ts +123 -0
- package/src/server.test.ts +430 -42
- package/src/server.ts +362 -84
- package/src/share-blob.test.ts +112 -0
- package/src/share-blob.ts +55 -0
- package/src/single-instance.test.ts +72 -11
- package/src/store.conformance.test.ts +41 -0
- package/src/store.jsonl.test.ts +159 -0
- package/src/store.ts +264 -34
- package/src/testing/store-conformance.ts +194 -0
- package/src/thread-review.test.ts +394 -0
- package/src/thread-review.ts +324 -0
- package/src/validate.test.ts +214 -35
- package/src/validate.ts +296 -25
- package/src/version.ts +6 -0
- package/src/working-tree.ts +149 -0
- package/src/review.test.ts +0 -167
- package/src/review.ts +0 -174
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
|
|
@@ -11,17 +11,31 @@
|
|
|
11
11
|
|
|
12
12
|
import * as v from "valibot";
|
|
13
13
|
import {
|
|
14
|
+
ARTIFACT_TYPES,
|
|
15
|
+
WORKFLOW_KINDS,
|
|
14
16
|
SCHEMA_VERSION,
|
|
15
17
|
type Anchor,
|
|
16
18
|
type Annotation,
|
|
19
|
+
type AnnotationTarget,
|
|
17
20
|
type Artifact,
|
|
18
21
|
type ArtifactMeta,
|
|
19
|
-
type
|
|
22
|
+
type DiffFileContents,
|
|
23
|
+
type Identity,
|
|
24
|
+
type Thread,
|
|
25
|
+
type HunkRejection,
|
|
26
|
+
type HarnessBinding,
|
|
27
|
+
type Delivery,
|
|
28
|
+
type PendingDelivery,
|
|
20
29
|
type Revision,
|
|
21
|
-
type
|
|
30
|
+
type SessionHistory,
|
|
31
|
+
type ShareAccess,
|
|
32
|
+
type ShareLink,
|
|
33
|
+
validateHistory,
|
|
34
|
+
type Message,
|
|
22
35
|
type WorkspaceKey,
|
|
23
36
|
} from "@cueloop/schema";
|
|
24
37
|
import { DaemonError } from "./errors";
|
|
38
|
+
import type { Request } from "./protocol";
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
41
|
* Drift guard for every hand-mirrored shape below. v.object strips keys it
|
|
@@ -37,22 +51,38 @@ const NonEmpty = v.pipe(v.string(), v.minLength(1));
|
|
|
37
51
|
export const WorkspaceSchema = v.object({
|
|
38
52
|
repoRoot: NonEmpty,
|
|
39
53
|
branch: NonEmpty,
|
|
54
|
+
rootCommit: v.optional(NonEmpty),
|
|
55
|
+
remote: v.optional(NonEmpty),
|
|
40
56
|
} satisfies EntriesOf<WorkspaceKey>);
|
|
41
57
|
|
|
42
58
|
export const ArtifactMetaSchema = v.object({
|
|
59
|
+
workflow: v.optional(v.picklist(WORKFLOW_KINDS)),
|
|
43
60
|
cwd: v.optional(v.string()),
|
|
44
61
|
agent: v.optional(v.string()),
|
|
45
62
|
agentSessionId: v.optional(v.string()),
|
|
46
63
|
planPath: v.optional(v.string()),
|
|
64
|
+
prototypePath: v.optional(v.string()),
|
|
47
65
|
pr: v.optional(v.string()),
|
|
48
66
|
herdrPane: v.optional(v.string()),
|
|
49
67
|
title: v.optional(v.string()),
|
|
68
|
+
workbench: v.optional(v.boolean()),
|
|
69
|
+
snapshot: v.optional(v.boolean()),
|
|
50
70
|
} satisfies EntriesOf<ArtifactMeta>);
|
|
51
71
|
|
|
72
|
+
export const DiffFileContentsSchema = v.object({
|
|
73
|
+
path: NonEmpty,
|
|
74
|
+
oldContents: v.string(),
|
|
75
|
+
newContents: v.string(),
|
|
76
|
+
status: v.picklist(["added", "modified", "deleted"]),
|
|
77
|
+
} satisfies EntriesOf<DiffFileContents>);
|
|
78
|
+
|
|
52
79
|
export const ArtifactSchema = v.object({
|
|
53
|
-
|
|
80
|
+
// Derived from the schema's runtime union: a new primitive extends the wire
|
|
81
|
+
// contract without touching this file.
|
|
82
|
+
type: v.picklist(ARTIFACT_TYPES),
|
|
54
83
|
content: v.string(),
|
|
55
84
|
meta: v.optional(ArtifactMetaSchema, {}),
|
|
85
|
+
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
56
86
|
} satisfies EntriesOf<Artifact>);
|
|
57
87
|
|
|
58
88
|
export const AnchorSchema = v.object({
|
|
@@ -60,22 +90,80 @@ export const AnchorSchema = v.object({
|
|
|
60
90
|
prefix: v.optional(v.string(), ""),
|
|
61
91
|
suffix: v.optional(v.string(), ""),
|
|
62
92
|
blockIndex: v.optional(v.number()),
|
|
93
|
+
endBlockIndex: v.optional(v.number()),
|
|
63
94
|
start: v.optional(v.number()),
|
|
64
95
|
end: v.optional(v.number()),
|
|
96
|
+
selector: v.optional(v.string()),
|
|
65
97
|
} satisfies EntriesOf<Anchor>);
|
|
66
98
|
|
|
99
|
+
/** The surface a note was made on; absent means the reviewed artifact. */
|
|
100
|
+
export const AnnotationTargetSchema: v.GenericSchema<AnnotationTarget> = v.variant("kind", [
|
|
101
|
+
v.object({ kind: v.literal("artifact") }),
|
|
102
|
+
v.object({ kind: v.literal("file"), path: NonEmpty, rev: v.picklist(["worktree", "head"]) }),
|
|
103
|
+
v.object({ kind: v.literal("welcome") }),
|
|
104
|
+
]);
|
|
105
|
+
|
|
67
106
|
/** Wire annotations arrive without createdAt - the daemon stamps it. */
|
|
68
107
|
export const AnnotationSchema = v.object({
|
|
69
108
|
id: NonEmpty,
|
|
70
|
-
/** Open kind set
|
|
109
|
+
/** Open kind set: the built-in is comment. */
|
|
71
110
|
kind: NonEmpty,
|
|
72
111
|
anchor: AnchorSchema,
|
|
112
|
+
target: v.optional(AnnotationTargetSchema),
|
|
73
113
|
body: v.string(),
|
|
74
114
|
orphan: v.optional(v.boolean()),
|
|
115
|
+
author: v.optional(v.string()),
|
|
116
|
+
replyTo: v.optional(NonEmpty),
|
|
117
|
+
resolution: v.optional(
|
|
118
|
+
v.object({
|
|
119
|
+
revision: v.number(),
|
|
120
|
+
source: v.picklist(["agent", "drift"]),
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
75
123
|
} satisfies EntriesOf<Omit<Annotation, "createdAt">>);
|
|
76
124
|
|
|
77
125
|
const SessionId = NonEmpty;
|
|
78
126
|
|
|
127
|
+
/** A stored annotation: the wire shape plus the daemon-stamped createdAt. */
|
|
128
|
+
export const FullAnnotationSchema = v.object({
|
|
129
|
+
...AnnotationSchema.entries,
|
|
130
|
+
createdAt: v.string(),
|
|
131
|
+
} satisfies EntriesOf<Annotation>);
|
|
132
|
+
|
|
133
|
+
export const IdentitySchema = v.object({
|
|
134
|
+
id: NonEmpty,
|
|
135
|
+
provider: v.picklist(["ssh", "github"]),
|
|
136
|
+
name: v.optional(v.string()),
|
|
137
|
+
handle: v.optional(v.string()),
|
|
138
|
+
} satisfies EntriesOf<Identity>);
|
|
139
|
+
|
|
140
|
+
export const ShareLinkSchema = v.object({
|
|
141
|
+
id: NonEmpty,
|
|
142
|
+
name: v.optional(v.string()),
|
|
143
|
+
requireAuth: v.boolean(),
|
|
144
|
+
allowlist: v.array(NonEmpty),
|
|
145
|
+
owner: v.optional(v.string()),
|
|
146
|
+
shareBranch: v.optional(v.string()),
|
|
147
|
+
} satisfies EntriesOf<ShareLink>);
|
|
148
|
+
|
|
149
|
+
export const HarnessBindingSchema = v.object({
|
|
150
|
+
id: NonEmpty,
|
|
151
|
+
threadId: NonEmpty,
|
|
152
|
+
harness: NonEmpty,
|
|
153
|
+
harnessSessionId: NonEmpty,
|
|
154
|
+
createdAt: NonEmpty,
|
|
155
|
+
approvedRetryMessageId: v.optional(NonEmpty),
|
|
156
|
+
} satisfies EntriesOf<HarnessBinding>);
|
|
157
|
+
|
|
158
|
+
export const DeliverySchema = v.object({
|
|
159
|
+
id: NonEmpty,
|
|
160
|
+
messageId: NonEmpty,
|
|
161
|
+
bindingId: NonEmpty,
|
|
162
|
+
status: v.picklist(["pending", "acknowledged"]),
|
|
163
|
+
createdAt: NonEmpty,
|
|
164
|
+
acknowledgedAt: v.optional(NonEmpty),
|
|
165
|
+
} satisfies EntriesOf<Delivery>);
|
|
166
|
+
|
|
79
167
|
export const Params = {
|
|
80
168
|
"session.create": v.object({ workspace: WorkspaceSchema, artifact: ArtifactSchema }),
|
|
81
169
|
"session.get": v.object({ id: SessionId }),
|
|
@@ -90,20 +178,117 @@ export const Params = {
|
|
|
90
178
|
"session.wait": v.object({
|
|
91
179
|
id: SessionId,
|
|
92
180
|
// clamped: a negative or absurd timeout is a client bug, not a daemon one
|
|
93
|
-
timeoutMs: v.optional(
|
|
181
|
+
timeoutMs: v.optional(
|
|
182
|
+
v.pipe(v.number(), v.minValue(0), v.maxValue(24 * 60 * 60 * 1000)),
|
|
183
|
+
60_000,
|
|
184
|
+
),
|
|
185
|
+
}),
|
|
186
|
+
"session.annotate": v.object({
|
|
187
|
+
id: SessionId,
|
|
188
|
+
annotation: AnnotationSchema,
|
|
189
|
+
authorName: v.optional(v.string()),
|
|
190
|
+
}),
|
|
191
|
+
// "session.comment" is the primary annotate method; "session.annotate" stays an accepted alias
|
|
192
|
+
"session.comment": v.object({
|
|
193
|
+
id: SessionId,
|
|
194
|
+
annotation: AnnotationSchema,
|
|
195
|
+
authorName: v.optional(v.string()),
|
|
94
196
|
}),
|
|
95
|
-
"session.annotate": v.object({ id: SessionId, annotation: AnnotationSchema }),
|
|
96
197
|
"session.removeAnnotation": v.object({ id: SessionId, annotationId: NonEmpty }),
|
|
198
|
+
"session.setParticipantName": v.object({ id: SessionId, author: NonEmpty, name: NonEmpty }),
|
|
97
199
|
"session.setWorkingCopy": v.object({ id: SessionId, workingCopy: v.optional(v.string()) }),
|
|
98
|
-
"session.
|
|
200
|
+
"session.cutBlock": v.object({
|
|
201
|
+
id: SessionId,
|
|
202
|
+
blockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
|
203
|
+
}),
|
|
204
|
+
"session.restoreBlock": v.object({
|
|
205
|
+
id: SessionId,
|
|
206
|
+
baseBlockIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
|
207
|
+
line: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
|
208
|
+
}),
|
|
209
|
+
"session.curate": v.object({
|
|
99
210
|
id: SessionId,
|
|
100
|
-
|
|
211
|
+
rejections: v.array(
|
|
212
|
+
v.object({
|
|
213
|
+
path: NonEmpty,
|
|
214
|
+
hunkIndex: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
|
215
|
+
changeIndex: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
|
216
|
+
} satisfies EntriesOf<HunkRejection>),
|
|
217
|
+
),
|
|
218
|
+
}),
|
|
219
|
+
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
220
|
+
"session.setTitle": v.object({ id: SessionId, title: v.string() }),
|
|
221
|
+
"session.projectFiles": v.object({ id: SessionId }),
|
|
222
|
+
"session.fileContents": v.object({ id: SessionId, path: NonEmpty }),
|
|
223
|
+
"repo.files": v.object({ cwd: NonEmpty }),
|
|
224
|
+
"repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
|
|
225
|
+
"repo.changes": v.object({ cwd: NonEmpty }),
|
|
226
|
+
"repo.diff": v.object({ cwd: NonEmpty }),
|
|
227
|
+
"session.workbench": v.object({ cwd: NonEmpty }),
|
|
228
|
+
"session.navigate": v.object({
|
|
229
|
+
id: SessionId,
|
|
230
|
+
entryId: NonEmpty,
|
|
231
|
+
summary: v.optional(v.string()),
|
|
232
|
+
// stand on this branch first, so a move on another branch is one request
|
|
233
|
+
branch: v.optional(NonEmpty),
|
|
234
|
+
}),
|
|
235
|
+
"session.branch": v.object({ id: SessionId, name: NonEmpty }),
|
|
236
|
+
"session.switch": v.object({ id: SessionId, branch: NonEmpty }),
|
|
237
|
+
"session.label": v.object({ id: SessionId, label: NonEmpty }),
|
|
238
|
+
"session.fork": v.object({ id: SessionId }),
|
|
239
|
+
"session.refreshDiff": v.object({ id: SessionId }),
|
|
240
|
+
"session.setShareId": v.object({ id: SessionId, shareId: NonEmpty }),
|
|
241
|
+
"session.setShares": v.object({ id: SessionId, shares: v.array(ShareLinkSchema) }),
|
|
242
|
+
"session.setAccess": v.object({ id: SessionId, githubLogins: v.array(NonEmpty) }),
|
|
243
|
+
"session.delete": v.object({ id: SessionId }),
|
|
244
|
+
"session.mergeShared": v.object({
|
|
245
|
+
id: SessionId,
|
|
246
|
+
annotations: v.array(FullAnnotationSchema),
|
|
247
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
248
|
+
// the removal entries a share recorded, carried by id so a merge applies each once
|
|
249
|
+
removals: v.optional(
|
|
250
|
+
v.array(v.object({ id: NonEmpty, annotationId: NonEmpty, createdAt: v.string() })),
|
|
251
|
+
),
|
|
252
|
+
}),
|
|
253
|
+
"session.sendMessage": v.object({
|
|
254
|
+
id: SessionId,
|
|
255
|
+
outcome: v.picklist(["approved", "changes_requested"]),
|
|
101
256
|
summary: v.optional(v.string(), ""),
|
|
257
|
+
actionBodies: v.optional(v.record(v.string(), v.string())),
|
|
258
|
+
}),
|
|
259
|
+
"harness.bind": v.object({
|
|
260
|
+
threadId: SessionId,
|
|
261
|
+
harness: NonEmpty,
|
|
262
|
+
harnessSessionId: NonEmpty,
|
|
263
|
+
}),
|
|
264
|
+
"harness.getBinding": v.object({ bindingId: NonEmpty }),
|
|
265
|
+
"harness.consumeApprovedRetry": v.object({
|
|
266
|
+
bindingId: NonEmpty,
|
|
267
|
+
messageId: NonEmpty,
|
|
268
|
+
content: v.string(),
|
|
269
|
+
}),
|
|
270
|
+
"delivery.pending": v.object({ bindingId: NonEmpty }),
|
|
271
|
+
"delivery.acknowledge": v.object({ deliveryId: NonEmpty }),
|
|
272
|
+
"session.submitRevision": v.object({
|
|
273
|
+
id: SessionId,
|
|
274
|
+
content: v.string(),
|
|
275
|
+
files: v.optional(v.array(DiffFileContentsSchema)),
|
|
276
|
+
/** Annotation ids the agent acted on; each is marked addressed. */
|
|
277
|
+
addressedAnnotationIds: v.optional(v.array(NonEmpty), []),
|
|
102
278
|
}),
|
|
103
|
-
"session.submitRevision": v.object({ id: SessionId, content: v.string() }),
|
|
104
279
|
"events.subscribe": v.object({}),
|
|
105
280
|
"daemon.ping": v.object({}),
|
|
281
|
+
// the owner token proves ownership; without it a request for owner stays a collaborator
|
|
282
|
+
"daemon.hello": v.object({
|
|
283
|
+
role: v.picklist(["owner", "collaborator", "agent"]),
|
|
284
|
+
token: v.optional(v.string()),
|
|
285
|
+
// the author a non-owner acts as, bound once for the connection
|
|
286
|
+
author: v.optional(NonEmpty),
|
|
287
|
+
}),
|
|
106
288
|
"daemon.shutdown": v.object({}),
|
|
289
|
+
// herdr adapter scratch: the review's opened tab, kept off the session record.
|
|
290
|
+
"herdr.getTab": v.object({ id: SessionId }),
|
|
291
|
+
"herdr.setTab": v.object({ id: SessionId, tabId: NonEmpty, paneId: NonEmpty }),
|
|
107
292
|
} as const;
|
|
108
293
|
|
|
109
294
|
export type MethodName = keyof typeof Params;
|
|
@@ -113,13 +298,22 @@ export function isKnownMethod(method: string): method is MethodName {
|
|
|
113
298
|
}
|
|
114
299
|
|
|
115
300
|
/** Validate params for a method, or throw a DaemonError the client can read. */
|
|
116
|
-
export function parseParams<M extends MethodName>(
|
|
301
|
+
export function parseParams<M extends MethodName>(
|
|
302
|
+
method: M,
|
|
303
|
+
params: Request["params"],
|
|
304
|
+
): v.InferOutput<(typeof Params)[M]> {
|
|
117
305
|
const result = v.safeParse(Params[method], params ?? {});
|
|
306
|
+
|
|
118
307
|
if (!result.success) {
|
|
119
308
|
const issue = result.issues[0]!;
|
|
120
|
-
const path = issue.path?.map((
|
|
121
|
-
|
|
309
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
310
|
+
|
|
311
|
+
throw new DaemonError(
|
|
312
|
+
"invalid_params",
|
|
313
|
+
`${method}: ${path ? path + " - " : ""}${issue.message}`,
|
|
314
|
+
);
|
|
122
315
|
}
|
|
316
|
+
|
|
123
317
|
return result.output;
|
|
124
318
|
}
|
|
125
319
|
|
|
@@ -129,31 +323,108 @@ export const RevisionSchema = v.object({
|
|
|
129
323
|
submittedAt: v.string(),
|
|
130
324
|
} satisfies EntriesOf<Revision>);
|
|
131
325
|
|
|
132
|
-
export const
|
|
133
|
-
|
|
326
|
+
export const MessageSchema = v.object({
|
|
327
|
+
id: NonEmpty,
|
|
328
|
+
outcome: v.picklist(["approved", "changes_requested"]),
|
|
134
329
|
summary: v.string(),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
} satisfies EntriesOf<
|
|
330
|
+
body: v.string(),
|
|
331
|
+
sentAt: v.string(),
|
|
332
|
+
} satisfies EntriesOf<Message>);
|
|
333
|
+
|
|
334
|
+
export const PendingDeliverySchema = v.object({
|
|
335
|
+
delivery: DeliverySchema,
|
|
336
|
+
message: MessageSchema,
|
|
337
|
+
} satisfies EntriesOf<PendingDelivery>);
|
|
338
|
+
|
|
339
|
+
const EntryBaseEntries = {
|
|
340
|
+
id: NonEmpty,
|
|
341
|
+
parentId: v.nullable(NonEmpty),
|
|
342
|
+
createdAt: v.string(),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
/** One record of a session's history; the tree pointers plus the entry's own fields. */
|
|
346
|
+
export const SessionEntrySchema = v.variant("type", [
|
|
347
|
+
v.object({
|
|
348
|
+
...EntryBaseEntries,
|
|
349
|
+
type: v.literal("revision"),
|
|
350
|
+
by: v.picklist(["agent", "reviewer"]),
|
|
351
|
+
content: v.string(),
|
|
352
|
+
}),
|
|
353
|
+
v.object({ ...EntryBaseEntries, type: v.literal("comment"), annotationId: NonEmpty }),
|
|
354
|
+
v.object({ ...EntryBaseEntries, type: v.literal("comment-removed"), annotationId: NonEmpty }),
|
|
355
|
+
v.object({ ...EntryBaseEntries, type: v.literal("message"), message: MessageSchema }),
|
|
356
|
+
v.object({
|
|
357
|
+
...EntryBaseEntries,
|
|
358
|
+
type: v.literal("branch-summary"),
|
|
359
|
+
text: v.string(),
|
|
360
|
+
abandoned: v.array(NonEmpty),
|
|
361
|
+
}),
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
export const SessionHistorySchema = v.pipe(
|
|
365
|
+
v.object({
|
|
366
|
+
entries: v.array(SessionEntrySchema),
|
|
367
|
+
tips: v.record(v.string(), NonEmpty),
|
|
368
|
+
branch: NonEmpty,
|
|
369
|
+
labels: v.record(v.string(), v.string()),
|
|
370
|
+
} satisfies EntriesOf<SessionHistory>),
|
|
371
|
+
// the shape is not enough: the tree itself must hold, or a walk hangs or rewires the path
|
|
372
|
+
v.rawCheck(({ dataset, addIssue }) => {
|
|
373
|
+
if (!dataset.typed) return;
|
|
374
|
+
const problem = validateHistory(dataset.value);
|
|
375
|
+
|
|
376
|
+
if (problem !== null) addIssue({ message: `history: ${problem}` });
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
138
379
|
|
|
139
380
|
/** Persisted records are validated on recovery: a bad file is skipped, not fatal. */
|
|
140
|
-
export const
|
|
381
|
+
export const ThreadRecordSchema = v.object({
|
|
141
382
|
schemaVersion: v.literal(SCHEMA_VERSION),
|
|
142
383
|
id: NonEmpty,
|
|
143
384
|
workspace: WorkspaceSchema,
|
|
144
385
|
artifact: ArtifactSchema,
|
|
145
386
|
revisions: v.array(RevisionSchema),
|
|
146
|
-
annotations: v.array(
|
|
387
|
+
annotations: v.array(FullAnnotationSchema),
|
|
388
|
+
history: v.optional(SessionHistorySchema),
|
|
389
|
+
curation: v.optional(
|
|
390
|
+
v.array(
|
|
391
|
+
v.object({
|
|
392
|
+
path: NonEmpty,
|
|
393
|
+
hunkIndex: v.number(),
|
|
394
|
+
changeIndex: v.optional(v.number()),
|
|
395
|
+
} satisfies EntriesOf<HunkRejection>),
|
|
396
|
+
),
|
|
397
|
+
),
|
|
147
398
|
workingCopy: v.optional(v.string()),
|
|
148
|
-
|
|
399
|
+
viewedPaths: v.optional(v.array(v.string())),
|
|
400
|
+
message: v.nullable(MessageSchema),
|
|
149
401
|
status: v.picklist(["pending", "resolved"]),
|
|
150
402
|
createdAt: v.string(),
|
|
151
|
-
|
|
403
|
+
shelvedAnnotations: v.optional(v.array(FullAnnotationSchema)),
|
|
404
|
+
parentSessionId: v.optional(v.string()),
|
|
405
|
+
shares: v.optional(v.array(ShareLinkSchema)),
|
|
406
|
+
shareId: v.optional(v.string()),
|
|
407
|
+
shareBranch: v.optional(v.string()),
|
|
408
|
+
owner: v.optional(v.string()),
|
|
409
|
+
access: v.optional(
|
|
410
|
+
v.object({ githubLogins: v.array(NonEmpty) } satisfies EntriesOf<ShareAccess>),
|
|
411
|
+
),
|
|
412
|
+
participants: v.optional(v.array(IdentitySchema)),
|
|
413
|
+
} satisfies EntriesOf<Thread>);
|
|
414
|
+
|
|
415
|
+
export function validateThreadRecord(
|
|
416
|
+
raw: Parameters<typeof v.safeParse>[1],
|
|
417
|
+
): { ok: true; value: v.InferOutput<typeof ThreadRecordSchema> } | { ok: false; error: string } {
|
|
418
|
+
const result = v.safeParse(ThreadRecordSchema, raw);
|
|
152
419
|
|
|
153
|
-
export function validateSessionRecord(raw: unknown): { ok: true; value: v.InferOutput<typeof SessionRecordSchema> } | { ok: false; error: string } {
|
|
154
|
-
const result = v.safeParse(SessionRecordSchema, raw);
|
|
155
420
|
if (result.success) return { ok: true, value: result.output };
|
|
156
421
|
const issue = result.issues[0]!;
|
|
157
|
-
const path = issue.path?.map((
|
|
422
|
+
const path = issue.path?.map((pathSegment) => String(pathSegment.key)).join(".") ?? "";
|
|
423
|
+
|
|
158
424
|
return { ok: false, error: `${path ? path + ": " : ""}${issue.message}` };
|
|
159
425
|
}
|
|
426
|
+
|
|
427
|
+
/** @deprecated use ThreadRecordSchema */
|
|
428
|
+
export const SessionRecordSchema = ThreadRecordSchema;
|
|
429
|
+
/** @deprecated use validateThreadRecord */
|
|
430
|
+
export const validateSessionRecord = validateThreadRecord;
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The daemon's build version, read from the package manifest - the handshake
|
|
2
|
+
* uses it so a client never keeps talking to a daemon from an earlier build. */
|
|
3
|
+
|
|
4
|
+
import manifest from "../package.json" with { type: "json" };
|
|
5
|
+
|
|
6
|
+
export const DAEMON_VERSION: string = manifest.version;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { DiffFileContents, DiffFileStatus } 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
|
+
/** Map a `git diff --name-status` letter to a diff status; renames/copies read as modified. */
|
|
16
|
+
function statusFromCode(code: string): DiffFileStatus {
|
|
17
|
+
const letter = code.charAt(0);
|
|
18
|
+
if (letter === "D") return "deleted";
|
|
19
|
+
if (letter === "A") return "added";
|
|
20
|
+
|
|
21
|
+
return "modified";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The working tree's changed files as repo-relative path plus status - a fast listing for the changes
|
|
26
|
+
* navigator that never reads file contents (unlike workingTreeDiff, which captures full curation copies).
|
|
27
|
+
* Uses tab-separated name-status so a trimmed leading column can never shift the parse.
|
|
28
|
+
*/
|
|
29
|
+
export async function workingChangeList(
|
|
30
|
+
cwd = process.cwd(),
|
|
31
|
+
): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
32
|
+
const changes: { path: string; status: DiffFileStatus }[] = [];
|
|
33
|
+
const tracked = (await git(["diff", "--name-status", "HEAD"], cwd)) ?? "";
|
|
34
|
+
|
|
35
|
+
for (const line of tracked.split("\n")) {
|
|
36
|
+
if (line.trim().length === 0) continue;
|
|
37
|
+
const parts = line.split("\t");
|
|
38
|
+
// a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
|
|
39
|
+
changes.push({ path: parts[parts.length - 1]!, status: statusFromCode(parts[0]!) });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const untracked = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
43
|
+
|
|
44
|
+
for (const path of untracked.split("\n")) {
|
|
45
|
+
if (path.trim().length > 0) changes.push({ path, status: "added" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return changes;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
|
|
52
|
+
async function headContents(path: string, cwd: string): Promise<string> {
|
|
53
|
+
const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
|
|
54
|
+
cwd,
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "ignore",
|
|
57
|
+
});
|
|
58
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
59
|
+
|
|
60
|
+
return (await gitProcess.exited) === 0 ? stdout : "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The working-tree file, or "" when it is gone (deleted). */
|
|
64
|
+
async function workingContents(path: string, cwd: string): Promise<string> {
|
|
65
|
+
try {
|
|
66
|
+
return await Bun.file(join(cwd, path)).text();
|
|
67
|
+
} catch {
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A NUL byte marks content git would treat as binary - not text-curatable. */
|
|
73
|
+
function looksBinary(text: string): boolean {
|
|
74
|
+
for (let index = 0; index < text.length; index++) {
|
|
75
|
+
if (text.charCodeAt(index) === 0) return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Full old/new contents for every tracked change against HEAD, classified by
|
|
83
|
+
* git's own status letter. Renames/copies and binary files are omitted: a
|
|
84
|
+
* path+text pair cannot express a rename header or a binary blob, so curation
|
|
85
|
+
* would emit a patch that drops the old path or corrupts the file - leaving them
|
|
86
|
+
* out keeps those changes in the patch but simply not curatable. The
|
|
87
|
+
* NUL-delimited stream survives paths with spaces or unicode.
|
|
88
|
+
*/
|
|
89
|
+
async function trackedFileContents(cwd: string): Promise<DiffFileContents[]> {
|
|
90
|
+
const nameStatus = (await git(["diff", "HEAD", "--name-status", "-z"], cwd)) ?? "";
|
|
91
|
+
const tokens = nameStatus.split("\0").filter((token) => token.length > 0);
|
|
92
|
+
const files: DiffFileContents[] = [];
|
|
93
|
+
let cursor = 0;
|
|
94
|
+
|
|
95
|
+
while (cursor < tokens.length) {
|
|
96
|
+
const code = tokens[cursor++]![0];
|
|
97
|
+
|
|
98
|
+
if (code === "R" || code === "C") {
|
|
99
|
+
cursor += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const path = tokens[cursor++]!;
|
|
103
|
+
const oldContents = code === "A" ? "" : await headContents(path, cwd);
|
|
104
|
+
const newContents = code === "D" ? "" : await workingContents(path, cwd);
|
|
105
|
+
|
|
106
|
+
if (looksBinary(oldContents) || looksBinary(newContents)) continue;
|
|
107
|
+
const status = code === "A" ? "added" : code === "D" ? "deleted" : "modified";
|
|
108
|
+
|
|
109
|
+
files.push({ path, oldContents, newContents, status });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return files;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface WorkingTreeDiff {
|
|
116
|
+
patch: string;
|
|
117
|
+
/** Full file contents per curatable changed file, keeping curation applyable. */
|
|
118
|
+
files: DiffFileContents[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function workingTreeDiff(cwd = process.cwd()): Promise<WorkingTreeDiff> {
|
|
122
|
+
const tracked = (await git(["diff", "HEAD"], cwd)) ?? "";
|
|
123
|
+
const files = await trackedFileContents(cwd);
|
|
124
|
+
// untracked files included by default
|
|
125
|
+
const untrackedList = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
126
|
+
let untracked = "";
|
|
127
|
+
|
|
128
|
+
for (const file of untrackedList.split("\n").filter(Boolean)) {
|
|
129
|
+
// `git diff --no-index` exits 1 when the files differ - that is success here
|
|
130
|
+
const gitProcess = Bun.spawn(["git", "diff", "--no-index", "--", "/dev/null", file], {
|
|
131
|
+
cwd,
|
|
132
|
+
stdout: "pipe",
|
|
133
|
+
stderr: "ignore",
|
|
134
|
+
});
|
|
135
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
136
|
+
const code = await gitProcess.exited;
|
|
137
|
+
|
|
138
|
+
if ((code === 0 || code === 1) && stdout.trim()) {
|
|
139
|
+
untracked += stdout.trim() + "\n";
|
|
140
|
+
const newContents = await workingContents(file, cwd);
|
|
141
|
+
|
|
142
|
+
if (!looksBinary(newContents)) {
|
|
143
|
+
files.push({ path: file, oldContents: "", newContents, status: "added" });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { patch: [tracked, untracked].filter(Boolean).join("\n"), files };
|
|
149
|
+
}
|