@gmickel/gno 2.5.1 → 2.7.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.
Files changed (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -69,6 +69,15 @@ export function isRequestAllowed(req: Request, port: number): boolean {
69
69
  }
70
70
 
71
71
  // Unsafe methods - require valid Origin or token
72
+ return isOriginOrTokenAllowed(req, port);
73
+ }
74
+
75
+ /**
76
+ * Origin/token check without the safe-method exemption. Owner-only reads
77
+ * that return host paths use it so a cross-origin page is refused on GET
78
+ * the same way as on POST.
79
+ */
80
+ export function isOriginOrTokenAllowed(req: Request, port: number): boolean {
72
81
  return validateToken(req) || validateOrigin(req, port);
73
82
  }
74
83
 
@@ -1,11 +1,4 @@
1
1
  import type { HttpGatewayOverrides } from "../mcp/http-security";
2
- /**
3
- * Bun.serve() web server for GNO web UI.
4
- * Uses Bun's fullstack dev server with HTML imports.
5
- * Opens DB once at startup, closes on shutdown.
6
- *
7
- * @module src/serve/server
8
- */
9
2
  import type { RequestPeerServer } from "./request-locality";
10
3
  import type { ResidentRuntime } from "./resident-runtime";
11
4
  import type { ContextHolder } from "./routes/api";
@@ -23,6 +16,14 @@ import {
23
16
  handlePdfjsVendorRequest,
24
17
  isPdfjsVendorPath,
25
18
  } from "./fn112-routes";
19
+ /**
20
+ * Bun.serve() web server for GNO web UI.
21
+ * Uses Bun's fullstack dev server with HTML imports.
22
+ * Opens DB once at startup, closes on shutdown.
23
+ *
24
+ * @module src/serve/server
25
+ */
26
+ import { withRemoteHostPathRedaction } from "./host-path-redaction";
26
27
  import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
27
28
  // HTML import - Bun handles bundling TSX/CSS automatically via routes
28
29
  import homepage from "./public/index.html";
@@ -82,6 +83,7 @@ import {
82
83
  handleTrashDoc,
83
84
  handleUpdateCollection,
84
85
  handleUpdateCollectionEgressPolicy,
86
+ handleRequestStatus,
85
87
  handleUpdateDoc,
86
88
  handleVerifyConnector,
87
89
  } from "./routes/api";
@@ -101,6 +103,24 @@ import {
101
103
  handleCreateSectionTarget,
102
104
  handleResolveSectionTarget,
103
105
  } from "./routes/section-targets";
