@gmickel/gno 2.6.0 → 2.7.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.
- package/README.md +25 -30
- package/assets/skill/SKILL.md +5 -3
- package/assets/skill/cli-reference.md +9 -2
- package/assets/skill/mcp-reference.md +2 -1
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.6.0.zip → gno-browser-clipper-v2.7.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +109 -18
- package/spec/mcp.md +36 -2
- package/spec/output-schemas/ask.schema.json +1 -1
- package/spec/output-schemas/capture-receipt.schema.json +1 -1
- package/spec/output-schemas/collection-list.schema.json +2 -2
- package/spec/output-schemas/doctor.schema.json +88 -0
- package/spec/output-schemas/error.schema.json +11 -2
- package/spec/output-schemas/get.schema.json +1 -1
- package/spec/output-schemas/mcp-capture-result.schema.json +1 -2
- package/spec/output-schemas/memory-remember.schema.json +2 -2
- package/spec/output-schemas/multi-get.schema.json +4 -1
- package/spec/output-schemas/peek.schema.json +2 -9
- package/spec/output-schemas/resident-status.schema.json +22 -0
- package/spec/output-schemas/search-result.schema.json +1 -1
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +110 -16
- package/src/cli/commands/ask.ts +31 -12
- package/src/cli/commands/doctor.ts +54 -20
- package/src/cli/commands/embed.ts +41 -3
- package/src/cli/commands/ls.ts +6 -1
- package/src/cli/commands/query.ts +5 -0
- package/src/cli/commands/status.ts +63 -5
- package/src/cli/commands/vec.ts +54 -0
- package/src/cli/detach.ts +29 -1
- package/src/cli/errors.ts +13 -9
- package/src/cli/program.ts +53 -1
- package/src/core/capture-sync.ts +9 -2
- package/src/core/host-paths.ts +49 -0
- package/src/core/memory-remember.ts +4 -3
- package/src/core/request-receipts.ts +63 -9
- package/src/core/shutdown-budget.ts +6 -0
- package/src/core/vector-partition-status.ts +52 -0
- package/src/core/windows-private-path.ts +136 -1
- package/src/embed/backlog.ts +145 -27
- package/src/embed/fingerprint.ts +6 -3
- package/src/embed/retry.ts +66 -27
- package/src/embed/variant-backlog.ts +48 -18
- package/src/embed/variant-retry.ts +31 -22
- package/src/index.ts +21 -2
- package/src/llm/inference-scope.ts +18 -0
- package/src/llm/native-worker/dispatcher.ts +2 -0
- package/src/llm/native-worker/embedding-identity.ts +42 -0
- package/src/llm/native-worker/protocol.ts +1 -0
- package/src/llm/types.ts +3 -0
- package/src/mcp/context.ts +9 -0
- package/src/mcp/resources/index.ts +6 -5
- package/src/mcp/tool-descriptions-core.ts +1 -1
- package/src/mcp/tools/capture.ts +1 -3
- package/src/mcp/tools/index.ts +11 -4
- package/src/mcp/tools/memory-remember.ts +1 -1
- package/src/mcp/tools/status.ts +23 -6
- package/src/pipeline/hybrid.ts +37 -7
- package/src/pipeline/vsearch.ts +14 -2
- package/src/serve/embed-scheduler.ts +133 -19
- package/src/serve/host-path-redaction.ts +116 -0
- package/src/serve/public/components/BootstrapStatus.tsx +5 -3
- package/src/serve/public/components/CaptureModal.tsx +1 -1
- package/src/serve/public/components/CollectionModelDialog.tsx +16 -13
- package/src/serve/public/components/CollectionsEmptyState.tsx +5 -3
- package/src/serve/public/components/FirstRunWizard.tsx +4 -2
- package/src/serve/public/components/sessions/SessionSearch.tsx +2 -2
- package/src/serve/public/components/sessions/SourcesPanel.tsx +77 -60
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +17 -2
- package/src/serve/public/lib/request-intent.ts +8 -0
- package/src/serve/public/{components/sessions → lib}/snippet.tsx +2 -3
- package/src/serve/public/pages/Collections.tsx +16 -13
- package/src/serve/public/pages/Connectors.tsx +7 -4
- package/src/serve/public/pages/Dashboard.tsx +16 -11
- package/src/serve/public/pages/DocView.tsx +10 -5
- package/src/serve/public/pages/DocumentEditor.tsx +91 -14
- package/src/serve/public/pages/Search.tsx +1 -41
- package/src/serve/resident-runtime.ts +33 -1
- package/src/serve/resident-status.ts +13 -1
- package/src/serve/routes/sessions.ts +61 -5
- package/src/serve/server.ts +15 -12
- package/src/serve/status-model.ts +26 -6
- package/src/serve/status.ts +4 -7
- package/src/serve/watch-reconciliation-shared.ts +3 -0
- package/src/serve/watch-service-events.ts +3 -2
- package/src/serve/watch-service-run-flush.ts +35 -2
- package/src/serve/watch-service.ts +5 -0
- package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
- package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +24 -3
- package/src/store/sqlite/change-journal-store.ts +1 -1
- package/src/store/sqlite/legacy-vector-ownership.ts +2 -1
- package/src/store/types.ts +11 -1
- package/src/store/vector/lazy.ts +46 -43
- package/src/store/vector/runtime-compat.ts +651 -0
- package/src/store/vector/sqlite-vec.ts +20 -2
- package/src/store/vector/status.ts +276 -35
- package/src/store/vector/types.ts +2 -0
- package/src/store/vector/variant-search.ts +71 -23
- package/src/store/vector/variants.ts +49 -14
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +0 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useCallback, useState } from "react";
|
|
2
2
|
|
|
3
|
+
import { writeOutcomeUnknown } from "../lib/request-intent";
|
|
4
|
+
|
|
3
5
|
interface ApiState<T> {
|
|
4
6
|
data: T | null;
|
|
5
7
|
loading: boolean;
|
|
@@ -86,6 +88,11 @@ export async function apiFetch<T>(
|
|
|
86
88
|
error: string | null;
|
|
87
89
|
/** `error.details` of the JSON error envelope, when the server sent one. */
|
|
88
90
|
details?: Record<string, unknown>;
|
|
91
|
+
/**
|
|
92
|
+
* A write may or may not have committed: no readable response, a server
|
|
93
|
+
* error, or the request is still pending under its request ID.
|
|
94
|
+
*/
|
|
95
|
+
outcomeUnknown?: true;
|
|
89
96
|
}> {
|
|
90
97
|
try {
|
|
91
98
|
const res = await fetch(endpoint, {
|
|
@@ -96,17 +103,24 @@ export async function apiFetch<T>(
|
|
|
96
103
|
const { json, parseError } = await parseJsonSafe(res);
|
|
97
104
|
|
|
98
105
|
if (parseError) {
|
|
99
|
-
return { data: null, error: parseError };
|
|
106
|
+
return { data: null, error: parseError, outcomeUnknown: true };
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
if (!res.ok) {
|
|
103
110
|
const apiError = json as {
|
|
104
|
-
error?: {
|
|
111
|
+
error?: {
|
|
112
|
+
code?: string;
|
|
113
|
+
message?: string;
|
|
114
|
+
details?: Record<string, unknown>;
|
|
115
|
+
};
|
|
105
116
|
};
|
|
106
117
|
return {
|
|
107
118
|
data: null,
|
|
108
119
|
error: apiError.error?.message || `Request failed: ${res.status}`,
|
|
109
120
|
details: apiError.error?.details,
|
|
121
|
+
...(writeOutcomeUnknown(res.status, apiError.error?.code)
|
|
122
|
+
? { outcomeUnknown: true as const }
|
|
123
|
+
: {}),
|
|
110
124
|
};
|
|
111
125
|
}
|
|
112
126
|
|
|
@@ -115,6 +129,7 @@ export async function apiFetch<T>(
|
|
|
115
129
|
return {
|
|
116
130
|
data: null,
|
|
117
131
|
error: err instanceof Error ? err.message : "Network error",
|
|
132
|
+
outcomeUnknown: true,
|
|
118
133
|
};
|
|
119
134
|
}
|
|
120
135
|
}
|
|
@@ -67,3 +67,11 @@ export function clearRequestIntent(
|
|
|
67
67
|
// Nothing persisted to clear.
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a failed write's HTTP answer leaves its commit unknown: a server
|
|
73
|
+
* error, or the request ID's earlier attempt is still in progress.
|
|
74
|
+
*/
|
|
75
|
+
export function writeOutcomeUnknown(status: number, code?: string): boolean {
|
|
76
|
+
return status >= 500 || code === "REQUEST_PENDING";
|
|
77
|
+
}
|
|
@@ -14,10 +14,9 @@ export function unescapeMarkdown(text: string): string {
|
|
|
14
14
|
/**
|
|
15
15
|
* Render a search snippet: FTS `<mark>` highlights become real <mark>
|
|
16
16
|
* elements, everything else stays React text (never parsed as HTML).
|
|
17
|
-
*
|
|
18
|
-
* archived turns are indexed as escaped Markdown.
|
|
17
|
+
* Markdown backslash escapes are dropped because indexed text keeps them.
|
|
19
18
|
*/
|
|
20
|
-
export function
|
|
19
|
+
export function renderSnippet(snippet: string): ReactNode[] {
|
|
21
20
|
const parts: ReactNode[] = [];
|
|
22
21
|
let remaining = snippet;
|
|
23
22
|
let key = 0;
|
|
@@ -76,7 +76,8 @@ interface PageProps {
|
|
|
76
76
|
interface CollectionStats {
|
|
77
77
|
activePresetId?: string;
|
|
78
78
|
name: string;
|
|
79
|
-
path
|
|
79
|
+
/** Host path; omitted for remote callers. */
|
|
80
|
+
path?: string;
|
|
80
81
|
documentCount: number;
|
|
81
82
|
chunkCount: number;
|
|
82
83
|
embeddedCount: number;
|
|
@@ -262,18 +263,20 @@ function CollectionCard({
|
|
|
262
263
|
</DropdownMenu>
|
|
263
264
|
</div>
|
|
264
265
|
|
|
265
|
-
|
|
266
|
-
<
|
|
267
|
-
<
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
266
|
+
{collection.path && (
|
|
267
|
+
<TooltipProvider>
|
|
268
|
+
<Tooltip>
|
|
269
|
+
<TooltipTrigger asChild>
|
|
270
|
+
<p className="truncate font-mono text-muted-foreground text-xs">
|
|
271
|
+
{truncatePath(collection.path)}
|
|
272
|
+
</p>
|
|
273
|
+
</TooltipTrigger>
|
|
274
|
+
<TooltipContent className="max-w-xs break-all">
|
|
275
|
+
<p className="font-mono text-xs">{collection.path}</p>
|
|
276
|
+
</TooltipContent>
|
|
277
|
+
</Tooltip>
|
|
278
|
+
</TooltipProvider>
|
|
279
|
+
)}
|
|
277
280
|
</CardHeader>
|
|
278
281
|
|
|
279
282
|
<CardContent className="pt-2">
|
|
@@ -30,7 +30,8 @@ interface ConnectorStatus {
|
|
|
30
30
|
target: string;
|
|
31
31
|
scope: "user" | "project";
|
|
32
32
|
installed: boolean;
|
|
33
|
-
path
|
|
33
|
+
/** Host path; omitted for remote callers. */
|
|
34
|
+
path?: string;
|
|
34
35
|
summary: string;
|
|
35
36
|
nextAction: string;
|
|
36
37
|
mode: {
|
|
@@ -234,9 +235,11 @@ export default function Connectors({ navigate }: PageProps) {
|
|
|
234
235
|
<span className="font-medium">Scope:</span>{" "}
|
|
235
236
|
{connector.scope}
|
|
236
237
|
</div>
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
{connector.path && (
|
|
239
|
+
<div className="font-mono text-muted-foreground text-xs">
|
|
240
|
+
{connector.path}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
240
243
|
</div>
|
|
241
244
|
{connector.error && (
|
|
242
245
|
<p className="text-destructive text-sm">
|
|
@@ -788,25 +788,30 @@ export default function Dashboard({ navigate }: PageProps) {
|
|
|
788
788
|
}
|
|
789
789
|
style={{ animationDelay: `${0.4 + index * 0.1}s` }}
|
|
790
790
|
>
|
|
791
|
-
<CardContent className="flex items-center justify-between py-4">
|
|
792
|
-
<div className="flex items-center gap-3">
|
|
791
|
+
<CardContent className="flex flex-wrap items-center justify-between gap-3 py-4">
|
|
792
|
+
<div className="flex min-w-0 flex-1 basis-48 items-center gap-3">
|
|
793
793
|
{syncing ? (
|
|
794
|
-
<Loader2Icon className="size-4 animate-spin text-amber-500" />
|
|
794
|
+
<Loader2Icon className="size-4 shrink-0 animate-spin text-amber-500" />
|
|
795
795
|
) : collection.embeddedCount >= collection.chunkCount ? (
|
|
796
|
-
<CheckCircle2Icon className="size-4 text-green-500" />
|
|
796
|
+
<CheckCircle2Icon className="size-4 shrink-0 text-green-500" />
|
|
797
797
|
) : (
|
|
798
|
-
<div className="size-4 rounded-full border-2 border-amber-500" />
|
|
798
|
+
<div className="size-4 shrink-0 rounded-full border-2 border-amber-500" />
|
|
799
799
|
)}
|
|
800
|
-
<div>
|
|
801
|
-
<div className="font-medium text-lg transition-colors group-hover:text-primary">
|
|
800
|
+
<div className="min-w-0">
|
|
801
|
+
<div className="truncate font-medium text-lg transition-colors group-hover:text-primary">
|
|
802
802
|
{collection.name}
|
|
803
803
|
</div>
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
804
|
+
{collection.path && (
|
|
805
|
+
<div
|
|
806
|
+
className="truncate font-mono text-muted-foreground text-sm"
|
|
807
|
+
title={collection.path}
|
|
808
|
+
>
|
|
809
|
+
{collection.path}
|
|
810
|
+
</div>
|
|
811
|
+
)}
|
|
807
812
|
</div>
|
|
808
813
|
</div>
|
|
809
|
-
<div className="flex items-center gap-3 text-right">
|
|
814
|
+
<div className="flex shrink-0 items-center gap-3 text-right">
|
|
810
815
|
<Button
|
|
811
816
|
onClick={(event) => {
|
|
812
817
|
event.stopPropagation();
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type FileRefactorPreviewPlan,
|
|
38
38
|
} from "../../../core/file-refactor-contract";
|
|
39
39
|
import { extractSections } from "../../../core/sections";
|
|
40
|
+
import { updateFrontmatterTags } from "../../../ingestion/frontmatter";
|
|
40
41
|
import {
|
|
41
42
|
CodeBlock,
|
|
42
43
|
CodeBlockCopyButton,
|
|
@@ -583,11 +584,10 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
583
584
|
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
584
585
|
}, [doc, isPdf]);
|
|
585
586
|
|
|
586
|
-
// Remote "Open original" target
|
|
587
|
-
//
|
|
588
|
-
// previous file:// scope (every read-only source) for remote clients.
|
|
587
|
+
// Remote "Open original" target: /api/doc-asset serves any collection file
|
|
588
|
+
// inline by URI, and remote clients never receive the host absPath.
|
|
589
589
|
const sourceAssetUrl = useMemo(() => {
|
|
590
|
-
if (!doc
|
|
590
|
+
if (!doc) {
|
|
591
591
|
return null;
|
|
592
592
|
}
|
|
593
593
|
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
@@ -1243,10 +1243,15 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1243
1243
|
}
|
|
1244
1244
|
|
|
1245
1245
|
clearRequestIntent(tagIntentRef, TAG_INTENT_KEY);
|
|
1246
|
-
//
|
|
1246
|
+
// Mirror the committed write-back so the frontmatter tag list is current
|
|
1247
|
+
// without waiting for the index to resync.
|
|
1247
1248
|
setDoc({
|
|
1248
1249
|
...doc,
|
|
1249
1250
|
tags: editedTags,
|
|
1251
|
+
content:
|
|
1252
|
+
data?.writeBack === "applied" && doc.content !== null
|
|
1253
|
+
? updateFrontmatterTags(doc.content, editedTags)
|
|
1254
|
+
: doc.content,
|
|
1250
1255
|
source: {
|
|
1251
1256
|
...doc.source,
|
|
1252
1257
|
sourceHash: data?.version.sourceHash ?? doc.source.sourceHash,
|
|
@@ -207,8 +207,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
207
207
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
208
208
|
const [creatingCopy, setCreatingCopy] = useState(false);
|
|
209
209
|
const [copyError, setCopyError] = useState<string | null>(null);
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
/**
|
|
211
|
+
* `outside`: the file changed under the editor. `unconfirmed`: a save's
|
|
212
|
+
* response was lost, so a change on disk may be that save's own commit.
|
|
213
|
+
*/
|
|
214
|
+
const [changeNotice, setChangeNotice] = useState<
|
|
215
|
+
"outside" | "unconfirmed" | null
|
|
212
216
|
>(null);
|
|
213
217
|
const [historyEntries, setHistoryEntries] = useState<LocalHistoryEntry[]>([]);
|
|
214
218
|
const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
|
|
@@ -322,12 +326,21 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
322
326
|
// read the current revision and last committed content from refs.
|
|
323
327
|
const docRef = useRef(doc);
|
|
324
328
|
const committedContentRef = useRef(originalContent);
|
|
329
|
+
const draftRef = useRef(content);
|
|
325
330
|
useEffect(() => {
|
|
326
331
|
docRef.current = doc;
|
|
327
332
|
}, [doc]);
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
draftRef.current = content;
|
|
335
|
+
}, [content]);
|
|
328
336
|
// One request ID per (document revision, content) save intent: retrying a
|
|
329
337
|
// save whose response was lost replays it instead of reporting a conflict.
|
|
330
338
|
const saveIntentRef = useRef<RequestIntent | null>(null);
|
|
339
|
+
// Content of a save that is in flight or whose response was lost: its
|
|
340
|
+
// commit is unknown, so change events are held until a response says whose
|
|
341
|
+
// change they were. Retry save re-sends exactly this content.
|
|
342
|
+
const unknownSaveRef = useRef<string | null>(null);
|
|
343
|
+
const changedWhileUnknownRef = useRef(false);
|
|
331
344
|
|
|
332
345
|
/** Save `contentToSave`; resolves true once it is committed on disk. */
|
|
333
346
|
const persistContent = useCallback(
|
|
@@ -341,8 +354,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
341
354
|
|
|
342
355
|
setSaveStatus("saving");
|
|
343
356
|
setSaveError(null);
|
|
357
|
+
unknownSaveRef.current = contentToSave;
|
|
344
358
|
|
|
345
|
-
const {
|
|
359
|
+
const {
|
|
360
|
+
data,
|
|
361
|
+
error: err,
|
|
362
|
+
outcomeUnknown,
|
|
363
|
+
} = await apiFetch<UpdateDocResponse>(
|
|
346
364
|
`/api/docs/${encodeURIComponent(current.docid)}`,
|
|
347
365
|
{
|
|
348
366
|
method: "PUT",
|
|
@@ -363,23 +381,45 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
363
381
|
if (err) {
|
|
364
382
|
setSaveStatus("error");
|
|
365
383
|
setSaveError(err);
|
|
384
|
+
if (outcomeUnknown) {
|
|
385
|
+
// Never downgrade a warning about a change from elsewhere.
|
|
386
|
+
setChangeNotice((notice) =>
|
|
387
|
+
notice === "outside" ? notice : "unconfirmed"
|
|
388
|
+
);
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
// A definitive rejection: this save did not commit, so a change seen
|
|
392
|
+
// meanwhile came from elsewhere.
|
|
393
|
+
unknownSaveRef.current = null;
|
|
394
|
+
if (changedWhileUnknownRef.current) {
|
|
395
|
+
changedWhileUnknownRef.current = false;
|
|
396
|
+
setChangeNotice("outside");
|
|
397
|
+
} else {
|
|
398
|
+
setChangeNotice((notice) =>
|
|
399
|
+
notice === "unconfirmed" ? null : notice
|
|
400
|
+
);
|
|
401
|
+
}
|
|
366
402
|
return false;
|
|
367
403
|
}
|
|
368
404
|
|
|
405
|
+
unknownSaveRef.current = null;
|
|
406
|
+
changedWhileUnknownRef.current = false;
|
|
369
407
|
if (data?.request?.replayed) {
|
|
370
408
|
// A replay reports an earlier commit; disk may have moved on since.
|
|
371
|
-
//
|
|
409
|
+
// Only a read showing a different hash is evidence of another writer.
|
|
372
410
|
const { data: latest } = await apiFetch<DocData>(
|
|
373
411
|
`/api/doc?uri=${encodeURIComponent(current.uri)}`
|
|
374
412
|
);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
413
|
+
setChangeNotice(
|
|
414
|
+
latest && latest.source.sourceHash !== data.version.sourceHash
|
|
415
|
+
? "outside"
|
|
416
|
+
: null
|
|
417
|
+
);
|
|
378
418
|
} else {
|
|
379
419
|
// Written now against the loaded revision: the next change event is
|
|
380
420
|
// this save's own sync, and any earlier notice is stale.
|
|
381
421
|
ignoreDocEventsUntilRef.current = Date.now() + 5_000;
|
|
382
|
-
|
|
422
|
+
setChangeNotice(null);
|
|
383
423
|
}
|
|
384
424
|
const previous = committedContentRef.current;
|
|
385
425
|
if (previous !== contentToSave) {
|
|
@@ -589,6 +629,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
589
629
|
}
|
|
590
630
|
}, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
|
|
591
631
|
|
|
632
|
+
// Retry the unconfirmed save itself (same content, same request ID), even
|
|
633
|
+
// when the draft has since changed or returned to the loaded text; once it
|
|
634
|
+
// is confirmed, the draft as it is then is saved on top of it.
|
|
635
|
+
const retryUnconfirmedSave = useCallback(async () => {
|
|
636
|
+
const pending = unknownSaveRef.current;
|
|
637
|
+
if (pending === null) return;
|
|
638
|
+
cancelAutosave();
|
|
639
|
+
if (await persistContent(pending)) {
|
|
640
|
+
await persistContent(draftRef.current);
|
|
641
|
+
}
|
|
642
|
+
}, [cancelAutosave, persistContent]);
|
|
643
|
+
|
|
592
644
|
const loadDocument = useCallback(() => {
|
|
593
645
|
const uri = currentTarget.uri;
|
|
594
646
|
|
|
@@ -637,16 +689,20 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
637
689
|
return;
|
|
638
690
|
}
|
|
639
691
|
handledDocEventRef.current = latestDocEvent.changedAt;
|
|
692
|
+
if (unknownSaveRef.current !== null) {
|
|
693
|
+
changedWhileUnknownRef.current = true;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
640
696
|
if (Date.now() < ignoreDocEventsUntilRef.current) {
|
|
641
697
|
return;
|
|
642
698
|
}
|
|
643
|
-
|
|
644
|
-
"This document changed on disk. Reload before continuing."
|
|
645
|
-
);
|
|
699
|
+
setChangeNotice("outside");
|
|
646
700
|
}, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]);
|
|
647
701
|
|
|
648
702
|
const reloadDocument = useCallback(() => {
|
|
649
|
-
|
|
703
|
+
unknownSaveRef.current = null;
|
|
704
|
+
changedWhileUnknownRef.current = false;
|
|
705
|
+
setChangeNotice(null);
|
|
650
706
|
loadDocument();
|
|
651
707
|
}, [loadDocument]);
|
|
652
708
|
|
|
@@ -1136,10 +1192,31 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1136
1192
|
</p>
|
|
1137
1193
|
)}
|
|
1138
1194
|
|
|
1139
|
-
{
|
|
1195
|
+
{changeNotice === "unconfirmed" && (
|
|
1196
|
+
<div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
|
|
1197
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
1198
|
+
<p className="text-amber-500 text-sm">
|
|
1199
|
+
The save may have completed, but its response was lost. Retry to
|
|
1200
|
+
confirm it.
|
|
1201
|
+
</p>
|
|
1202
|
+
<Button
|
|
1203
|
+
disabled={saveStatus === "saving"}
|
|
1204
|
+
onClick={retryUnconfirmedSave}
|
|
1205
|
+
size="sm"
|
|
1206
|
+
variant="outline"
|
|
1207
|
+
>
|
|
1208
|
+
Retry save
|
|
1209
|
+
</Button>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
)}
|
|
1213
|
+
|
|
1214
|
+
{changeNotice === "outside" && (
|
|
1140
1215
|
<div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
|
|
1141
1216
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
1142
|
-
<p className="text-amber-500 text-sm">
|
|
1217
|
+
<p className="text-amber-500 text-sm">
|
|
1218
|
+
This document changed on disk. Reload before continuing.
|
|
1219
|
+
</p>
|
|
1143
1220
|
<Button onClick={reloadDocument} size="sm" variant="outline">
|
|
1144
1221
|
Reload
|
|
1145
1222
|
</Button>
|
|
@@ -50,50 +50,10 @@ import {
|
|
|
50
50
|
fetchServerCapabilities,
|
|
51
51
|
type ServerCapabilities,
|
|
52
52
|
} from "../lib/server-capabilities";
|
|
53
|
+
import { renderSnippet } from "../lib/snippet";
|
|
53
54
|
import { cn } from "../lib/utils";
|
|
54
55
|
import { AIModelSelector, TagFacets } from "./search-page-widgets";
|
|
55
56
|
|
|
56
|
-
/**
|
|
57
|
-
* Render snippet with <mark> tags as highlighted spans.
|
|
58
|
-
* Only allows mark tags - strips all other HTML for safety.
|
|
59
|
-
*/
|
|
60
|
-
function renderSnippet(snippet: string): React.ReactNode {
|
|
61
|
-
const parts: React.ReactNode[] = [];
|
|
62
|
-
let remaining = snippet;
|
|
63
|
-
let key = 0;
|
|
64
|
-
|
|
65
|
-
while (remaining.length > 0) {
|
|
66
|
-
const markStart = remaining.indexOf("<mark>");
|
|
67
|
-
if (markStart === -1) {
|
|
68
|
-
parts.push(remaining);
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (markStart > 0) {
|
|
73
|
-
parts.push(remaining.slice(0, markStart));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const markEnd = remaining.indexOf("</mark>", markStart);
|
|
77
|
-
if (markEnd === -1) {
|
|
78
|
-
parts.push(remaining.slice(markStart));
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const highlighted = remaining.slice(markStart + 6, markEnd);
|
|
83
|
-
parts.push(
|
|
84
|
-
<mark
|
|
85
|
-
className="rounded bg-primary/20 px-0.5 font-medium text-primary"
|
|
86
|
-
key={key++}
|
|
87
|
-
>
|
|
88
|
-
{highlighted}
|
|
89
|
-
</mark>
|
|
90
|
-
);
|
|
91
|
-
remaining = remaining.slice(markEnd + 7);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return parts;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
57
|
interface PageProps {
|
|
98
58
|
navigate: (to: string | number) => void;
|
|
99
59
|
}
|
|
@@ -45,9 +45,11 @@ import { JobManager } from "../core/job-manager";
|
|
|
45
45
|
import { recordContentMutation } from "../core/mutation-generations";
|
|
46
46
|
import {
|
|
47
47
|
shutdownDuration,
|
|
48
|
+
RESIDENT_BUSY_TIMEOUT_MS,
|
|
48
49
|
SHUTDOWN_DRAIN_MS,
|
|
49
50
|
SHUTDOWN_ABORT_MS,
|
|
50
51
|
} from "../core/shutdown-budget";
|
|
52
|
+
import { acquireCliWriteLease } from "../core/write-lease";
|
|
51
53
|
import { defaultSyncService, withContentTypeRules } from "../ingestion";
|
|
52
54
|
import { withOwnedInferenceScope } from "../llm/inference-scope";
|
|
53
55
|
import { getActivePreset } from "../llm/registry";
|
|
@@ -60,7 +62,7 @@ import {
|
|
|
60
62
|
disposeServerContext,
|
|
61
63
|
type ServerContext,
|
|
62
64
|
} from "./context";
|
|
63
|
-
import { createEmbedScheduler } from "./embed-scheduler";
|
|
65
|
+
import { createEmbedScheduler, embedSchedulerIssues } from "./embed-scheduler";
|
|
64
66
|
import { FindingsScheduler, type FindingsPassResult } from "./findings-pass";
|
|
65
67
|
import { AdmissionController, ReaderGate } from "./resident-admission";
|
|
66
68
|
import { ResidentBackgroundWork } from "./resident-background-work";
|
|
@@ -189,10 +191,25 @@ export type ResidentRuntimeDeps = {
|
|
|
189
191
|
eventBus?: DocumentEventBus | null;
|
|
190
192
|
callbacks?: CollectionWatchCallbacks;
|
|
191
193
|
syncOptions?: Parameters<typeof withContentTypeRules>[0];
|
|
194
|
+
acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
|
|
192
195
|
}) => CollectionWatchService;
|
|
193
196
|
modelManagerFactory?: (config: Config) => ModelManager;
|
|
194
197
|
};
|
|
195
198
|
|
|
199
|
+
/** No-wait shared writer lease for background writes; null when held elsewhere. */
|
|
200
|
+
async function acquireBackgroundWriteLease(
|
|
201
|
+
dbPath: string,
|
|
202
|
+
command: string
|
|
203
|
+
): Promise<(() => Promise<void>) | null> {
|
|
204
|
+
const lease = await acquireCliWriteLease({
|
|
205
|
+
dbPath,
|
|
206
|
+
waitMs: 0,
|
|
207
|
+
noWait: true,
|
|
208
|
+
command,
|
|
209
|
+
});
|
|
210
|
+
return lease.ok ? lease.release : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
196
213
|
export async function startResidentRuntime(
|
|
197
214
|
options: ResidentRuntimeOptions = {},
|
|
198
215
|
deps: ResidentRuntimeDeps = {}
|
|
@@ -274,6 +291,11 @@ export async function startResidentRuntime(
|
|
|
274
291
|
if (!syncCollections.ok) return failStartup(syncCollections.error.message);
|
|
275
292
|
const syncContexts = await store.syncContexts(initialConfig.contexts ?? []);
|
|
276
293
|
if (!syncContexts.ok) return failStartup(syncContexts.error.message);
|
|
294
|
+
// SQLite busy waits block this event loop; keep each one short enough that
|
|
295
|
+
// a stop signal is still handled within the stop grace.
|
|
296
|
+
store.setBusyTimeout?.(RESIDENT_BUSY_TIMEOUT_MS);
|
|
297
|
+
const leaseFor = (command: string) => () =>
|
|
298
|
+
acquireBackgroundWriteLease(dbPath, command);
|
|
277
299
|
|
|
278
300
|
let ctx: ServerContext;
|
|
279
301
|
try {
|
|
@@ -322,6 +344,7 @@ export async function startResidentRuntime(
|
|
|
322
344
|
onEmbedded: () => {
|
|
323
345
|
generations.index += 1;
|
|
324
346
|
},
|
|
347
|
+
acquireWriteLease: leaseFor(`gno ${mode} (background embed)`),
|
|
325
348
|
});
|
|
326
349
|
ctxHolder.scheduler = scheduler;
|
|
327
350
|
ctxHolder.current.scheduler = scheduler;
|
|
@@ -351,8 +374,16 @@ export async function startResidentRuntime(
|
|
|
351
374
|
},
|
|
352
375
|
},
|
|
353
376
|
syncOptions: withContentTypeRules({}, initialConfig),
|
|
377
|
+
acquireWriteLease: leaseFor(`gno ${mode} (watch sync)`),
|
|
354
378
|
});
|
|
355
379
|
watchService.start();
|
|
380
|
+
// A backlog left by `--no-embed` or an earlier run gets a pass without
|
|
381
|
+
// waiting for a file change.
|
|
382
|
+
const startupStatus = await store.getStatus({
|
|
383
|
+
embedModel: getActivePreset(initialConfig).embed,
|
|
384
|
+
});
|
|
385
|
+
if (startupStatus.ok && startupStatus.value.embeddingBacklog > 0)
|
|
386
|
+
scheduler.notifySyncComplete([]);
|
|
356
387
|
ctxHolder.watchService = watchService;
|
|
357
388
|
ctxHolder.current.watchService = watchService;
|
|
358
389
|
|
|
@@ -590,6 +621,7 @@ export async function startResidentRuntime(
|
|
|
590
621
|
failed: jobs.recent.filter((job) => job.status === "failed").length,
|
|
591
622
|
},
|
|
592
623
|
generations: { ...generations },
|
|
624
|
+
backgroundIssues: embedSchedulerIssues(scheduler.getState()),
|
|
593
625
|
});
|
|
594
626
|
},
|
|
595
627
|
setListenerPort(port) {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/** Safe, transport-neutral resident lifecycle status projection. */
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
BackgroundIssue,
|
|
5
|
+
ResidentStatus,
|
|
6
|
+
RuntimeMode,
|
|
7
|
+
} from "./status-model";
|
|
4
8
|
|
|
5
9
|
const EMPTY_MODELS: ResidentStatus["models"] = {
|
|
6
10
|
activeLeases: 0,
|
|
@@ -50,6 +54,7 @@ export interface ResidentStatusSnapshotInput {
|
|
|
50
54
|
models: ResidentStatus["models"];
|
|
51
55
|
jobs: ResidentStatus["jobs"];
|
|
52
56
|
generations: ResidentStatus["generations"];
|
|
57
|
+
backgroundIssues?: BackgroundIssue[];
|
|
53
58
|
now?: number;
|
|
54
59
|
}
|
|
55
60
|
|
|
@@ -72,6 +77,13 @@ export function buildResidentStatusSnapshot(
|
|
|
72
77
|
models: { ...input.models },
|
|
73
78
|
jobs: { ...input.jobs },
|
|
74
79
|
generations: { ...input.generations },
|
|
80
|
+
...(input.backgroundIssues?.length
|
|
81
|
+
? {
|
|
82
|
+
backgroundIssues: input.backgroundIssues.map((issue) => ({
|
|
83
|
+
...issue,
|
|
84
|
+
})),
|
|
85
|
+
}
|
|
86
|
+
: {}),
|
|
75
87
|
};
|
|
76
88
|
}
|
|
77
89
|
|