@gmickel/gno 1.24.0 → 1.25.1

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.
Files changed (51) hide show
  1. package/README.md +9 -1
  2. package/assets/skill/SKILL.md +11 -0
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +50 -0
  15. package/spec/db/schema.sql +101 -0
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/src/core/browser-clip-provenance.ts +139 -0
  28. package/src/core/browser-clip.ts +473 -0
  29. package/src/core/capture-write.ts +5 -0
  30. package/src/core/capture.ts +75 -18
  31. package/src/core/file-lock.ts +20 -6
  32. package/src/serve/capture-service.ts +420 -0
  33. package/src/serve/clipper-body.ts +62 -0
  34. package/src/serve/clipper-capture.ts +248 -0
  35. package/src/serve/clipper-contract.ts +57 -0
  36. package/src/serve/clipper-idempotency.ts +35 -0
  37. package/src/serve/clipper-pairing.ts +297 -0
  38. package/src/serve/clipper-security-errors.ts +23 -0
  39. package/src/serve/clipper-security.ts +449 -0
  40. package/src/serve/public/app.tsx +8 -1
  41. package/src/serve/public/globals.built.css +1 -1
  42. package/src/serve/public/index.html +1 -0
  43. package/src/serve/public/lib/clipper-approval.ts +206 -0
  44. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  45. package/src/serve/routes/api.ts +19 -115
  46. package/src/serve/routes/clipper.ts +394 -0
  47. package/src/serve/server.ts +22 -0
  48. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  49. package/src/store/migrations/index.ts +2 -0
  50. package/src/store/sqlite/clipper-store-types.ts +104 -0
  51. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <base href="/" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <meta name="referrer" content="no-referrer" />
7
8
  <title>GNO - Local Knowledge Index</title>
8
9
  <link rel="stylesheet" href="./globals.built.css" />
9
10
  </head>
