@gmickel/gno 1.43.0 → 1.45.0
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/assets/skill/SKILL.md +3 -0
- package/assets/skill/recipes/memory-file-decision.md +76 -0
- package/assets/skill/recipes/memory-scoped-recall.md +66 -0
- package/assets/skill/recipes/memory-supersede-fact.md +68 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.43.0.zip → gno-browser-clipper-v1.45.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.45.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +89 -8
- package/spec/mcp.md +12 -0
- package/spec/output-schemas/changes-follow-event.schema.json +35 -0
- package/spec/output-schemas/index-receipt.schema.json +135 -0
- package/spec/output-schemas/process-status.schema.json +76 -0
- package/src/cli/commands/agents/block.ts +9 -8
- package/src/cli/commands/changes-follow.ts +167 -0
- package/src/cli/commands/changes.ts +63 -0
- package/src/cli/commands/daemon.ts +35 -0
- package/src/cli/commands/doctor.ts +71 -0
- package/src/cli/commands/embed.ts +236 -178
- package/src/cli/commands/index-cmd.ts +238 -57
- package/src/cli/program.ts +94 -4
- package/src/config/types.ts +48 -0
- package/src/core/capture-sync.ts +144 -0
- package/src/core/capture.ts +10 -0
- package/src/core/findings-records.ts +381 -0
- package/src/core/findings-run-state.ts +282 -0
- package/src/embed/stage-state.ts +199 -0
- package/src/mcp/tools/capture.ts +91 -136
- package/src/serve/capture-service.ts +227 -53
- package/src/serve/findings-pass.ts +335 -0
- package/src/serve/resident-runtime.ts +42 -0
- package/src/serve/routes/api.ts +14 -14
- package/browser-extension/artifacts/gno-browser-clipper-v1.43.0.zip.sha256 +0 -1
package/src/mcp/tools/capture.ts
CHANGED
|
@@ -15,16 +15,21 @@ import type { ToolContext } from "../server";
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
buildCaptureReceipt,
|
|
18
|
+
CaptureSyncError,
|
|
19
|
+
ensureCapturedFileIndexed,
|
|
18
20
|
listCaptureDiskRelPaths,
|
|
19
21
|
planCapture,
|
|
22
|
+
syncCapturedFile,
|
|
20
23
|
type CaptureInput as SharedCaptureInput,
|
|
21
24
|
type CaptureReceipt,
|
|
25
|
+
type SyncCapturedFileResult,
|
|
22
26
|
} from "../../core/capture";
|
|
23
27
|
import { writeCapturePlanFile } from "../../core/capture-write";
|
|
24
28
|
import { MCP_ERRORS } from "../../core/errors";
|
|
25
29
|
import { withWriteLock } from "../../core/file-lock";
|
|
30
|
+
import { recordContentMutation } from "../../core/mutation-generations";
|
|
26
31
|
import { normalizeCollectionName } from "../../core/validation";
|
|
27
|
-
import {
|
|
32
|
+
import { DEFAULT_LOCK_WAIT_MS } from "../../core/write-lease";
|
|
28
33
|
import { runTool, type ToolResult } from "./index";
|
|
29
34
|
|
|
30
35
|
interface CaptureInput extends Omit<
|
|
@@ -102,6 +107,14 @@ function buildSharedInput(
|
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
/** Surface a sync failure as an MCP tool error the `CODE: message` way. */
|
|
111
|
+
function rethrowCaptureError(error: unknown): never {
|
|
112
|
+
if (error instanceof CaptureSyncError) {
|
|
113
|
+
throw new Error(`${error.code}: ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
export function handleCapture(
|
|
106
119
|
args: CaptureInput,
|
|
107
120
|
ctx: ToolContext
|
|
@@ -114,154 +127,96 @@ export function handleCapture(
|
|
|
114
127
|
throw new Error("Write tools disabled. Start MCP with --enable-write.");
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
const collectionName = normalizeCollectionName(args.collection);
|
|
131
|
+
const collection = ctx.collections.find(
|
|
132
|
+
(c) => c.name.toLowerCase() === collectionName
|
|
133
|
+
);
|
|
134
|
+
if (!collection) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${args.collection}`
|
|
121
137
|
);
|
|
122
|
-
|
|
123
|
-
throw new Error(
|
|
124
|
-
`${MCP_ERRORS.NOT_FOUND.code}: Collection not found: ${args.collection}`
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const existingDocs = await ctx.store.listDocuments(collectionName);
|
|
129
|
-
if (!existingDocs.ok) {
|
|
130
|
-
throw new Error(existingDocs.error.message);
|
|
131
|
-
}
|
|
138
|
+
}
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ${message}`);
|
|
144
|
-
}
|
|
140
|
+
// Write + lexical sync complete under the shared write lease: the tool
|
|
141
|
+
// succeeds only once the capture is retrievable (v1.38 contention
|
|
142
|
+
// contract: wait for the lease, LOCKED when it stays busy).
|
|
143
|
+
return await withWriteLock(
|
|
144
|
+
ctx.writeLockPath,
|
|
145
|
+
async () => {
|
|
146
|
+
const existingDocs = await ctx.store.listDocuments(collectionName);
|
|
147
|
+
if (!existingDocs.ok) {
|
|
148
|
+
throw new Error(existingDocs.error.message);
|
|
149
|
+
}
|
|
145
150
|
|
|
146
|
-
|
|
151
|
+
let plan;
|
|
152
|
+
try {
|
|
153
|
+
plan = planCapture({
|
|
154
|
+
input: buildSharedInput(args, collection.name),
|
|
155
|
+
existingRelPaths: existingDocs.value.map((doc) => doc.relPath),
|
|
156
|
+
diskRelPaths: await listCaptureDiskRelPaths(collection.path),
|
|
157
|
+
});
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const message =
|
|
160
|
+
error instanceof Error ? error.message : String(error);
|
|
161
|
+
throw new Error(`${MCP_ERRORS.INVALID_INPUT.code}: ${message}`);
|
|
162
|
+
}
|
|
147
163
|
|
|
148
|
-
|
|
149
|
-
const existingFile = Bun.file(absPath);
|
|
150
|
-
const exists = await existingFile.exists();
|
|
164
|
+
assertNotSensitive(plan.relPath);
|
|
151
165
|
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const existingDoc = docResult.ok ? docResult.value : undefined;
|
|
158
|
-
return buildCaptureReceipt({
|
|
159
|
-
plan,
|
|
166
|
+
const absPath = join(collection.path, plan.relPath);
|
|
167
|
+
const syncInput = {
|
|
168
|
+
collection,
|
|
169
|
+
store: ctx.store,
|
|
170
|
+
relPath: plan.relPath,
|
|
160
171
|
absPath,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
config: ctx.config,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let synced: SyncCapturedFileResult;
|
|
176
|
+
let overwritten = false;
|
|
177
|
+
try {
|
|
178
|
+
if (plan.openedExisting) {
|
|
179
|
+
synced = await ensureCapturedFileIndexed(syncInput);
|
|
180
|
+
} else {
|
|
181
|
+
overwritten =
|
|
182
|
+
(await Bun.file(absPath).exists()) && args.overwrite === true;
|
|
183
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
184
|
+
await writeCapturePlanFile(plan, absPath);
|
|
185
|
+
synced = await syncCapturedFile(syncInput);
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
rethrowCaptureError(error);
|
|
189
|
+
}
|
|
190
|
+
if (synced.result) {
|
|
191
|
+
recordContentMutation(synced.result, ctx.markContentMutation);
|
|
192
|
+
}
|
|
174
193
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
docid: "",
|
|
190
|
-
sync: {
|
|
191
|
-
status: "failed",
|
|
192
|
-
error: "RUNTIME: Sync result missing",
|
|
193
|
-
},
|
|
194
|
-
overwritten: exists && args.overwrite === true,
|
|
195
|
-
serverInstanceId: ctx.serverInstanceId,
|
|
196
|
-
}) as McpCaptureResult;
|
|
197
|
-
}
|
|
198
|
-
if (syncResult.status === "error") {
|
|
199
|
-
return buildCaptureReceipt({
|
|
200
|
-
plan,
|
|
201
|
-
absPath,
|
|
202
|
-
docid: "",
|
|
203
|
-
sync: {
|
|
204
|
-
status: "failed",
|
|
205
|
-
error: `INGEST_ERROR: ${syncResult.errorCode ?? "ERROR"} - ${
|
|
206
|
-
syncResult.errorMessage ?? "Unknown error"
|
|
207
|
-
}`,
|
|
208
|
-
},
|
|
209
|
-
overwritten: exists && args.overwrite === true,
|
|
210
|
-
serverInstanceId: ctx.serverInstanceId,
|
|
211
|
-
}) as McpCaptureResult;
|
|
212
|
-
}
|
|
213
|
-
if (syncResult.status === "added" || syncResult.status === "updated") {
|
|
214
|
-
ctx.markContentMutation?.();
|
|
215
|
-
}
|
|
194
|
+
const isMarkdown =
|
|
195
|
+
plan.relPath.endsWith(".md") || plan.relPath.endsWith(".markdown");
|
|
196
|
+
if (!isMarkdown && !plan.openedExisting && plan.tags.length > 0) {
|
|
197
|
+
const tagResult = await ctx.store.setDocTags(
|
|
198
|
+
synced.documentId,
|
|
199
|
+
plan.tags,
|
|
200
|
+
"user"
|
|
201
|
+
);
|
|
202
|
+
if (!tagResult.ok) {
|
|
203
|
+
console.error(
|
|
204
|
+
`[MCP] Warning: Document created but tags not stored: ${tagResult.error.message}`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
216
208
|
|
|
217
|
-
let docid = syncResult.docid;
|
|
218
|
-
let documentId: number | undefined;
|
|
219
|
-
const docResult = await ctx.store.getDocument(
|
|
220
|
-
collectionName,
|
|
221
|
-
plan.relPath
|
|
222
|
-
);
|
|
223
|
-
if (docResult.ok && docResult.value) {
|
|
224
|
-
docid = docid ?? docResult.value.docid;
|
|
225
|
-
documentId = docResult.value.id;
|
|
226
|
-
}
|
|
227
|
-
if (!docid) {
|
|
228
209
|
return buildCaptureReceipt({
|
|
229
210
|
plan,
|
|
230
211
|
absPath,
|
|
231
|
-
docid:
|
|
232
|
-
sync:
|
|
233
|
-
|
|
234
|
-
error: "RUNTIME: Document missing after sync",
|
|
235
|
-
},
|
|
236
|
-
overwritten: exists && args.overwrite === true,
|
|
212
|
+
docid: synced.docid,
|
|
213
|
+
sync: synced.sync,
|
|
214
|
+
overwritten,
|
|
237
215
|
serverInstanceId: ctx.serverInstanceId,
|
|
238
216
|
}) as McpCaptureResult;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
plan.relPath.endsWith(".md") || plan.relPath.endsWith(".markdown");
|
|
243
|
-
if (!isMarkdown && plan.tags.length > 0 && documentId) {
|
|
244
|
-
const tagResult = await ctx.store.setDocTags(
|
|
245
|
-
documentId,
|
|
246
|
-
plan.tags,
|
|
247
|
-
"user"
|
|
248
|
-
);
|
|
249
|
-
if (!tagResult.ok) {
|
|
250
|
-
console.error(
|
|
251
|
-
`[MCP] Warning: Document created but tags not stored: ${tagResult.error.message}`
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return buildCaptureReceipt({
|
|
257
|
-
plan,
|
|
258
|
-
absPath,
|
|
259
|
-
docid,
|
|
260
|
-
sync: { status: "completed" },
|
|
261
|
-
overwritten: exists && args.overwrite === true,
|
|
262
|
-
serverInstanceId: ctx.serverInstanceId,
|
|
263
|
-
}) as McpCaptureResult;
|
|
264
|
-
});
|
|
217
|
+
},
|
|
218
|
+
DEFAULT_LOCK_WAIT_MS
|
|
219
|
+
);
|
|
265
220
|
},
|
|
266
221
|
formatCaptureResult
|
|
267
222
|
);
|
|
@@ -20,15 +20,23 @@ import type { DocumentEventBus } from "./doc-events";
|
|
|
20
20
|
import type { EmbedScheduler } from "./embed-scheduler";
|
|
21
21
|
import type { CollectionWatchService } from "./watch-service";
|
|
22
22
|
|
|
23
|
+
import { getIndexDbPath } from "../app/constants";
|
|
23
24
|
import {
|
|
24
25
|
buildCaptureReceipt,
|
|
26
|
+
CaptureSyncError,
|
|
27
|
+
type CaptureSyncPaths,
|
|
28
|
+
ensureCapturedFileIndexed,
|
|
25
29
|
extractCaptureSourceFromFrontmatter,
|
|
26
30
|
hashCaptureContent,
|
|
27
31
|
listCaptureDiskRelPaths,
|
|
28
32
|
planCapture,
|
|
33
|
+
syncCapturedFile,
|
|
29
34
|
} from "../core/capture";
|
|
30
35
|
import { writeCapturePlanFile } from "../core/capture-write";
|
|
36
|
+
import { MCP_ERRORS } from "../core/errors";
|
|
37
|
+
import { withWriteLock } from "../core/file-lock";
|
|
31
38
|
import { recordContentMutation } from "../core/mutation-generations";
|
|
39
|
+
import { DEFAULT_LOCK_WAIT_MS, writeLeasePath } from "../core/write-lease";
|
|
32
40
|
import {
|
|
33
41
|
type CollectionSyncResult,
|
|
34
42
|
defaultSyncService,
|
|
@@ -40,6 +48,8 @@ import { startJob } from "./jobs";
|
|
|
40
48
|
|
|
41
49
|
export interface ResidentCaptureContext {
|
|
42
50
|
config: Config;
|
|
51
|
+
/** Resident server context; only the index name is read (lease path). */
|
|
52
|
+
current?: { indexName?: string };
|
|
43
53
|
scheduler: EmbedScheduler | null;
|
|
44
54
|
eventBus: DocumentEventBus | null;
|
|
45
55
|
watchService: CollectionWatchService | null;
|
|
@@ -47,6 +57,78 @@ export interface ResidentCaptureContext {
|
|
|
47
57
|
markContentMutation?: () => void;
|
|
48
58
|
}
|
|
49
59
|
|
|
60
|
+
export interface ResidentCaptureDependencies {
|
|
61
|
+
/**
|
|
62
|
+
* `await-sync` (the `/api/capture` contract): write + lexical sync complete
|
|
63
|
+
* under the shared write lease before the response, `201` on create.
|
|
64
|
+
* `job` (browser clipper): write, then `202` with a sync job to poll.
|
|
65
|
+
*/
|
|
66
|
+
mode?: "await-sync" | "job";
|
|
67
|
+
syncPaths?: CaptureSyncPaths;
|
|
68
|
+
syncCollection?: typeof defaultSyncService.syncCollection;
|
|
69
|
+
/** Shared `.mcp-write.lock` path; defaults to the resident index's lease. */
|
|
70
|
+
lockPath?: string;
|
|
71
|
+
lockWaitMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ResidentCaptureErrorShape {
|
|
75
|
+
code: string;
|
|
76
|
+
message: string;
|
|
77
|
+
status: number;
|
|
78
|
+
details?: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const HTTP_OK = 200;
|
|
82
|
+
const HTTP_CREATED = 201;
|
|
83
|
+
const HTTP_ACCEPTED = 202;
|
|
84
|
+
const HTTP_CONFLICT = 409;
|
|
85
|
+
const HTTP_INTERNAL = 500;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Map a capture execution failure to its wire shape: lease busy is `LOCKED`
|
|
89
|
+
* (409, the MCP write-lock code), a written-but-unsynced capture is
|
|
90
|
+
* `CAPTURE_SYNC_FAILED` (500) carrying the write half of the receipt, and
|
|
91
|
+
* anything else is `RUNTIME` (500).
|
|
92
|
+
*/
|
|
93
|
+
export const classifyResidentCaptureError = (
|
|
94
|
+
error: unknown,
|
|
95
|
+
planned?: Extract<ResidentCapturePlanResult, { ok: true }>
|
|
96
|
+
): ResidentCaptureErrorShape => {
|
|
97
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
98
|
+
if (error instanceof CaptureSyncError) {
|
|
99
|
+
return {
|
|
100
|
+
code: error.code,
|
|
101
|
+
message,
|
|
102
|
+
status: HTTP_INTERNAL,
|
|
103
|
+
details: {
|
|
104
|
+
absPath: error.absPath,
|
|
105
|
+
relPath: error.relPath,
|
|
106
|
+
...(planned
|
|
107
|
+
? {
|
|
108
|
+
uri: `gno://${planned.collection.name}/${planned.plan.relPath}`,
|
|
109
|
+
contentHash: planned.plan.contentHash,
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (message.startsWith(`${MCP_ERRORS.LOCKED.code}:`)) {
|
|
116
|
+
return { code: MCP_ERRORS.LOCKED.code, message, status: HTTP_CONFLICT };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
code: "RUNTIME",
|
|
120
|
+
message: `Failed to capture document: ${message}`,
|
|
121
|
+
status: HTTP_INTERNAL,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const resolveCaptureLockPath = (
|
|
126
|
+
context: ResidentCaptureContext,
|
|
127
|
+
dependencies: ResidentCaptureDependencies
|
|
128
|
+
): string =>
|
|
129
|
+
dependencies.lockPath ??
|
|
130
|
+
writeLeasePath(getIndexDbPath(context.current?.indexName));
|
|
131
|
+
|
|
50
132
|
export type ResidentCapturePlanResult =
|
|
51
133
|
| {
|
|
52
134
|
ok: true;
|
|
@@ -172,57 +254,36 @@ const syncResidentCollection = async (
|
|
|
172
254
|
return result;
|
|
173
255
|
};
|
|
174
256
|
|
|
175
|
-
|
|
257
|
+
const emitCaptureCreated = (
|
|
258
|
+
context: ResidentCaptureContext,
|
|
259
|
+
collection: Collection,
|
|
260
|
+
relPath: string
|
|
261
|
+
): void => {
|
|
262
|
+
context.scheduler?.notifySyncComplete([relPath]);
|
|
263
|
+
context.eventBus?.emit({
|
|
264
|
+
type: "document-changed",
|
|
265
|
+
uri: `gno://${collection.name}/${relPath}`,
|
|
266
|
+
collection: collection.name,
|
|
267
|
+
relPath,
|
|
268
|
+
origin: "create",
|
|
269
|
+
changedAt: new Date().toISOString(),
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Write the planned capture, then start the legacy sync job (browser
|
|
275
|
+
* clipper contract: `202` + `sync.status: "pending"`).
|
|
276
|
+
*/
|
|
277
|
+
const executeCaptureAsJob = async (
|
|
176
278
|
context: ResidentCaptureContext,
|
|
177
279
|
store: SqliteAdapter,
|
|
178
280
|
planned: Extract<ResidentCapturePlanResult, { ok: true }>,
|
|
179
|
-
dependencies:
|
|
180
|
-
syncCollection?: typeof defaultSyncService.syncCollection;
|
|
181
|
-
} = {}
|
|
281
|
+
dependencies: ResidentCaptureDependencies
|
|
182
282
|
): Promise<{ body: unknown; status: number }> => {
|
|
183
283
|
const { collection, fullPath, plan } = planned;
|
|
184
|
-
if (plan.provenanceConflict) {
|
|
185
|
-
return {
|
|
186
|
-
body: buildCaptureReceipt({
|
|
187
|
-
plan,
|
|
188
|
-
absPath: fullPath,
|
|
189
|
-
sync: {
|
|
190
|
-
status: "skipped",
|
|
191
|
-
reason:
|
|
192
|
-
"Existing capture has absent or different browser provenance.",
|
|
193
|
-
},
|
|
194
|
-
}),
|
|
195
|
-
status: 409,
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
if (plan.openedExisting) {
|
|
199
|
-
const existingDocument = await store.getDocument(
|
|
200
|
-
collection.name,
|
|
201
|
-
plan.relPath
|
|
202
|
-
);
|
|
203
|
-
if (!existingDocument.ok) {
|
|
204
|
-
throw new Error(existingDocument.error.message);
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
207
|
-
body: buildCaptureReceipt({
|
|
208
|
-
plan,
|
|
209
|
-
absPath: fullPath,
|
|
210
|
-
docid: existingDocument.value?.docid,
|
|
211
|
-
sync: existingDocument.value
|
|
212
|
-
? { status: "completed" }
|
|
213
|
-
: {
|
|
214
|
-
status: "skipped",
|
|
215
|
-
reason: "Existing file is not indexed yet.",
|
|
216
|
-
},
|
|
217
|
-
}),
|
|
218
|
-
status: 200,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
284
|
await mkdir(dirname(fullPath), { recursive: true });
|
|
223
285
|
context.watchService?.suppress(fullPath);
|
|
224
286
|
await writeCapturePlanFile(plan, fullPath);
|
|
225
|
-
const gnoUri = `gno://${collection.name}/${plan.relPath}`;
|
|
226
287
|
const syncCollection =
|
|
227
288
|
dependencies.syncCollection ??
|
|
228
289
|
defaultSyncService.syncCollection.bind(defaultSyncService);
|
|
@@ -235,15 +296,7 @@ export const executeResidentCapturePlan = async (
|
|
|
235
296
|
store,
|
|
236
297
|
syncCollection
|
|
237
298
|
);
|
|
238
|
-
context
|
|
239
|
-
context.eventBus?.emit({
|
|
240
|
-
type: "document-changed",
|
|
241
|
-
uri: gnoUri,
|
|
242
|
-
collection: collection.name,
|
|
243
|
-
relPath: plan.relPath,
|
|
244
|
-
origin: "create",
|
|
245
|
-
changedAt: new Date().toISOString(),
|
|
246
|
-
});
|
|
299
|
+
emitCaptureCreated(context, collection, plan.relPath);
|
|
247
300
|
return {
|
|
248
301
|
collections: [result],
|
|
249
302
|
totalDurationMs: result.durationMs,
|
|
@@ -274,10 +327,131 @@ export const executeResidentCapturePlan = async (
|
|
|
274
327
|
error: jobResult.error,
|
|
275
328
|
},
|
|
276
329
|
}),
|
|
277
|
-
status:
|
|
330
|
+
status: HTTP_ACCEPTED,
|
|
278
331
|
};
|
|
279
332
|
};
|
|
280
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Write + lexical sync under the shared write lease; the response is sent
|
|
336
|
+
* only once the capture is retrievable. Throws `CaptureSyncError` when the
|
|
337
|
+
* file landed but sync failed, and the `LOCKED` error when the lease stays
|
|
338
|
+
* busy past `lockWaitMs`.
|
|
339
|
+
*/
|
|
340
|
+
const executeCaptureAwaitingSync = async (
|
|
341
|
+
context: ResidentCaptureContext,
|
|
342
|
+
store: SqliteAdapter,
|
|
343
|
+
planned: Extract<ResidentCapturePlanResult, { ok: true }>,
|
|
344
|
+
dependencies: ResidentCaptureDependencies
|
|
345
|
+
): Promise<{ body: unknown; status: number }> => {
|
|
346
|
+
const { collection, fullPath, plan } = planned;
|
|
347
|
+
return withWriteLock(
|
|
348
|
+
resolveCaptureLockPath(context, dependencies),
|
|
349
|
+
async () => {
|
|
350
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
351
|
+
context.watchService?.suppress(fullPath);
|
|
352
|
+
await writeCapturePlanFile(plan, fullPath);
|
|
353
|
+
const synced = await syncCapturedFile({
|
|
354
|
+
collection,
|
|
355
|
+
store,
|
|
356
|
+
relPath: plan.relPath,
|
|
357
|
+
absPath: fullPath,
|
|
358
|
+
config: context.config,
|
|
359
|
+
syncPaths: dependencies.syncPaths,
|
|
360
|
+
});
|
|
361
|
+
if (synced.result) {
|
|
362
|
+
recordContentMutation(synced.result, context.markContentMutation);
|
|
363
|
+
}
|
|
364
|
+
emitCaptureCreated(context, collection, plan.relPath);
|
|
365
|
+
return {
|
|
366
|
+
body: buildCaptureReceipt({
|
|
367
|
+
plan,
|
|
368
|
+
absPath: fullPath,
|
|
369
|
+
docid: synced.docid,
|
|
370
|
+
sync: synced.sync,
|
|
371
|
+
}),
|
|
372
|
+
status: HTTP_CREATED,
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
dependencies.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS
|
|
376
|
+
);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* `open_existing`: an indexed file needs no lease; a disk-only file is synced
|
|
381
|
+
* under the lease so opening it is also a retrievable success.
|
|
382
|
+
*/
|
|
383
|
+
const openExistingCapture = async (
|
|
384
|
+
context: ResidentCaptureContext,
|
|
385
|
+
store: SqliteAdapter,
|
|
386
|
+
planned: Extract<ResidentCapturePlanResult, { ok: true }>,
|
|
387
|
+
dependencies: ResidentCaptureDependencies
|
|
388
|
+
): Promise<{ body: unknown; status: number }> => {
|
|
389
|
+
const { collection, fullPath, plan } = planned;
|
|
390
|
+
const syncInput = {
|
|
391
|
+
collection,
|
|
392
|
+
store,
|
|
393
|
+
relPath: plan.relPath,
|
|
394
|
+
absPath: fullPath,
|
|
395
|
+
config: context.config,
|
|
396
|
+
syncPaths: dependencies.syncPaths,
|
|
397
|
+
};
|
|
398
|
+
const existingDocument = await store.getDocument(
|
|
399
|
+
collection.name,
|
|
400
|
+
plan.relPath
|
|
401
|
+
);
|
|
402
|
+
if (!existingDocument.ok) {
|
|
403
|
+
throw new Error(existingDocument.error.message);
|
|
404
|
+
}
|
|
405
|
+
const synced = existingDocument.value
|
|
406
|
+
? await ensureCapturedFileIndexed(syncInput)
|
|
407
|
+
: await withWriteLock(
|
|
408
|
+
resolveCaptureLockPath(context, dependencies),
|
|
409
|
+
() => ensureCapturedFileIndexed(syncInput),
|
|
410
|
+
dependencies.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS
|
|
411
|
+
);
|
|
412
|
+
if (synced.result) {
|
|
413
|
+
recordContentMutation(synced.result, context.markContentMutation);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
body: buildCaptureReceipt({
|
|
417
|
+
plan,
|
|
418
|
+
absPath: fullPath,
|
|
419
|
+
docid: synced.docid,
|
|
420
|
+
sync: synced.sync,
|
|
421
|
+
}),
|
|
422
|
+
status: HTTP_OK,
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
export const executeResidentCapturePlan = async (
|
|
427
|
+
context: ResidentCaptureContext,
|
|
428
|
+
store: SqliteAdapter,
|
|
429
|
+
planned: Extract<ResidentCapturePlanResult, { ok: true }>,
|
|
430
|
+
dependencies: ResidentCaptureDependencies = {}
|
|
431
|
+
): Promise<{ body: unknown; status: number }> => {
|
|
432
|
+
const { fullPath, plan } = planned;
|
|
433
|
+
if (plan.provenanceConflict) {
|
|
434
|
+
return {
|
|
435
|
+
body: buildCaptureReceipt({
|
|
436
|
+
plan,
|
|
437
|
+
absPath: fullPath,
|
|
438
|
+
sync: {
|
|
439
|
+
status: "skipped",
|
|
440
|
+
reason:
|
|
441
|
+
"Existing capture has absent or different browser provenance.",
|
|
442
|
+
},
|
|
443
|
+
}),
|
|
444
|
+
status: HTTP_CONFLICT,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
if (plan.openedExisting) {
|
|
448
|
+
return openExistingCapture(context, store, planned, dependencies);
|
|
449
|
+
}
|
|
450
|
+
return (dependencies.mode ?? "job") === "job"
|
|
451
|
+
? executeCaptureAsJob(context, store, planned, dependencies)
|
|
452
|
+
: executeCaptureAwaitingSync(context, store, planned, dependencies);
|
|
453
|
+
};
|
|
454
|
+
|
|
281
455
|
export const browserClipIdempotencyPlan = (
|
|
282
456
|
planned: Extract<ResidentCapturePlanResult, { ok: true }>
|
|
283
457
|
): ClipperIdempotencyPlan => {
|