106
+ import {
107
+ handleSessionsAddSource,
108
+ handleSessionsAutomationDisable,
109
+ handleSessionsAutomationEnable,
110
+ handleSessionsAutomationPreview,
111
+ handleSessionsAutomationRemove,
112
+ handleSessionsAutomationRun,
113
+ handleSessionsAutomationSet,
114
+ handleSessionsDiscover,
115
+ handleSessionsImport,
116
+ handleSessionsInit,
117
+ handleSessionsRemoveSource,
118
+ handleSessionsStatus,
119
+ } from "./routes/sessions";
120
+
121
+ /** `/api/sessions/automation/:id[/...]` path parameter. */
122
+ const automationProfileId = (req: Request): string =>
123
+ decodeURIComponent(new URL(req.url).pathname.split("/")[4] ?? "");
104
124
  import {
105
125
  handleTraceDelete,
106
126
  handleTraceExport,
@@ -109,7 +129,11 @@ import {
109
129
  handleTracePurge,
110
130
  handleTraceShow,
111
131
  } from "./routes/traces";
112
- import { forbiddenResponse, isRequestAllowed } from "./security";
132
+ import {
133
+ forbiddenResponse,
134
+ isOriginOrTokenAllowed,
135
+ isRequestAllowed,
136
+ } from "./security";
113
137
  import {
114
138
  createSpaBundleSource,
115
139
  type SpaBundleSource,
@@ -548,7 +572,7 @@ export async function startServer(
548
572
  development: isDev,
549
573
 
550
574
  // Static routes - Bun handles HTML bundling and /_bun/* assets automatically
551
- routes: {
575
+ routes: withRemoteHostPathRedaction({
552
576
  "/mcp": gateway.route,
553
577
  ...clipperRoutesForBind(
554
578
  isHttpGatewayLoopbackBind(gatewayConfig.host),
@@ -566,6 +590,7 @@ export async function startServer(
566
590
  "/collections": spaPageRoute,
567
591
  "/connectors": spaPageRoute,
568
592
  "/traces": spaPageRoute,
593
+ "/sessions": spaPageRoute,
569
594
  "/context/compiled": spaPageRoute,
570
595
  "/ask": spaPageRoute,
571
596
  "/graph": spaPageRoute,
@@ -833,6 +858,174 @@ export async function startServer(
833
858
  );
834
859
  },
835
860
  },
861
+ "/api/sessions/status": {
862
+ GET: async (req: Request) =>
863
+ withSecurityHeaders(
864
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
865
+ handleSessionsStatus(ctxHolder)
866
+ ),
867
+ isDev
868
+ ),
869
+ },
870
+ "/api/sessions/discover": {
871
+ GET: async (req: Request, server: RequestPeerServer) => {
872
+ // Returns host paths: a cross-origin page is refused like a write.
873
+ if (!isOriginOrTokenAllowed(req, port)) {
874
+ return withSecurityHeaders(forbiddenResponse(), isDev);
875
+ }
876
+ return withSecurityHeaders(
877
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
878
+ handleSessionsDiscover(ctxHolder, req, { server })
879
+ ),
880
+ isDev
881
+ );
882
+ },
883
+ },
884
+ "/api/sessions/import": {
885
+ POST: async (req: Request) => {
886
+ if (!isRequestAllowed(req, port)) {
887
+ return withSecurityHeaders(forbiddenResponse(), isDev);
888
+ }
889
+ return withSecurityHeaders(
890
+ await handleSessionsImport(ctxHolder, req),
891
+ isDev
892
+ );
893
+ },
894
+ },
895
+ "/api/sessions/sources": {
896
+ POST: async (req: Request, server: RequestPeerServer) => {
897
+ if (!isRequestAllowed(req, port)) {
898
+ return withSecurityHeaders(forbiddenResponse(), isDev);
899
+ }
900
+ return withSecurityHeaders(
901
+ await handleSessionsAddSource(ctxHolder, store, req, { server }),
902
+ isDev
903
+ );
904
+ },
905
+ },
906
+ "/api/sessions/sources/:id": {
907
+ DELETE: async (req: Request, server: RequestPeerServer) => {
908
+ if (!isRequestAllowed(req, port)) {
909
+ return withSecurityHeaders(forbiddenResponse(), isDev);
910
+ }
911
+ const id = decodeURIComponent(
912
+ new URL(req.url).pathname.split("/")[4] ?? ""
913
+ );
914
+ return withSecurityHeaders(
915
+ await handleSessionsRemoveSource(ctxHolder, store, id, req, {
916
+ server,
917
+ }),
918
+ isDev
919
+ );
920
+ },
921
+ },
922
+ "/api/sessions/automation/run": {
923
+ POST: async (req: Request) => {
924
+ if (!isRequestAllowed(req, port)) {
925
+ return withSecurityHeaders(forbiddenResponse(), isDev);
926
+ }
927
+ return withSecurityHeaders(
928
+ await handleSessionsAutomationRun(ctxHolder, store, req),
929
+ isDev
930
+ );
931
+ },
932
+ },
933
+ "/api/sessions/automation/:id": {
934
+ PUT: async (req: Request, server: RequestPeerServer) => {
935
+ if (!isRequestAllowed(req, port)) {
936
+ return withSecurityHeaders(forbiddenResponse(), isDev);
937
+ }
938
+ return withSecurityHeaders(
939
+ await handleSessionsAutomationSet(
940
+ ctxHolder,
941
+ store,
942
+ automationProfileId(req),
943
+ req,
944
+ { server }
945
+ ),
946
+ isDev
947
+ );
948
+ },
949
+ DELETE: async (req: Request, server: RequestPeerServer) => {
950
+ if (!isRequestAllowed(req, port)) {
951
+ return withSecurityHeaders(forbiddenResponse(), isDev);
952
+ }
953
+ return withSecurityHeaders(
954
+ await handleSessionsAutomationRemove(
955
+ ctxHolder,
956
+ store,
957
+ automationProfileId(req),
958
+ req,
959
+ { server }
960
+ ),
961
+ isDev
962
+ );
963
+ },
964
+ },
965
+ "/api/sessions/automation/:id/preview": {
966
+ GET: async (req: Request, server: RequestPeerServer) =>
967
+ !isOriginOrTokenAllowed(req, port)
968
+ ? withSecurityHeaders(forbiddenResponse(), isDev)
969
+ : withSecurityHeaders(
970
+ await handleResidentRead(
971
+ runtime as ResidentRuntime,
972
+ req,
973
+ () =>
974
+ handleSessionsAutomationPreview(
975
+ ctxHolder,
976
+ automationProfileId(req),
977
+ req,
978
+ { server }
979
+ )
980
+ ),
981
+ isDev
982
+ ),
983
+ },
984
+ "/api/sessions/automation/:id/enable": {
985
+ POST: async (req: Request, server: RequestPeerServer) => {
986
+ if (!isRequestAllowed(req, port)) {
987
+ return withSecurityHeaders(forbiddenResponse(), isDev);
988
+ }
989
+ return withSecurityHeaders(
990
+ await handleSessionsAutomationEnable(
991
+ ctxHolder,
992
+ store,
993
+ automationProfileId(req),
994
+ req,
995
+ { server }
996
+ ),
997
+ isDev
998
+ );
999
+ },
1000
+ },
1001
+ "/api/sessions/automation/:id/disable": {
1002
+ POST: async (req: Request, server: RequestPeerServer) => {
1003
+ if (!isRequestAllowed(req, port)) {
1004
+ return withSecurityHeaders(forbiddenResponse(), isDev);
1005
+ }
1006
+ return withSecurityHeaders(
1007
+ await handleSessionsAutomationDisable(
1008
+ ctxHolder,
1009
+ store,
1010
+ automationProfileId(req),
1011
+ req,
1012
+ { server }
1013
+ ),
1014
+ isDev
1015
+ );
1016
+ },
1017
+ },
1018
+ "/api/sessions/init": {
1019
+ POST: async (req: Request, server: RequestPeerServer) => {
1020
+ if (!isRequestAllowed(req, port)) {
1021
+ return withSecurityHeaders(forbiddenResponse(), isDev);
1022
+ }
1023
+ return withSecurityHeaders(
1024
+ await handleSessionsInit(ctxHolder, store, req, { server }),
1025
+ isDev
1026
+ );
1027
+ },
1028
+ },
836
1029
  "/api/memory/remember": {
837
1030
  POST: async (req: Request) => {
838
1031
  if (!isRequestAllowed(req, port)) {
@@ -1046,6 +1239,18 @@ export async function startServer(
1046
1239
  );
1047
1240
  },
1048
1241
  },
1242
+ "/api/requests/:requestId": {
1243
+ GET: async (req: Request) => {
1244
+ const url = new URL(req.url);
1245
+ const requestId = decodeURIComponent(
1246
+ url.pathname.split("/").pop() || ""
1247
+ );
1248
+ return withSecurityHeaders(
1249
+ await handleRequestStatus(store, requestId),
1250
+ isDev
1251
+ );
1252
+ },
1253
+ },
1049
1254
  "/api/doc": {
1050
1255
  GET: async (req: Request) => {
1051
1256
  const url = new URL(req.url);
@@ -1539,7 +1744,7 @@ export async function startServer(
1539
1744
  );
1540
1745
  },
1541
1746
  },
1542
- },
1747
+ }),
1543
1748
  // Production catch-all: /vendor/pdfjs prefix, then hashed SPA chunks
1544
1749
  // (gzip + immutable) and the private SPA source — the same factory the
1545
1750
  // tests mount (no test-only fallback path).
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Daemon-only session automation tick: heartbeat, coalesced schedule
3
+ * admissions and a drain of pending profiles through the manual importer.
4
+ *
5
+ * Runs only in `gno daemon` on a session-archive config (never in `serve`).
6
+ * One tick at a time over the daemon's background-work tracker; the owner
7
+ * config is reread every tick, so enable/disable take effect without a
8
+ * restart. Imports take the shared write lease; a busy lease backs off.
9
+ */
10
+
11
+ import type { SessionAutomationRunResult } from "../sessions/types";
12
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
13
+
14
+ import { acquireCliWriteLease } from "../core/write-lease";
15
+ import { heartbeatAutomation, tickAutomation } from "../sessions/automation";
16
+ import { AUTOMATION_TICK_MS } from "../sessions/automation-state";
17
+
18
+ const LEASE_HOLDER_COMMAND = "gno daemon (session automation)";
19
+
20
+ /**
21
+ * The importer syncs the lexical index itself, so the resident watcher later
22
+ * sees unchanged files and never queues embedding: do it here for the
23
+ * collections a run synced, and mark the mutation (as the REST import does).
24
+ */
25
+ export function notifyAutomationImport(
26
+ result: SessionAutomationRunResult,
27
+ sink: {
28
+ markMutation: () => void;
29
+ notifySyncComplete: (collections: string[]) => void;
30
+ }
31
+ ): void {
32
+ const collections = [
33
+ ...new Set(
34
+ result.receipts.flatMap((receipt) => receipt.lexical.collections)
35
+ ),
36
+ ];
37
+ if (collections.length === 0) return;
38
+ sink.markMutation();
39
+ sink.notifySyncComplete(collections);
40
+ }
41
+
42
+ export interface SessionAutomationSchedulerOptions {
43
+ store: SqliteAdapter;
44
+ configPath: string;
45
+ indexName: string;
46
+ dbPath: string;
47
+ startBackgroundWork: (
48
+ operation: (signal: AbortSignal) => Promise<void>
49
+ ) => boolean;
50
+ /** Called after every run; the daemon logs content-free counts. */
51
+ onResult?: (result: SessionAutomationRunResult) => void;
52
+ onError?: (error: unknown) => void;
53
+ now?: () => Date;
54
+ tickMs?: number;
55
+ }
56
+
57
+ export class SessionAutomationScheduler {
58
+ readonly #options: SessionAutomationSchedulerOptions;
59
+ readonly #startedAt: Date;
60
+ #timer: ReturnType<typeof setTimeout> | null = null;
61
+ #heartbeat: ReturnType<typeof setInterval> | null = null;
62
+ #running: Promise<void> | null = null;
63
+ #disposed = false;
64
+
65
+ constructor(options: SessionAutomationSchedulerOptions) {
66
+ this.#options = options;
67
+ this.#startedAt = (options.now ?? (() => new Date()))();
68
+ }
69
+
70
+ /** Drain once at startup, then tick; heartbeat independently of ticks. */
71
+ start(): void {
72
+ this.#beat();
73
+ this.#heartbeat = setInterval(
74
+ () => this.#beat(),
75
+ this.#options.tickMs ?? AUTOMATION_TICK_MS
76
+ );
77
+ this.#heartbeat.unref?.();
78
+ this.#schedule(0);
79
+ }
80
+
81
+ /** A tick or import in progress must not let the heartbeat go stale. */
82
+ #beat(): void {
83
+ if (this.#disposed) return;
84
+ const options = this.#options;
85
+ heartbeatAutomation({
86
+ configPath: options.configPath,
87
+ indexName: options.indexName,
88
+ now: options.now,
89
+ daemonStartedAt: this.#startedAt,
90
+ }).catch((error: unknown) => options.onError?.(error));
91
+ }
92
+
93
+ /** One tick; coalesces with a tick already in flight. */
94
+ tick(): Promise<void> {
95
+ if (this.#running) return this.#running;
96
+ const options = this.#options;
97
+ const run = tickAutomation({
98
+ configPath: options.configPath,
99
+ indexName: options.indexName,
100
+ store: options.store,
101
+ now: options.now,
102
+ daemonStartedAt: this.#startedAt,
103
+ // The daemon serves MCP and status over HTTP: keep its loop free.
104
+ inChildProcess: true,
105
+ acquireLease: async () => {
106
+ const lease = await acquireCliWriteLease({
107
+ dbPath: options.dbPath,
108
+ waitMs: 0,
109
+ noWait: true,
110
+ command: LEASE_HOLDER_COMMAND,
111
+ });
112
+ return lease.ok ? { ok: true, release: lease.release } : { ok: false };
113
+ },
114
+ })
115
+ .then((results) => {
116
+ for (const result of results) options.onResult?.(result);
117
+ })
118
+ .catch((error: unknown) => options.onError?.(error))
119
+ .finally(() => {
120
+ this.#running = null;
121
+ this.#schedule(options.tickMs ?? AUTOMATION_TICK_MS);
122
+ });
123
+ this.#running = run;
124
+ return run;
125
+ }
126
+
127
+ dispose(): void {
128
+ this.#disposed = true;
129
+ if (this.#heartbeat) clearInterval(this.#heartbeat);
130
+ this.#heartbeat = null;
131
+ if (this.#timer) clearTimeout(this.#timer);
132
+ this.#timer = null;
133
+ }
134
+
135
+ #schedule(delayMs: number): void {
136
+ if (this.#disposed) return;
137
+ if (this.#timer) clearTimeout(this.#timer);
138
+ this.#timer = setTimeout(() => {
139
+ this.#timer = null;
140
+ if (this.#disposed || this.#running) return;
141
+ const started = this.#options.startBackgroundWork(() => this.tick());
142
+ if (!started) this.#schedule(this.#options.tickMs ?? AUTOMATION_TICK_MS);
143
+ }, delayMs);
144
+ this.#timer.unref?.();
145
+ }
146
+ }
@@ -1,6 +1,10 @@
1
1
  import type { ContentTypeBoostStatus } from "../config/content-types";
2
2
  import type { ActivationStatus } from "../core/activation-status";
3
3
  import type { ChunkingStatus } from "../store/chunking";
4
+ import type {
5
+ VectorPartitionStatus,
6
+ VectorRuntimeStatus,
7
+ } from "../store/vector/status";
4
8
 
5
9
  export type HealthCheckStatus = "ok" | "warn" | "error";
6
10
 
@@ -109,6 +113,16 @@ export interface ResidentStatus {
109
113
  content: number;
110
114
  index: number;
111
115
  };
116
+ /** Present only while a background job is in trouble. */
117
+ backgroundIssues?: BackgroundIssue[];
118
+ }
119
+
120
+ /** A resident background job that keeps failing, has stopped retrying, or overruns. */
121
+ export interface BackgroundIssue {
122
+ job: "embed" | "resident";
123
+ state: "failing" | "parked" | "overrunning" | "unresponsive";
124
+ consecutiveFailures: number;
125
+ runningSeconds: number | null;
112
126
  }
113
127
 
114
128
  export interface BackgroundServiceState {
@@ -186,6 +200,8 @@ export interface AppStatusResponse {
186
200
  totalDocuments: number;
187
201
  totalChunks: number;
188
202
  embeddingBacklog: number;
203
+ vectorPartitions?: VectorPartitionStatus[];
204
+ vectorRuntime?: VectorRuntimeStatus;
189
205
  lastUpdated: string | null;
190
206
  recentErrors: number;
191
207
  healthy: boolean;
@@ -734,6 +734,8 @@ export async function buildAppStatus(
734
734
  totalDocuments: status.activeDocuments,
735
735
  totalChunks: status.totalChunks,
736
736
  embeddingBacklog: status.embeddingBacklog,
737
+ vectorPartitions: status.vectorPartitions,
738
+ vectorRuntime: status.vectorRuntime,
737
739
  chunking: status.chunking,
738
740
  lastUpdated: status.lastUpdatedAt,
739
741
  recentErrors: status.recentErrors,
@@ -45,6 +45,9 @@ export const WATCHER_MAX_SUPPRESSION_ENTRIES = 4_096;
45
45
  /** Bounded retry delay after failed classification/sync. */
46
46
  export const WATCHER_RETRY_BACKOFF_MS = 500;
47
47
 
48
+ /** Retry delay while another writer holds the shared writer lease. */
49
+ export const WATCHER_LEASE_RETRY_MS = 5_000;
50
+
48
51
  /**
49
52
  * Single fixed budget for fallback classification across visited directories,
50
53
  * candidates, removals, dirty dirs, and aggregate store rows.
@@ -219,7 +219,8 @@ export function requeueAfterFailure(
219
219
  collectionName: string,
220
220
  exact: string[],
221
221
  dirty: string[],
222
- forceFlags?: PendingForceFlags
222
+ forceFlags?: PendingForceFlags,
223
+ delayMs = WATCHER_RETRY_BACKOFF_MS
223
224
  ): void {
224
225
  queueWithoutSchedule(host, collectionName, exact, dirty, forceFlags);
225
226
  if (host.disposed()) {
@@ -241,7 +242,7 @@ export function requeueAfterFailure(
241
242
  host.timers.delete(collectionName);
242
243
  host.retryScheduled.delete(collectionName);
243
244
  startFlush(host, collectionName);
244
- }, WATCHER_RETRY_BACKOFF_MS)
245
+ }, delayMs)
245
246
  );
246
247
  }
247
248
 
@@ -14,6 +14,7 @@ import type { WatchQueueHost } from "./watch-service-events";
14
14
  import type { CollectionPending } from "./watch-service-state";
15
15
  import type { WatcherSnapshot, WatcherSnapshotFs } from "./watch-snapshot";
16
16
 
17
+ import { WATCHER_LEASE_RETRY_MS } from "./watch-reconciliation-shared";
17
18
  import {
18
19
  requeueAfterFailure,
19
20
  requeueGenerationReconcile,
@@ -65,6 +66,11 @@ export interface RunFlushContext {
65
66
  snapshotFs?: WatcherSnapshotFs;
66
67
  /** Test seam: lower snapshot entry ceiling for overflow→full proofs. */
67
68
  snapshotEntryCeiling?: number;
69
+ /**
70
+ * Shared writer lease for this flush; null when another writer holds it,
71
+ * which leaves the work queued for the retry timer instead of waiting.
72
+ */
73
+ acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
68
74
  }
69
75
 
70
76
  /**
@@ -86,19 +92,44 @@ export async function runOwnedCollectionFlush(
86
92
  return;
87
93
  }
88
94
 
95
+ ctx.syncing.add(collectionName);
96
+ let releaseLease: (() => Promise<void>) | null = null;
97
+ if (ctx.acquireWriteLease) {
98
+ releaseLease = await ctx.acquireWriteLease();
99
+ if (!releaseLease || ctx.disposed()) {
100
+ ctx.syncing.delete(collectionName);
101
+ await releaseLease?.();
102
+ if (!ctx.disposed()) {
103
+ requeueAfterFailure(
104
+ ctx.queueHost,
105
+ collectionName,
106
+ [],
107
+ [],
108
+ undefined,
109
+ WATCHER_LEASE_RETRY_MS
110
+ );
111
+ }
112
+ return;
113
+ }
114
+ }
115
+
89
116
  const collection = ctx
90
117
  .collections()
91
118
  .find((entry) => entry.name === collectionName);
92
119
  if (!collection) {
120
+ await releaseLease?.();
121
+ ctx.syncing.delete(collectionName);
93
122
  ctx.pendingByCollection.delete(collectionName);
94
123
  ctx.flushDeadlineAt.delete(collectionName);
95
124
  return;
96
125
  }
97
126
 
98
- const taken = takePending(pending);
127
+ // Events that arrived while the lease was being taken join this flush.
128
+ const taken = takePending(
129
+ ctx.pendingByCollection.get(collectionName) ?? pending
130
+ );
99
131
  ctx.pendingByCollection.set(collectionName, emptyPending());
100
132
  ctx.flushDeadlineAt.delete(collectionName);
101
- ctx.syncing.add(collectionName);
102
133
 
103
134
  const ownerGeneration = ctx.collectionGenerations.get(collectionName) ?? 0;
104
135
  const ownerRoot = normalize(collection.path);
@@ -219,6 +250,8 @@ export async function runOwnedCollectionFlush(
219
250
  throw outcome.error;
220
251
  }
221
252
  } finally {
253
+ // Release before any follow-up flush below tries to take the lease again.
254
+ await releaseLease?.();
222
255
  ctx.syncing.delete(collectionName);
223
256
  ctx.clearLifecycleTombstones(collectionName);
224
257
  ctx.pruneSuppression();
@@ -108,6 +108,8 @@ interface CollectionWatchServiceOptions {
108
108
  * Production leaves this unset (uses WATCHER_SNAPSHOT_ENTRY_CEILING).
109
109
  */
110
110
  snapshotEntryCeiling?: number;
111
+ /** Shared writer lease taken (no wait) around each flush's writes. */
112
+ acquireWriteLease?: () => Promise<(() => Promise<void>) | null>;
111
113
  }
112
114
 
113
115
  export class CollectionWatchService {
@@ -146,6 +148,7 @@ export class CollectionWatchService {
146
148
  | undefined;
147
149
  readonly #snapshotFs: WatcherSnapshotFs | undefined;
148
150
  readonly #snapshotEntryCeiling: number | undefined;
151
+ readonly #acquireWriteLease: CollectionWatchServiceOptions["acquireWriteLease"];
149
152
  #nextCollectionGeneration = 0;
150
153
  #disposed = false;
151
154
  #lastEventAt: string | null = null;
@@ -169,6 +172,7 @@ export class CollectionWatchService {
169
172
  this.#buildSnapshot = options.buildSnapshot;
170
173
  this.#snapshotFs = options.snapshotFs;
171
174
  this.#snapshotEntryCeiling = options.snapshotEntryCeiling;
175
+ this.#acquireWriteLease = options.acquireWriteLease;
172
176
  }
173
177
 
174
178
  start(): void {
@@ -394,6 +398,7 @@ export class CollectionWatchService {
394
398
  },
395
399
  snapshotFs: this.#snapshotFs,
396
400
  snapshotEntryCeiling: this.#snapshotEntryCeiling,
401
+ acquireWriteLease: this.#acquireWriteLease,
397
402
  });
398
403
  }
399
404