@@ -0,0 +1,206 @@
1
+ const PAIR_ID = /^[a-f0-9]{64}$/u;
2
+ const PAIRING_CODE = /^\d{8}$/u;
3
+ const TOKEN = /^[a-f0-9]{64}$/u;
4
+ const EXTENSION_ORIGIN = /^chrome-extension:\/\/[a-p]{32}$/u;
5
+ const KNOWN_ERROR_CODES = new Set([
6
+ "CLIPPER_ABORTED",
7
+ "CLIPPER_BODY_TOO_LARGE",
8
+ "CLIPPER_BUSY",
9
+ "CLIPPER_FORBIDDEN",
10
+ "CLIPPER_INVALID_JSON",
11
+ "CLIPPER_RATE_LIMITED",
12
+ "CLIPPER_UNAUTHORIZED",
13
+ "CLIPPER_PAIRING_UNAVAILABLE",
14
+ "CLIPPER_CSRF",
15
+ "CLIPPER_INVALID_REQUEST",
16
+ "CLIPPER_PAIR_NOT_FOUND",
17
+ "CLIPPER_PAIR_EXPIRED",
18
+ "CLIPPER_PAIR_INVALID_CODE",
19
+ "CLIPPER_PAIR_ALREADY_USED",
20
+ "CLIPPER_PREVIEW_MISMATCH",
21
+ "CLIPPER_PREVIEW_REQUIRED",
22
+ "CLIPPER_IDEMPOTENCY_PENDING",
23
+ "CLIPPER_IDEMPOTENCY_CONFLICT",
24
+ "CLIPPER_IDEMPOTENCY_RECOVERY_CONFLICT",
25
+ "CLIPPER_IDEMPOTENCY_GRANT_INACTIVE",
26
+ "CLIPPER_CAPTURE_FAILED",
27
+ "NOT_FOUND",
28
+ "RUNTIME",
29
+ "VALIDATION",
30
+ ]);
31
+
32
+ export interface ClipperPairLaunch {
33
+ pairId: string | null;
34
+ valid: boolean;
35
+ }
36
+
37
+ export interface ClipperApproval {
38
+ origin: string;
39
+ expiresAt: string;
40
+ }
41
+
42
+ export class ClipperApprovalError extends Error {
43
+ readonly code: string;
44
+ readonly retryable: boolean;
45
+
46
+ constructor(code: string, message: string, retryable = false) {
47
+ super(message);
48
+ this.name = "ClipperApprovalError";
49
+ this.code = code;
50
+ this.retryable = retryable;
51
+ }
52
+ }
53
+
54
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
55
+ value !== null && typeof value === "object" && !Array.isArray(value);
56
+
57
+ const hasExactKeys = (
58
+ value: Record<string, unknown>,
59
+ keys: readonly string[]
60
+ ): boolean => {
61
+ const actual = Object.keys(value).sort();
62
+ const expected = [...keys].sort();
63
+ return (
64
+ actual.length === expected.length &&
65
+ actual.every((key, index) => key === expected[index])
66
+ );
67
+ };
68
+
69
+ const isDateTime = (value: unknown): value is string =>
70
+ typeof value === "string" &&
71
+ Number.isFinite(Date.parse(value)) &&
72
+ value.includes("T");
73
+
74
+ export const consumeClipperPairLaunch = (
75
+ location: Pick<Location, "pathname" | "search" | "hash">,
76
+ history: Pick<History, "replaceState">
77
+ ): ClipperPairLaunch => {
78
+ if (location.pathname !== "/clipper/pair") {
79
+ return { pairId: null, valid: false };
80
+ }
81
+ const match =
82
+ location.search === ""
83
+ ? /^#pairId=(?<pairId>[a-f0-9]{64})$/u.exec(location.hash)
84
+ : null;
85
+ history.replaceState({}, "", "/clipper/pair");
86
+ const pairId = match?.groups?.pairId ?? null;
87
+ return { pairId, valid: pairId !== null };
88
+ };
89
+
90
+ const parseError = (value: unknown): ClipperApprovalError | null => {
91
+ if (
92
+ !isRecord(value) ||
93
+ !hasExactKeys(value, ["error"]) ||
94
+ !isRecord(value.error) ||
95
+ !hasExactKeys(value.error, ["code", "message"]) ||
96
+ typeof value.error.code !== "string" ||
97
+ !KNOWN_ERROR_CODES.has(value.error.code) ||
98
+ typeof value.error.message !== "string" ||
99
+ value.error.message.length === 0
100
+ ) {
101
+ return null;
102
+ }
103
+ return new ClipperApprovalError(
104
+ value.error.code,
105
+ value.error.message,
106
+ value.error.code === "CLIPPER_PAIR_INVALID_CODE"
107
+ );
108
+ };
109
+
110
+ const readJson = async (response: Response): Promise<unknown> => {
111
+ if (
112
+ !(response.headers.get("content-type") ?? "").includes("application/json")
113
+ ) {
114
+ throw new ClipperApprovalError(
115
+ "CLIPPER_INVALID_RESPONSE",
116
+ "GNO returned a non-JSON pairing response."
117
+ );
118
+ }
119
+ try {
120
+ return await response.json();
121
+ } catch {
122
+ throw new ClipperApprovalError(
123
+ "CLIPPER_INVALID_RESPONSE",
124
+ "GNO returned malformed pairing data."
125
+ );
126
+ }
127
+ };
128
+
129
+ const requireSuccess = (response: Response, value: unknown): void => {
130
+ if (response.ok) return;
131
+ const parsed = parseError(value);
132
+ if (parsed) throw parsed;
133
+ throw new ClipperApprovalError(
134
+ "CLIPPER_INVALID_RESPONSE",
135
+ "GNO returned an unknown pairing error."
136
+ );
137
+ };
138
+
139
+ export async function approveClipperPair(
140
+ pairId: string,
141
+ pairingCode: string,
142
+ fetcher: typeof fetch = fetch
143
+ ): Promise<ClipperApproval> {
144
+ if (!PAIR_ID.test(pairId) || !PAIRING_CODE.test(pairingCode)) {
145
+ throw new ClipperApprovalError(
146
+ "CLIPPER_INVALID_REQUEST",
147
+ "Enter the exact eight-digit code shown by the extension."
148
+ );
149
+ }
150
+
151
+ const csrfResponse = await fetcher("/api/clipper/pair/csrf", {
152
+ method: "GET",
153
+ cache: "no-store",
154
+ credentials: "same-origin",
155
+ });
156
+ const csrfBody = await readJson(csrfResponse);
157
+ requireSuccess(csrfResponse, csrfBody);
158
+ if (
159
+ !isRecord(csrfBody) ||
160
+ !hasExactKeys(csrfBody, ["schemaVersion", "csrfToken"]) ||
161
+ csrfBody.schemaVersion !== "1.0" ||
162
+ typeof csrfBody.csrfToken !== "string" ||
163
+ !TOKEN.test(csrfBody.csrfToken)
164
+ ) {
165
+ throw new ClipperApprovalError(
166
+ "CLIPPER_INVALID_RESPONSE",
167
+ "GNO returned an unsupported pairing response."
168
+ );
169
+ }
170
+
171
+ const approvalResponse = await fetcher("/api/clipper/pair/approve", {
172
+ method: "POST",
173
+ cache: "no-store",
174
+ credentials: "same-origin",
175
+ headers: {
176
+ "Content-Type": "application/json",
177
+ "X-GNO-CSRF": csrfBody.csrfToken,
178
+ },
179
+ body: JSON.stringify({ pairId, pairingCode }),
180
+ });
181
+ const approvalBody = await readJson(approvalResponse);
182
+ requireSuccess(approvalResponse, approvalBody);
183
+ if (
184
+ !isRecord(approvalBody) ||
185
+ !hasExactKeys(approvalBody, [
186
+ "schemaVersion",
187
+ "status",
188
+ "origin",
189
+ "expiresAt",
190
+ ]) ||
191
+ approvalBody.schemaVersion !== "1.0" ||
192
+ approvalBody.status !== "approved" ||
193
+ typeof approvalBody.origin !== "string" ||
194
+ !EXTENSION_ORIGIN.test(approvalBody.origin) ||
195
+ !isDateTime(approvalBody.expiresAt)
196
+ ) {
197
+ throw new ClipperApprovalError(
198
+ "CLIPPER_INVALID_RESPONSE",
199
+ "GNO returned an unsupported pairing response."
200
+ );
201
+ }
202
+ return {
203
+ origin: approvalBody.origin,
204
+ expiresAt: approvalBody.expiresAt,
205
+ };
206
+ }
@@ -0,0 +1,210 @@
1
+ import {
2
+ AlertCircleIcon,
3
+ CheckCircle2Icon,
4
+ LinkIcon,
5
+ Loader2Icon,
6
+ } from "lucide-react";
7
+ import { useState } from "react";
8
+
9
+ import { GnoLogo } from "../components/GnoLogo";
10
+ import { Button } from "../components/ui/button";
11
+ import {
12
+ Card,
13
+ CardContent,
14
+ CardDescription,
15
+ CardFooter,
16
+ CardHeader,
17
+ CardTitle,
18
+ } from "../components/ui/card";
19
+ import { Input } from "../components/ui/input";
20
+ import {
21
+ approveClipperPair,
22
+ ClipperApprovalError,
23
+ type ClipperApproval,
24
+ } from "../lib/clipper-approval";
25
+
26
+ interface ClipperPairingProps {
27
+ pairId: string | null;
28
+ }
29
+
30
+ type PairingState = "ready" | "approving" | "approved" | "terminal";
31
+
32
+ const terminalCodes = new Set([
33
+ "CLIPPER_PAIR_EXPIRED",
34
+ "CLIPPER_PAIR_ALREADY_USED",
35
+ "CLIPPER_PAIR_NOT_FOUND",
36
+ ]);
37
+
38
+ export default function ClipperPairing({ pairId }: ClipperPairingProps) {
39
+ const [activePairId, setActivePairId] = useState(pairId);
40
+ const [pairingCode, setPairingCode] = useState("");
41
+ const [state, setState] = useState<PairingState>(
42
+ pairId === null ? "terminal" : "ready"
43
+ );
44
+ const [approval, setApproval] = useState<ClipperApproval | null>(null);
45
+ const [error, setError] = useState<string | null>(
46
+ pairId === null
47
+ ? "This pairing link is invalid. Start pairing again from the extension."
48
+ : null
49
+ );
50
+
51
+ const clearSensitiveState = () => {
52
+ setPairingCode("");
53
+ setActivePairId(null);
54
+ };
55
+
56
+ const approve = async () => {
57
+ if (activePairId === null || !/^\d{8}$/u.test(pairingCode)) return;
58
+ setState("approving");
59
+ setError(null);
60
+ try {
61
+ const result = await approveClipperPair(activePairId, pairingCode);
62
+ clearSensitiveState();
63
+ setApproval(result);
64
+ setState("approved");
65
+ } catch (cause) {
66
+ const parsed =
67
+ cause instanceof ClipperApprovalError
68
+ ? cause
69
+ : new ClipperApprovalError(
70
+ "CLIPPER_NETWORK",
71
+ "Could not reach the local GNO gateway. Keep the extension open and retry.",
72
+ true
73
+ );
74
+ setError(parsed.message);
75
+ if (terminalCodes.has(parsed.code) || !parsed.retryable) {
76
+ clearSensitiveState();
77
+ setState("terminal");
78
+ } else {
79
+ setPairingCode("");
80
+ setState("ready");
81
+ }
82
+ }
83
+ };
84
+
85
+ const cancel = () => {
86
+ clearSensitiveState();
87
+ setError("Pairing cancelled. Start again from the extension when ready.");
88
+ setState("terminal");
89
+ };
90
+
91
+ return (
92
+ <main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-6">
93
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,oklch(0.68_0.11_55/0.12),transparent_42%),radial-gradient(circle_at_bottom_right,oklch(0.52_0.08_245/0.10),transparent_48%)]" />
94
+ <Card className="relative w-full max-w-lg border-border/70 shadow-2xl">
95
+ <CardHeader>
96
+ <div className="mb-3 flex items-center gap-3 text-primary">
97
+ <GnoLogo className="size-9" />
98
+ <span className="font-mono text-xs tracking-[0.2em] uppercase">
99
+ Local approval
100
+ </span>
101
+ </div>
102
+ <CardTitle className="font-semibold text-2xl">
103
+ Pair the GNO browser clipper
104
+ </CardTitle>
105
+ <CardDescription className="leading-6">
106
+ This extension may send only content you explicitly capture into
107
+ this local GNO. Access expires within 30 days and can be revoked
108
+ from the extension.
109
+ </CardDescription>
110
+ </CardHeader>
111
+ <CardContent className="space-y-5">
112
+ {state === "ready" || state === "approving" ? (
113
+ <>
114
+ <div className="rounded-lg border border-border/60 bg-muted/25 p-4 text-sm">
115
+ <div className="mb-2 flex items-center gap-2 font-medium">
116
+ <LinkIcon className="size-4 text-primary" />
117
+ Confirm this browser
118
+ </div>
119
+ <p className="text-muted-foreground">
120
+ Enter the eight-digit code shown in the extension. GNO never
121
+ gives this page the extension grant.
122
+ </p>
123
+ </div>
124
+ <label className="block space-y-2">
125
+ <span className="font-medium text-sm">Pairing code</span>
126
+ <Input
127
+ aria-label="Pairing code"
128
+ autoComplete="off"
129
+ autoFocus
130
+ className="h-12 font-mono text-lg tracking-[0.3em]"
131
+ disabled={state === "approving"}
132
+ inputMode="numeric"
133
+ maxLength={8}
134
+ onChange={(event) =>
135
+ setPairingCode(
136
+ event.currentTarget.value
137
+ .replaceAll(/\D/gu, "")
138
+ .slice(0, 8)
139
+ )
140
+ }
141
+ pattern="[0-9]{8}"
142
+ placeholder="00000000"
143
+ value={pairingCode}
144
+ />
145
+ </label>
146
+ </>
147
+ ) : null}
148
+
149
+ {approval ? (
150
+ <div
151
+ className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4"
152
+ role="status"
153
+ >
154
+ <div className="mb-2 flex items-center gap-2 font-medium text-emerald-700 dark:text-emerald-300">
155
+ <CheckCircle2Icon className="size-5" />
156
+ Browser paired
157
+ </div>
158
+ <p className="break-all text-muted-foreground text-sm">
159
+ {approval.origin}
160
+ </p>
161
+ <p className="mt-1 text-muted-foreground text-xs">
162
+ Expires {new Date(approval.expiresAt).toLocaleString()}
163
+ </p>
164
+ </div>
165
+ ) : null}
166
+
167
+ {error ? (
168
+ <div
169
+ className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm"
170
+ role="alert"
171
+ >
172
+ <AlertCircleIcon className="mt-0.5 size-4 shrink-0 text-destructive" />
173
+ <span>{error}</span>
174
+ </div>
175
+ ) : null}
176
+ </CardContent>
177
+ <CardFooter className="justify-between gap-3">
178
+ {state === "ready" || state === "approving" ? (
179
+ <>
180
+ <Button
181
+ disabled={state === "approving"}
182
+ onClick={cancel}
183
+ type="button"
184
+ variant="ghost"
185
+ >
186
+ Cancel
187
+ </Button>
188
+ <Button
189
+ disabled={
190
+ state === "approving" || !/^\d{8}$/u.test(pairingCode)
191
+ }
192
+ onClick={() => void approve()}
193
+ type="button"
194
+ >
195
+ {state === "approving" ? (
196
+ <Loader2Icon className="animate-spin" />
197
+ ) : null}
198
+ Approve extension
199
+ </Button>
200
+ </>
201
+ ) : (
202
+ <Button asChild className="ml-auto" variant="outline">
203
+ <a href="/">Back to GNO</a>
204
+ </Button>
205
+ )}
206
+ </CardFooter>
207
+ </Card>
208
+ </main>
209
+ );
210
+ }
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  // node:fs/promises structure ops have no Bun equivalent
9
- import { mkdir, readdir } from "node:fs/promises";
9
+ import { readdir } from "node:fs/promises";
10
10
  // node:path has no Bun equivalent
11
- import { dirname, join as pathJoin, posix as pathPosix } from "node:path";
11
+ import { posix as pathPosix } from "node:path";
12
12
 
13
13
  import type {
14
14
  Collection,
@@ -48,14 +48,7 @@ import {
48
48
  fingerprintContentTypeRules,
49
49
  normalizeContentTypes,
50
50
  } from "../../config";
51
- import {
52
- buildCaptureReceipt,
53
- listCaptureDiskRelPaths,
54
- planCapture,
55
- type CapturePlan,
56
- type PublicCaptureInput,
57
- } from "../../core/capture";
58
- import { writeCapturePlanFile } from "../../core/capture-write";
51
+ import { type PublicCaptureInput } from "../../core/capture";
59
52
  import {
60
53
  type ConnectorVerificationCode,
61
54
  getConnectorVerificationRemediation,
@@ -146,6 +139,10 @@ import {
146
139
  } from "../../publish/artifact";
147
140
  import { exportPublishArtifact } from "../../publish/export-service";
148
141
  import { buildBrowseTree, normalizeBrowsePath } from "../browse-tree";
142
+ import {
143
+ executeResidentCapturePlan,
144
+ planResidentCapture,
145
+ } from "../capture-service";
149
146
  import { applyConfigChange, applyConfigChangeTyped } from "../config-sync";
150
147
  import {
151
148
  getConnectorStatuses,
@@ -3167,114 +3164,21 @@ export async function handleCreateCapture(
3167
3164
  );
3168
3165
  }
3169
3166
 
3170
- const collectionName = body.collection.toLowerCase();
3171
- const collection = ctxHolder.config.collections.find(
3172
- (candidate) => candidate.name.toLowerCase() === collectionName
3173
- );
3174
- if (!collection) {
3175
- return errorResponse(
3176
- "NOT_FOUND",
3177
- `Collection not found: ${body.collection}`,
3178
- 404
3179
- );
3180
- }
3181
-
3182
- let plan: CapturePlan;
3183
- try {
3184
- plan = planCapture({
3185
- input: {
3186
- ...body,
3187
- collection: collection.name,
3188
- },
3189
- existingRelPaths: await listCollectionRelPaths(store, collection.name),
3190
- diskRelPaths: await listCaptureDiskRelPaths(collection.path),
3191
- });
3192
- } catch (error) {
3193
- return errorResponse(
3194
- "VALIDATION",
3195
- error instanceof Error ? error.message : String(error),
3196
- 409
3197
- );
3198
- }
3199
-
3200
- const fullPath = pathJoin(collection.path, plan.relPath);
3201
- if (plan.openedExisting) {
3202
- const existingDoc = await store.getDocument(collection.name, plan.relPath);
3203
- if (!existingDoc.ok) {
3204
- return errorResponse("RUNTIME", existingDoc.error.message, 500);
3205
- }
3206
- return jsonResponse(
3207
- buildCaptureReceipt({
3208
- plan,
3209
- absPath: fullPath,
3210
- docid: existingDoc.value?.docid,
3211
- sync: existingDoc.value
3212
- ? { status: "completed" }
3213
- : {
3214
- status: "skipped",
3215
- reason: "Existing file is not indexed yet.",
3216
- },
3217
- })
3218
- );
3167
+ const planned = await planResidentCapture(ctxHolder, store, {
3168
+ ...body,
3169
+ collection: body.collection,
3170
+ });
3171
+ if (!planned.ok) {
3172
+ return errorResponse(planned.code, planned.message, planned.status);
3219
3173
  }
3220
-
3221
3174
  try {
3222
- await mkdir(dirname(fullPath), { recursive: true });
3223
- ctxHolder.watchService?.suppress(fullPath);
3224
- await writeCapturePlanFile(plan, fullPath);
3225
-
3226
- const gnoUri = `gno://${collection.name}/${plan.relPath}`;
3227
- const jobResult = await startJob(
3228
- "sync",
3229
- async (): Promise<SyncResult> => {
3230
- const result = await syncResidentCollection(
3231
- ctxHolder,
3232
- collection,
3233
- store,
3234
- withContentTypeRules({ runUpdateCmd: false }, ctxHolder.config),
3235
- deps?.syncCollection
3236
- );
3237
- ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
3238
- ctxHolder.eventBus?.emit({
3239
- type: "document-changed",
3240
- uri: gnoUri,
3241
- collection: collection.name,
3242
- relPath: plan.relPath,
3243
- origin: "create",
3244
- changedAt: new Date().toISOString(),
3245
- });
3246
- return {
3247
- collections: [result],
3248
- totalDurationMs: result.durationMs,
3249
- totalFilesProcessed: result.filesProcessed,
3250
- totalFilesAdded: result.filesAdded,
3251
- totalFilesUpdated: result.filesUpdated,
3252
- totalFilesErrored: result.filesErrored,
3253
- totalFilesSkipped: result.filesSkipped,
3254
- };
3255
- },
3256
- ctxHolder.jobManager
3257
- );
3258
-
3259
- return jsonResponse(
3260
- buildCaptureReceipt({
3261
- plan,
3262
- absPath: fullPath,
3263
- sync: jobResult.ok
3264
- ? {
3265
- status: "pending",
3266
- jobId: jobResult.jobId,
3267
- reason: "Sync job started; poll /api/jobs/:id for status.",
3268
- }
3269
- : {
3270
- status: "skipped",
3271
- jobId: jobResult.activeJobId,
3272
- reason: "Sync skipped because another job is running.",
3273
- error: jobResult.error,
3274
- },
3275
- }),
3276
- 202
3175
+ const result = await executeResidentCapturePlan(
3176
+ ctxHolder,
3177
+ store,
3178
+ planned,
3179
+ deps
3277
3180
  );
3181
+ return jsonResponse(result.body, result.status);
3278
3182
  } catch (error) {
3279
3183
  return errorResponse(
3280
3184
  "RUNTIME",