@gmickel/gno 2.7.1 → 2.8.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 (76) hide show
  1. package/README.md +3 -2
  2. package/assets/skill/SKILL.md +8 -1
  3. package/assets/skill/cli-reference.md +8 -1
  4. package/assets/skill/examples.md +2 -1
  5. package/assets/skill/mcp-reference.md +3 -1
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/{gno-browser-clipper-v2.7.1.zip → gno-browser-clipper-v2.8.0.zip} +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/manifest.json +1 -1
  10. package/package.json +1 -1
  11. package/spec/cli.md +48 -6
  12. package/spec/db/schema.sql +0 -1
  13. package/spec/mcp.md +18 -3
  14. package/spec/output-schemas/audit-report.schema.json +18 -4
  15. package/spec/output-schemas/backlinks.schema.json +4 -0
  16. package/spec/output-schemas/collection-list.schema.json +13 -0
  17. package/spec/output-schemas/graph.schema.json +2 -0
  18. package/spec/output-schemas/links-list.schema.json +4 -0
  19. package/spec/output-schemas/status.schema.json +15 -0
  20. package/src/cli/commands/audit.ts +23 -4
  21. package/src/cli/commands/collection/list.ts +39 -5
  22. package/src/cli/commands/embed.ts +3 -3
  23. package/src/cli/commands/links.ts +34 -131
  24. package/src/cli/commands/shared.ts +7 -0
  25. package/src/cli/commands/status.ts +5 -0
  26. package/src/cli/program.ts +12 -2
  27. package/src/config/loader.ts +43 -0
  28. package/src/config/types.ts +8 -0
  29. package/src/core/audit-contract.ts +16 -4
  30. package/src/core/audit-freshness.ts +11 -1
  31. package/src/core/audit-links.ts +145 -25
  32. package/src/core/audit-provenance.ts +11 -4
  33. package/src/core/audit-workspace.ts +19 -4
  34. package/src/core/audit.ts +67 -15
  35. package/src/core/context-compiler.ts +3 -0
  36. package/src/core/context-evidence.ts +11 -0
  37. package/src/core/graph-edge-confidence.ts +23 -1
  38. package/src/core/host-paths.ts +1 -0
  39. package/src/core/knowledge-impact.ts +28 -0
  40. package/src/core/link-workspace.ts +324 -0
  41. package/src/core/retrieval-replay-candidate.ts +6 -0
  42. package/src/core/retrieval-trace-request.ts +3 -0
  43. package/src/index.ts +14 -1
  44. package/src/ingestion/graph-reconciliation.ts +77 -15
  45. package/src/ingestion/source-availability/darwin-path.ts +9 -3
  46. package/src/ingestion/sync.ts +22 -1
  47. package/src/ingestion/types.ts +14 -0
  48. package/src/llm/inference-scope.ts +4 -3
  49. package/src/mcp/http-egress.ts +42 -3
  50. package/src/mcp/tools/audit.ts +11 -2
  51. package/src/mcp/tools/changes.ts +1 -0
  52. package/src/mcp/tools/links.ts +3 -0
  53. package/src/mcp/tools/sessions.ts +33 -4
  54. package/src/mcp/tools/status.ts +3 -0
  55. package/src/pipeline/expansion.ts +19 -31
  56. package/src/pipeline/graph-retrieval.ts +22 -2
  57. package/src/pipeline/hybrid.ts +1 -1
  58. package/src/pipeline/types.ts +6 -3
  59. package/src/serve/findings-pass.ts +1 -1
  60. package/src/serve/public/pages/GraphView.tsx +2 -0
  61. package/src/serve/routes/changes.ts +6 -1
  62. package/src/serve/routes/links.ts +13 -0
  63. package/src/serve/routes/sessions.ts +41 -53
  64. package/src/sessions/config-refresh.ts +111 -0
  65. package/src/store/migrations/033-drop-documents-active-index.ts +30 -0
  66. package/src/store/migrations/034-collection-link-workspace.ts +47 -0
  67. package/src/store/migrations/index.ts +4 -0
  68. package/src/store/sqlite/adapter.ts +390 -231
  69. package/src/store/sqlite/eligibility.ts +8 -2
  70. package/src/store/sqlite/graph-link-resolver.ts +252 -5
  71. package/src/store/sqlite/graph-neighbors.ts +147 -40
  72. package/src/store/sqlite/graph-reference-state.ts +13 -2
  73. package/src/store/sqlite/workspace-link-resolver.ts +654 -0
  74. package/src/store/types.ts +49 -3
  75. package/src/store/vector/stats.ts +1 -1
  76. package/browser-extension/artifacts/gno-browser-clipper-v2.7.1.zip.sha256 +0 -1
@@ -138,8 +138,12 @@ import {
138
138
  classifyResolvedGraphEdge,
139
139
  mergeGraphEdgeAudit,
140
140
  } from "../../core/graph-edge-confidence";
141
- import { buildWikiBestMatchSubquery } from "../../core/graph-resolver";
142
141
  import { buildContentPrefilterNeedles } from "../../core/link-relevance";
142
+ import {
143
+ detectCollectionWorkspace,
144
+ detectNestedWorkspacePrefixes,
145
+ type LinkWorkspaceSource,
146
+ } from "../../core/link-workspace";
143
147
  import { normalizeWikiName, stripWikiMdExt } from "../../core/links";
144
148
  import {
145
149
  TYPED_METADATA_INGEST_VERSION,
@@ -213,7 +217,12 @@ import {
213
217
  applyGraphEdges,
214
218
  type DesiredGraphEdge,
215
219
  } from "./graph-edge-application";
216
- import { resolveGraphLinkTargets } from "./graph-link-resolver";
220
+ import {
221
+ type GraphLinkSourceIdentity,
222
+ isTraversableResolution,
223
+ linkSourceIdentity,
224
+ resolveGraphLinkTargets,
225
+ } from "./graph-link-resolver";
217
226
  import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
218
227
  import { createGraphReferenceStore } from "./graph-reference-state";
219
228
  import {
@@ -243,6 +252,11 @@ import {
243
252
  listTraces as listStoredTraces,
244
253
  mergeTraceEgressLineage as mergeStoredTraceEgressLineage,
245
254
  } from "./retrieval-trace-store";
255
+ import {
256
+ loadLinkWorkspaceMemberships,
257
+ workspaceKeyForDocument,
258
+ workspaceMemberCollections,
259
+ } from "./workspace-link-resolver";
246
260
 
247
261
  // ─────────────────────────────────────────────────────────────────────────────
248
262
  // FTS5 Query Escaping
@@ -903,11 +917,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
903
917
  INSERT INTO collections (
904
918
  name, path, pattern, include, exclude, update_cmd, language_hint,
905
919
  egress_policy, egress_policy_source, egress_policy_revision,
920
+ real_path, workspace_root, workspace_source,
906
921
  synced_at
907
922
  )
908
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
923
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
909
924
  ON CONFLICT(name) DO UPDATE SET
910
925
  path = excluded.path,
926
+ ${COLLECTION_WORKSPACE_UPSERT_SET}
911
927
  pattern = excluded.pattern,
912
928
  include = excluded.include,
913
929
  exclude = excluded.exclude,
@@ -936,6 +952,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
936
952
 
937
953
  for (const c of collections) {
938
954
  const egress = resolveConfiguredEgressPolicy(c);
955
+ const workspace = detectCollectionWorkspace(c);
939
956
  stmt.run(
940
957
  c.name,
941
958
  c.path,
@@ -946,7 +963,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
946
963
  c.languageHint ?? null,
947
964
  egress.policy,
948
965
  egress.source,
949
- c.egressPolicyRevision ?? 0
966
+ c.egressPolicyRevision ?? 0,
967
+ workspace.realPath,
968
+ workspace.root,
969
+ workspace.source
950
970
  );
951
971
  }
952
972
  });
@@ -971,11 +991,13 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
971
991
  INSERT INTO collections (
972
992
  name, path, pattern, include, exclude, update_cmd, language_hint,
973
993
  egress_policy, egress_policy_source, egress_policy_revision,
994
+ real_path, workspace_root, workspace_source,
974
995
  synced_at
975
996
  )
976
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
997
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
977
998
  ON CONFLICT(name) DO UPDATE SET
978
999
  path = excluded.path,
1000
+ ${COLLECTION_WORKSPACE_UPSERT_SET}
979
1001
  pattern = excluded.pattern,
980
1002
  include = excluded.include,
981
1003
  exclude = excluded.exclude,
@@ -1001,6 +1023,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1001
1023
  END,
1002
1024
  synced_at = datetime('now')
1003
1025
  WHERE collections.path IS NOT excluded.path
1026
+ OR collections.real_path IS NOT excluded.real_path
1027
+ OR collections.workspace_root IS NOT excluded.workspace_root
1028
+ OR collections.workspace_source IS NOT excluded.workspace_source
1004
1029
  OR collections.pattern IS NOT excluded.pattern
1005
1030
  OR collections.include IS NOT excluded.include
1006
1031
  OR collections.exclude IS NOT excluded.exclude
@@ -1021,6 +1046,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1021
1046
  const transaction = db.transaction(() => {
1022
1047
  for (const collection of collections) {
1023
1048
  const egress = resolveConfiguredEgressPolicy(collection);
1049
+ const workspace = detectCollectionWorkspace(collection);
1024
1050
  stmt.run(
1025
1051
  collection.name,
1026
1052
  collection.path,
@@ -1035,7 +1061,10 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1035
1061
  collection.languageHint ?? null,
1036
1062
  egress.policy,
1037
1063
  egress.source,
1038
- collection.egressPolicyRevision ?? 0
1064
+ collection.egressPolicyRevision ?? 0,
1065
+ workspace.realPath,
1066
+ workspace.root,
1067
+ workspace.source
1039
1068
  );
1040
1069
  }
1041
1070
  });
@@ -1129,6 +1158,62 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1129
1158
  }
1130
1159
  }
1131
1160
 
1161
+ /**
1162
+ * Re-detect nested vaults (`.obsidian/` below the collection root) from the
1163
+ * directories of the collection's active documents. Returns whether the
1164
+ * stored membership changed.
1165
+ */
1166
+ async refreshCollectionNestedWorkspaces(
1167
+ collection: string
1168
+ ): Promise<StoreResult<boolean>> {
1169
+ try {
1170
+ const db = this.ensureOpen();
1171
+ const row = db
1172
+ .query<
1173
+ {
1174
+ real_path: string | null;
1175
+ workspace_source: LinkWorkspaceSource | null;
1176
+ workspace_nested: string | null;
1177
+ },
1178
+ [string]
1179
+ >(
1180
+ "SELECT real_path, workspace_source, workspace_nested FROM collections WHERE name = ?"
1181
+ )
1182
+ .get(collection);
1183
+ if (!row) return ok(false);
1184
+ const eligible =
1185
+ row.real_path !== null &&
1186
+ row.workspace_source !== "disabled" &&
1187
+ row.workspace_source !== "unavailable";
1188
+ const nested = eligible
1189
+ ? detectNestedWorkspacePrefixes(
1190
+ row.real_path as string,
1191
+ db
1192
+ .query<{ rel_path: string }, [string]>(
1193
+ "SELECT rel_path FROM documents WHERE collection = ? AND active = 1"
1194
+ )
1195
+ .all(collection)
1196
+ .map((document) => document.rel_path)
1197
+ )
1198
+ : [];
1199
+ const next = nested.length > 0 ? JSON.stringify(nested) : null;
1200
+ if (next === row.workspace_nested) return ok(false);
1201
+ db.run("UPDATE collections SET workspace_nested = ? WHERE name = ?", [
1202
+ next,
1203
+ collection,
1204
+ ]);
1205
+ return ok(true);
1206
+ } catch (cause) {
1207
+ return err(
1208
+ "QUERY_FAILED",
1209
+ cause instanceof Error
1210
+ ? cause.message
1211
+ : "Failed to refresh nested link workspaces",
1212
+ cause
1213
+ );
1214
+ }
1215
+ }
1216
+
1132
1217
  async getContexts(): Promise<StoreResult<ContextRow[]>> {
1133
1218
  try {
1134
1219
  const db = this.ensureOpen();
@@ -2183,7 +2268,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
2183
2268
  params.push(options.collection);
2184
2269
  }
2185
2270
 
2186
- const sql = `SELECT * FROM documents WHERE ${clauses.join(" AND ")} ORDER BY id`;
2271
+ const sql = `SELECT * FROM documents INDEXED BY idx_documents_mirror_hash WHERE ${clauses.join(" AND ")} ORDER BY id`;
2187
2272
  rows.push(...db.query<DbDocumentRow, string[]>(sql).all(...params));
2188
2273
  }
2189
2274
 
@@ -3786,12 +3871,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3786
3871
 
3787
3872
  /**
3788
3873
  * Get backlinks pointing to a document.
3789
- * Uses target_ref_norm for matching (wiki=normalized title with path fallbacks, markdown=rel_path).
3874
+ * Collection-scoped links match target_ref_norm (wiki=normalized title with
3875
+ * path fallbacks, markdown=rel_path). Plain wiki links from a document in a
3876
+ * link workspace count only when the shared resolver lands them on this
3877
+ * document (tied links never count). `collection` / `collections` restrict
3878
+ * the SOURCE collections; unset means every collection.
3790
3879
  * Only returns links from active source documents.
3791
3880
  */
3792
3881
  async getBacklinksForDoc(
3793
3882
  documentId: number,
3794
- options?: { collection?: string }
3883
+ options?: { collection?: string; collections?: string[] }
3795
3884
  ): Promise<StoreResult<BacklinkRow[]>> {
3796
3885
  try {
3797
3886
  const db = this.ensureOpen();
@@ -3841,17 +3930,34 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3841
3930
  addVariantsWithBasename(relPathKey);
3842
3931
 
3843
3932
  interface DbBacklinkRow {
3933
+ link_id: number;
3844
3934
  source_doc_id: number;
3845
3935
  docid: string;
3846
3936
  uri: string;
3847
3937
  title: string | null;
3938
+ source_collection: string;
3939
+ source_rel_path: string;
3940
+ target_ref_norm: string;
3941
+ target_collection: string | null;
3848
3942
  link_text: string | null;
3849
3943
  start_line: number;
3850
3944
  start_col: number;
3851
3945
  }
3852
3946
 
3853
3947
  const targetCollection = target.collection;
3854
- const sourceCollectionFilter = options?.collection;
3948
+ const sourceAllowlist =
3949
+ options?.collections ??
3950
+ (options?.collection ? [options.collection] : undefined);
3951
+ const sourceScopeSql = sourceAllowlist
3952
+ ? sourceAllowlist.length === 0
3953
+ ? "AND 0"
3954
+ : `AND src.collection IN (${sourceAllowlist.map(() => "?").join(",")})`
3955
+ : "";
3956
+ const sourceScopeParams = sourceAllowlist ?? [];
3957
+ const backlinkColumns = `dl.id AS link_id, dl.source_doc_id, src.docid,
3958
+ src.uri, src.title, src.collection AS source_collection,
3959
+ src.rel_path AS source_rel_path, dl.target_ref_norm,
3960
+ dl.target_collection, dl.link_text, dl.start_line, dl.start_col`;
3855
3961
 
3856
3962
  // Query wiki backlinks (link_type='wiki') with path-style fallbacks
3857
3963
  // NULL target_collection means "same collection as source" - enforce this in SQL
@@ -3881,7 +3987,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3881
3987
  wikiConditions.length > 0
3882
3988
  ? db
3883
3989
  .query<DbBacklinkRow, string[]>(
3884
- `SELECT dl.source_doc_id, src.docid, src.uri, src.title, dl.link_text, dl.start_line, dl.start_col
3990
+ `SELECT ${backlinkColumns}
3885
3991
  FROM doc_links dl
3886
3992
  JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
3887
3993
  WHERE dl.link_type = 'wiki'
@@ -3890,22 +3996,112 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3890
3996
  (dl.target_collection IS NULL AND src.collection = ?)
3891
3997
  OR dl.target_collection = ?
3892
3998
  )
3893
- ${sourceCollectionFilter ? "AND src.collection = ?" : ""}
3999
+ ${sourceScopeSql}
3894
4000
  ORDER BY src.uri, dl.start_line, dl.start_col`
3895
4001
  )
3896
4002
  .all(
3897
4003
  ...wikiParams,
3898
4004
  targetCollection,
3899
4005
  targetCollection,
3900
- ...(sourceCollectionFilter ? [sourceCollectionFilter] : [])
4006
+ ...sourceScopeParams
3901
4007
  )
3902
4008
  : [];
3903
4009
 
4010
+ // Workspace candidates: plain links from any member collection whose
4011
+ // last segment names this file. Resolution decides which count.
4012
+ const memberships = loadLinkWorkspaceMemberships(db);
4013
+ const targetWsKey = workspaceKeyForDocument(
4014
+ memberships,
4015
+ target.collection,
4016
+ target.rel_path
4017
+ );
4018
+ const workspaceBacklinks: DbBacklinkRow[] = [];
4019
+ if (targetWsKey !== null) {
4020
+ const members = workspaceMemberCollections(
4021
+ memberships,
4022
+ new Set([targetWsKey])
4023
+ );
4024
+ const base = stripWikiMdExt(
4025
+ normalizeWikiName(target.rel_path.split("/").pop() ?? target.rel_path)
4026
+ );
4027
+ const escapeLike = (value: string): string =>
4028
+ value
4029
+ .replaceAll("\\", "\\\\")
4030
+ .replaceAll("%", "\\%")
4031
+ .replaceAll("_", "\\_");
4032
+ if (members.length > 0) {
4033
+ workspaceBacklinks.push(
4034
+ ...db
4035
+ .query<DbBacklinkRow, string[]>(
4036
+ `SELECT ${backlinkColumns}
4037
+ FROM doc_links dl
4038
+ JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
4039
+ WHERE dl.link_type = 'wiki'
4040
+ AND dl.target_collection IS NULL
4041
+ AND src.collection IN (${members.map(() => "?").join(",")})
4042
+ AND (dl.target_ref_norm IN (?, ?)
4043
+ OR dl.target_ref_norm LIKE ? ESCAPE '\\'
4044
+ OR dl.target_ref_norm LIKE ? ESCAPE '\\'
4045
+ OR dl.target_ref_norm IN (${[...keySet].map(() => "?").join(",") || "NULL"}))
4046
+ ${sourceScopeSql}
4047
+ ORDER BY src.uri, dl.start_line, dl.start_col`
4048
+ )
4049
+ .all(
4050
+ ...members,
4051
+ base,
4052
+ `${base}.md`,
4053
+ `%/${escapeLike(base)}`,
4054
+ `%/${escapeLike(`${base}.md`)}`,
4055
+ ...keySet,
4056
+ ...sourceScopeParams
4057
+ )
4058
+ );
4059
+ }
4060
+ }
4061
+ const candidates = new Map<number, DbBacklinkRow>();
4062
+ for (const row of [...wikiBacklinks, ...workspaceBacklinks]) {
4063
+ candidates.set(row.link_id, row);
4064
+ }
4065
+ const candidateRows = [...candidates.values()];
4066
+ const resolutions = resolveGraphLinkTargets(
4067
+ db,
4068
+ candidateRows.map((row) => ({
4069
+ targetRefNorm: row.target_ref_norm,
4070
+ targetCollection: row.target_collection ?? row.source_collection,
4071
+ linkType: "wiki" as const,
4072
+ source: linkSourceIdentity(row),
4073
+ }))
4074
+ );
4075
+ const legacyLinkIds = new Set(wikiBacklinks.map((row) => row.link_id));
4076
+ const resolvedWikiBacklinks = candidateRows
4077
+ .filter((row, index) => {
4078
+ const workspaceLink =
4079
+ !row.target_collection &&
4080
+ workspaceKeyForDocument(
4081
+ memberships,
4082
+ row.source_collection,
4083
+ row.source_rel_path
4084
+ ) !== null;
4085
+ // Collection-scoped links keep their key-match semantics.
4086
+ if (!workspaceLink) return legacyLinkIds.has(row.link_id);
4087
+ const resolution = resolutions[index];
4088
+ return (
4089
+ isTraversableResolution(resolution) &&
4090
+ resolution.targetId === documentId
4091
+ );
4092
+ })
4093
+ .sort(
4094
+ (left, right) =>
4095
+ (left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0) ||
4096
+ left.start_line - right.start_line ||
4097
+ left.start_col - right.start_col
4098
+ );
4099
+
3904
4100
  // Query markdown backlinks (link_type='markdown')
3905
4101
  // NULL target_collection means "same collection as source" - enforce this in SQL
3906
4102
  const mdBacklinks = db
3907
4103
  .query<DbBacklinkRow, string[]>(
3908
- `SELECT dl.source_doc_id, src.docid, src.uri, src.title, dl.link_text, dl.start_line, dl.start_col
4104
+ `SELECT ${backlinkColumns}
3909
4105
  FROM doc_links dl
3910
4106
  JOIN documents src ON src.id = dl.source_doc_id AND src.active = 1
3911
4107
  WHERE dl.link_type = 'markdown'
@@ -3914,25 +4110,28 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3914
4110
  (dl.target_collection IS NULL AND src.collection = ?)
3915
4111
  OR dl.target_collection = ?
3916
4112
  )
3917
- ${sourceCollectionFilter ? "AND src.collection = ?" : ""}
4113
+ ${sourceScopeSql}
3918
4114
  ORDER BY src.uri, dl.start_line, dl.start_col`
3919
4115
  )
3920
4116
  .all(
3921
4117
  target.rel_path,
3922
4118
  targetCollection,
3923
4119
  targetCollection,
3924
- ...(sourceCollectionFilter ? [sourceCollectionFilter] : [])
4120
+ ...sourceScopeParams
3925
4121
  );
3926
4122
 
3927
- const allBacklinks = [...wikiBacklinks, ...mdBacklinks].map((r) => ({
3928
- sourceDocId: r.source_doc_id,
3929
- sourceDocid: r.docid,
3930
- sourceDocUri: r.uri,
3931
- sourceDocTitle: r.title,
3932
- linkText: r.link_text,
3933
- startLine: r.start_line,
3934
- startCol: r.start_col,
3935
- }));
4123
+ const allBacklinks = [...resolvedWikiBacklinks, ...mdBacklinks].map(
4124
+ (r) => ({
4125
+ sourceDocId: r.source_doc_id,
4126
+ sourceDocid: r.docid,
4127
+ sourceDocUri: r.uri,
4128
+ sourceDocTitle: r.title,
4129
+ sourceCollection: r.source_collection,
4130
+ linkText: r.link_text,
4131
+ startLine: r.start_line,
4132
+ startCol: r.start_col,
4133
+ })
4134
+ );
3936
4135
 
3937
4136
  return ok(allBacklinks);
3938
4137
  } catch (cause) {
@@ -3951,206 +4150,64 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
3951
4150
  targetRefNorm: string;
3952
4151
  targetCollection: string;
3953
4152
  linkType: "wiki" | "markdown";
4153
+ source?: GraphLinkSourceIdentity;
3954
4154
  }>
3955
4155
  ): Promise<
3956
4156
  StoreResult<
3957
- Array<{ docid: string; uri: string; title: string | null } | null>
3958
- >
3959
- > {
3960
- try {
3961
- const db = this.ensureOpen();
3962
-
3963
- const results: Array<{
4157
+ Array<{
3964
4158
  docid: string;
3965
4159
  uri: string;
3966
4160
  title: string | null;
3967
- } | null> = Array.from({ length: targets.length }, () => null);
3968
-
3969
- const wikiTargets: Array<{
3970
- idx: number;
3971
4161
  collection: string;
3972
- baseRef: string;
3973
- baseRefMd: string;
3974
- }> = [];
3975
- const mdTargets: Array<{
3976
- idx: number;
3977
- collection: string;
3978
- relPath: string;
3979
- }> = [];
3980
-
3981
- for (const [idx, target] of targets.entries()) {
3982
- if (target.linkType === "wiki") {
3983
- const baseRef = stripWikiMdExt(target.targetRefNorm);
3984
- wikiTargets.push({
3985
- idx,
3986
- collection: target.targetCollection,
3987
- baseRef,
3988
- baseRefMd: `${baseRef}.md`,
3989
- });
3990
- } else {
3991
- mdTargets.push({
3992
- idx,
3993
- collection: target.targetCollection,
3994
- relPath: target.targetRefNorm,
4162
+ } | null>
4163
+ >
4164
+ > {
4165
+ try {
4166
+ const db = this.ensureOpen();
4167
+ const resolutions = resolveGraphLinkTargets(db, targets);
4168
+ const ids = [
4169
+ ...new Set(
4170
+ resolutions
4171
+ .filter(isTraversableResolution)
4172
+ .map((resolution) => resolution.targetId)
4173
+ ),
4174
+ ];
4175
+ const byId = new Map<
4176
+ number,
4177
+ { docid: string; uri: string; title: string | null; collection: string }
4178
+ >();
4179
+ for (let offset = 0; offset < ids.length; offset += 900) {
4180
+ const batch = ids.slice(offset, offset + 900);
4181
+ for (const row of db
4182
+ .query<
4183
+ {
4184
+ id: number;
4185
+ docid: string;
4186
+ uri: string;
4187
+ title: string | null;
4188
+ collection: string;
4189
+ },
4190
+ number[]
4191
+ >(
4192
+ `SELECT id, docid, uri, title, collection FROM documents WHERE id IN (${batch.map(() => "?").join(",")})`
4193
+ )
4194
+ .all(...batch)) {
4195
+ byId.set(row.id, {
4196
+ docid: row.docid,
4197
+ uri: row.uri,
4198
+ title: row.title,
4199
+ collection: row.collection,
3995
4200
  });
3996
4201
  }
3997
4202
  }
3998
-
3999
- const chunkArray = <T>(items: T[], chunkSize: number): T[][] => {
4000
- const chunks: T[][] = [];
4001
- for (let i = 0; i < items.length; i += chunkSize) {
4002
- chunks.push(items.slice(i, i + chunkSize));
4003
- }
4004
- return chunks;
4005
- };
4006
-
4007
- const MAX_SQL_PARAMS = 900;
4008
- const wikiBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 4));
4009
- const mdBatchSize = Math.max(1, Math.floor(MAX_SQL_PARAMS / 3));
4010
-
4011
- const titleExpr = "lower(trim(d.title))";
4012
- const relExpr = "lower(d.rel_path)";
4013
- const suffixMatchExprExpr = (
4014
- targetExpr: string,
4015
- valueExpr: string
4016
- ): string =>
4017
- `(substr(${targetExpr}, -length(${valueExpr})) = ${valueExpr}
4018
- AND (length(${targetExpr}) = length(${valueExpr})
4019
- OR substr(${targetExpr}, -length(${valueExpr}) - 1, 1) = '/'))`;
4020
-
4021
- if (wikiTargets.length > 0) {
4022
- for (const batch of chunkArray(wikiTargets, wikiBatchSize)) {
4023
- const valuesClause = batch.map(() => "(?, ?, ?, ?)").join(", ");
4024
- const wikiParams = batch.flatMap((t) => [
4025
- t.idx,
4026
- t.collection,
4027
- t.baseRef,
4028
- t.baseRefMd,
4029
- ]);
4030
-
4031
- const baseRefExpr = "t.base_ref";
4032
- const baseRefMdExpr = "t.base_ref_md";
4033
- const wikiWhere = `
4034
- ${titleExpr} = ${baseRefExpr}
4035
- OR ${titleExpr} = ${baseRefMdExpr}
4036
- OR ${suffixMatchExprExpr(baseRefExpr, titleExpr)}
4037
- OR ${suffixMatchExprExpr(baseRefMdExpr, `${titleExpr} || '.md'`)}
4038
- OR ${relExpr} = ${baseRefExpr}
4039
- OR ${relExpr} = ${baseRefMdExpr}
4040
- OR ${suffixMatchExprExpr(relExpr, baseRefMdExpr)}
4041
- OR ${suffixMatchExprExpr(relExpr, baseRefExpr)}
4042
- OR ${suffixMatchExprExpr(baseRefMdExpr, relExpr)}
4043
- OR ${suffixMatchExprExpr(baseRefExpr, relExpr)}
4044
- `;
4045
-
4046
- const wikiRank = `CASE
4047
- WHEN ${titleExpr} = ${baseRefExpr} THEN 1
4048
- WHEN ${titleExpr} = ${baseRefMdExpr} THEN 2
4049
- WHEN ${suffixMatchExprExpr(baseRefExpr, titleExpr)} THEN 3
4050
- WHEN ${suffixMatchExprExpr(
4051
- baseRefMdExpr,
4052
- `${titleExpr} || '.md'`
4053
- )} THEN 4
4054
- WHEN ${relExpr} = ${baseRefExpr} THEN 5
4055
- WHEN ${relExpr} = ${baseRefMdExpr} THEN 6
4056
- WHEN ${suffixMatchExprExpr(relExpr, baseRefMdExpr)} THEN 7
4057
- WHEN ${suffixMatchExprExpr(relExpr, baseRefExpr)} THEN 8
4058
- WHEN ${suffixMatchExprExpr(baseRefMdExpr, relExpr)} THEN 9
4059
- WHEN ${suffixMatchExprExpr(baseRefExpr, relExpr)} THEN 10
4060
- ELSE 99
4061
- END`;
4062
-
4063
- const wikiQuery = `
4064
- WITH targets(idx, collection, base_ref, base_ref_md) AS (
4065
- VALUES ${valuesClause}
4066
- ),
4067
- candidates AS (
4068
- SELECT
4069
- t.idx,
4070
- d.docid,
4071
- d.uri,
4072
- d.title,
4073
- d.id as doc_id,
4074
- ${wikiRank} as rank
4075
- FROM targets t
4076
- JOIN documents d ON d.active = 1 AND d.collection = t.collection
4077
- WHERE ${wikiWhere}
4078
- ),
4079
- ranked AS (
4080
- SELECT *,
4081
- ROW_NUMBER() OVER (PARTITION BY idx ORDER BY rank, doc_id) as rn
4082
- FROM candidates
4083
- )
4084
- SELECT idx, docid, uri, title
4085
- FROM ranked
4086
- WHERE rn = 1
4087
- `;
4088
-
4089
- const wikiRows = db
4090
- .query<
4091
- { idx: number; docid: string; uri: string; title: string | null },
4092
- (string | number)[]
4093
- >(wikiQuery)
4094
- .all(...wikiParams);
4095
-
4096
- for (const row of wikiRows) {
4097
- results[row.idx] = {
4098
- docid: row.docid,
4099
- uri: row.uri,
4100
- title: row.title,
4101
- };
4102
- }
4103
- }
4104
- }
4105
-
4106
- if (mdTargets.length > 0) {
4107
- for (const batch of chunkArray(mdTargets, mdBatchSize)) {
4108
- const valuesClause = batch.map(() => "(?, ?, ?)").join(", ");
4109
- const mdParams = batch.flatMap((t) => [
4110
- t.idx,
4111
- t.collection,
4112
- t.relPath,
4113
- ]);
4114
- const mdQuery = `
4115
- WITH targets(idx, collection, rel_path) AS (
4116
- VALUES ${valuesClause}
4117
- ),
4118
- ranked AS (
4119
- SELECT
4120
- t.idx,
4121
- d.docid,
4122
- d.uri,
4123
- d.title,
4124
- d.id as doc_id,
4125
- ROW_NUMBER() OVER (PARTITION BY t.idx ORDER BY d.id) as rn
4126
- FROM targets t
4127
- JOIN documents d ON d.active = 1
4128
- AND d.collection = t.collection
4129
- AND d.rel_path = t.rel_path
4130
- )
4131
- SELECT idx, docid, uri, title
4132
- FROM ranked
4133
- WHERE rn = 1
4134
- `;
4135
-
4136
- const mdRows = db
4137
- .query<
4138
- { idx: number; docid: string; uri: string; title: string | null },
4139
- (string | number)[]
4140
- >(mdQuery)
4141
- .all(...mdParams);
4142
-
4143
- for (const row of mdRows) {
4144
- results[row.idx] = {
4145
- docid: row.docid,
4146
- uri: row.uri,
4147
- title: row.title,
4148
- };
4149
- }
4150
- }
4151
- }
4152
-
4153
- return ok(results);
4203
+ // Tied workspace links are ambiguous: reported unresolved, never guessed.
4204
+ return ok(
4205
+ resolutions.map((resolution) =>
4206
+ isTraversableResolution(resolution)
4207
+ ? (byId.get(resolution.targetId) ?? null)
4208
+ : null
4209
+ )
4210
+ );
4154
4211
  } catch (cause) {
4155
4212
  return err(
4156
4213
  "QUERY_FAILED",
@@ -4757,6 +4814,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4757
4814
  ? normalizeDocEdgeType(options.edgeType)
4758
4815
  : undefined;
4759
4816
 
4817
+ // Collection allowlist: every visited node must be in scope, so a
4818
+ // forbidden collection can never act as a traversal bridge. Names are
4819
+ // validated collection identifiers, inlined as quoted literals.
4820
+ const scopeCollections = options.collections
4821
+ ? [...new Set(options.collections)].filter((name) =>
4822
+ COLLECTION_IDENTIFIER.test(name)
4823
+ )
4824
+ : undefined;
4825
+ const nextDocScope = scopeCollections
4826
+ ? scopeCollections.length === 0
4827
+ ? " AND 0"
4828
+ : ` AND next_doc.collection IN (${scopeCollections
4829
+ .map((name) => `'${name}'`)
4830
+ .join(",")})`
4831
+ : "";
4832
+
4760
4833
  const nodeLimit = Math.min(maxNodes, visitedLimit);
4761
4834
  const edgeTypeFilter = edgeType ? "AND e.edge_type = ?" : "";
4762
4835
  const edgeTypeFilterFor = (alias: string): string =>
@@ -4795,7 +4868,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4795
4868
  ORDER BY e2.edge_type ASC, e2.id ASC
4796
4869
  ) AS next_rank
4797
4870
  FROM doc_edges e2
4798
- JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
4871
+ JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
4799
4872
  WHERE e2.src_doc_id = ${frontierName}.doc_id
4800
4873
  ${edgeTypeFilterFor("e2")}
4801
4874
  AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
@@ -4826,7 +4899,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4826
4899
  ORDER BY e2.edge_type ASC, e2.id ASC
4827
4900
  ) AS next_rank
4828
4901
  FROM doc_edges e2
4829
- JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1
4902
+ JOIN documents next_doc ON next_doc.id = e2.src_doc_id AND next_doc.active = 1${nextDocScope}
4830
4903
  WHERE e2.dst_doc_id = ${frontierName}.doc_id
4831
4904
  ${edgeTypeFilterFor("e2")}
4832
4905
  AND instr(${frontierName}.path, printf(',%d,', e2.src_doc_id)) = 0
@@ -4857,7 +4930,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4857
4930
  ORDER BY e2.edge_type ASC, e2.id ASC
4858
4931
  ) AS next_rank
4859
4932
  FROM doc_edges e2
4860
- JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1
4933
+ JOIN documents next_doc ON next_doc.id = e2.dst_doc_id AND next_doc.active = 1${nextDocScope}
4861
4934
  WHERE e2.src_doc_id = ${frontierName}.doc_id
4862
4935
  ${edgeTypeFilterFor("e2")}
4863
4936
  AND instr(${frontierName}.path, printf(',%d,', e2.dst_doc_id)) = 0
@@ -4885,7 +4958,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
4885
4958
  ORDER BY e3.edge_type ASC, e3.id ASC
4886
4959
  ) AS next_rank
4887
4960
  FROM doc_edges e3
4888
- JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1
4961
+ JOIN documents next_doc ON next_doc.id = e3.src_doc_id AND next_doc.active = 1${nextDocScope}
4889
4962
  WHERE e3.dst_doc_id = ${frontierName}.doc_id
4890
4963
  ${edgeTypeFilterFor("e3")}
4891
4964
  AND instr(${frontierName}.path, printf(',%d,', e3.src_doc_id)) = 0
@@ -5255,19 +5328,53 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5255
5328
  : "";
5256
5329
  const params = sourceIds ? [JSON.stringify(sourceIds)] : [];
5257
5330
  const inserted = db.transaction(() => {
5258
- const wiki = db
5259
- .query<DesiredGraphEdge, string[]>(`
5260
- SELECT DISTINCT src.id AS sourceId, tgt.id AS targetId,
5261
- 'mentions' AS edgeType, 'parsed' AS confidence, 'wikilink' AS source
5331
+ // Wiki edges use the shared (workspace-aware) resolver in batches;
5332
+ // tied workspace links are audit-only and never become edges.
5333
+ const wikiRows = db
5334
+ .query<
5335
+ {
5336
+ source_id: number;
5337
+ source_collection: string;
5338
+ source_rel_path: string;
5339
+ target_ref_norm: string;
5340
+ target_collection: string | null;
5341
+ },
5342
+ string[]
5343
+ >(`
5344
+ SELECT src.id AS source_id, src.collection AS source_collection,
5345
+ src.rel_path AS source_rel_path, dl.target_ref_norm,
5346
+ dl.target_collection
5262
5347
  FROM documents src JOIN doc_links dl ON dl.source_doc_id = src.id
5263
- JOIN documents tgt ON tgt.id = (${buildWikiBestMatchSubquery(
5264
- "COALESCE(dl.target_collection, src.collection)",
5265
- "dl.target_ref_norm"
5266
- )})
5267
- WHERE src.active = 1 AND tgt.active = 1 AND dl.link_type = 'wiki'
5348
+ WHERE src.active = 1 AND dl.link_type = 'wiki'
5268
5349
  ${sourceFilter}
5350
+ ORDER BY src.id, dl.id
5269
5351
  `)
5270
5352
  .all(...params);
5353
+ const wikiResolutions = resolveGraphLinkTargets(
5354
+ db,
5355
+ wikiRows.map((row) => ({
5356
+ targetRefNorm: row.target_ref_norm,
5357
+ targetCollection: row.target_collection ?? row.source_collection,
5358
+ linkType: "wiki" as const,
5359
+ source: linkSourceIdentity(row),
5360
+ }))
5361
+ );
5362
+ const wikiKeys = new Set<string>();
5363
+ const wiki: DesiredGraphEdge[] = [];
5364
+ for (const [index, row] of wikiRows.entries()) {
5365
+ const resolution = wikiResolutions[index];
5366
+ if (!isTraversableResolution(resolution)) continue;
5367
+ const key = `${row.source_id}:${resolution.targetId}`;
5368
+ if (wikiKeys.has(key)) continue;
5369
+ wikiKeys.add(key);
5370
+ wiki.push({
5371
+ sourceId: row.source_id,
5372
+ targetId: resolution.targetId,
5373
+ edgeType: "mentions",
5374
+ confidence: "parsed",
5375
+ source: "wikilink",
5376
+ });
5377
+ }
5271
5378
  const markdown = db
5272
5379
  .query<DesiredGraphEdge, string[]>(`
5273
5380
  SELECT DISTINCT src.id AS sourceId, tgt.id AS targetId,
@@ -5362,12 +5469,14 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5362
5469
  link_type: "wiki" | "markdown";
5363
5470
  match_rank: number | null;
5364
5471
  match_count: number | null;
5472
+ reason?: string;
5365
5473
  }
5366
5474
 
5367
5475
  interface GraphLinkResolutionRow {
5368
5476
  source_id: number;
5369
5477
  source_docid: string;
5370
5478
  source_collection: string;
5479
+ source_rel_path: string;
5371
5480
  target_ref_norm: string;
5372
5481
  target_collection: string | null;
5373
5482
  link_type: "wiki" | "markdown";
@@ -5396,6 +5505,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5396
5505
  src.id as source_id,
5397
5506
  src.docid as source_docid,
5398
5507
  src.collection as source_collection,
5508
+ src.rel_path as source_rel_path,
5399
5509
  dl.target_ref_norm,
5400
5510
  dl.target_collection,
5401
5511
  dl.link_type
@@ -5413,6 +5523,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5413
5523
  targetRefNorm: row.target_ref_norm,
5414
5524
  targetCollection: row.target_collection ?? row.source_collection,
5415
5525
  linkType: row.link_type,
5526
+ source: linkSourceIdentity(row),
5416
5527
  }))
5417
5528
  );
5418
5529
  const resolvedEdgeRows: ResolvedEdgeRow[] = [];
@@ -5422,11 +5533,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5422
5533
  };
5423
5534
  for (const [index, row] of graphLinkRows.entries()) {
5424
5535
  const resolution = resolutions[index];
5425
- if (!resolution) {
5536
+ if (!isTraversableResolution(resolution)) {
5426
5537
  unresolvedByType[row.link_type] += 1;
5427
5538
  continue;
5428
5539
  }
5429
- const targetCollection = row.target_collection ?? row.source_collection;
5540
+ // Scope uses the resolved target identity, not the declared prefix.
5541
+ const targetCollection =
5542
+ resolution.targetCollection ??
5543
+ row.target_collection ??
5544
+ row.source_collection;
5430
5545
  if (collection && targetCollection !== collection) continue;
5431
5546
  resolvedEdgeRows.push({
5432
5547
  source_id: row.source_id,
@@ -5436,6 +5551,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5436
5551
  link_type: row.link_type,
5437
5552
  match_rank: resolution.matchRank,
5438
5553
  match_count: resolution.matchCount,
5554
+ reason: resolution.reason,
5439
5555
  });
5440
5556
  }
5441
5557
  resolvedEdgeRows.sort(
@@ -5617,7 +5733,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5617
5733
  const { confidence, audit } = classifyResolvedGraphEdge(
5618
5734
  row.link_type,
5619
5735
  row.match_rank,
5620
- row.match_count
5736
+ row.match_count,
5737
+ row.reason
5621
5738
  );
5622
5739
  const existing = edgeMap.get(key);
5623
5740
  if (existing) {
@@ -5927,6 +6044,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5927
6044
  path: string;
5928
6045
  egress_policy: EgressPolicy;
5929
6046
  egress_policy_source: EgressPolicySource;
6047
+ workspace_root: string | null;
6048
+ workspace_source: LinkWorkspaceSource | null;
5930
6049
  total: number;
5931
6050
  active: number;
5932
6051
  errored: number;
@@ -5980,6 +6099,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
5980
6099
  c.path,
5981
6100
  c.egress_policy,
5982
6101
  c.egress_policy_source,
6102
+ c.workspace_root,
6103
+ c.workspace_source,
5983
6104
  COALESCE(ds.total, 0) AS total,
5984
6105
  COALESCE(ds.active, 0) AS active,
5985
6106
  COALESCE(ds.errored, 0) AS errored,
@@ -6081,6 +6202,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6081
6202
  path: s.path,
6082
6203
  egressPolicy: s.egress_policy,
6083
6204
  egressPolicySource: s.egress_policy_source,
6205
+ workspaceRoot: s.workspace_root,
6206
+ workspaceSource: s.workspace_source ?? "none",
6084
6207
  totalDocuments: s.total,
6085
6208
  activeDocuments: s.active,
6086
6209
  errorDocuments: s.errored,
@@ -6357,6 +6480,22 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
6357
6480
  }
6358
6481
  }
6359
6482
 
6483
+ /** Collection names as the config schema admits them (safe to inline). */
6484
+ const COLLECTION_IDENTIFIER = /^[a-z0-9][a-z0-9_-]{0,63}$/;
6485
+
6486
+ /**
6487
+ * Workspace columns on collection upsert. Nested vault prefixes are refreshed
6488
+ * by ingestion; they stay valid only while the collection's real root does.
6489
+ */
6490
+ const COLLECTION_WORKSPACE_UPSERT_SET = `real_path = excluded.real_path,
6491
+ workspace_root = excluded.workspace_root,
6492
+ workspace_source = excluded.workspace_source,
6493
+ workspace_nested = CASE
6494
+ WHEN collections.real_path IS excluded.real_path
6495
+ THEN collections.workspace_nested
6496
+ ELSE NULL
6497
+ END,`;
6498
+
6360
6499
  // ─────────────────────────────────────────────────────────────────────────────
6361
6500
  // DB Row Types (snake_case from SQLite)
6362
6501
  // ─────────────────────────────────────────────────────────────────────────────
@@ -6371,6 +6510,10 @@ interface DbCollectionRow {
6371
6510
  language_hint: string | null;
6372
6511
  egress_policy: EgressPolicy;
6373
6512
  egress_policy_source: EgressPolicySource;
6513
+ real_path?: string | null;
6514
+ workspace_root?: string | null;
6515
+ workspace_source?: LinkWorkspaceSource | null;
6516
+ workspace_nested?: string | null;
6374
6517
  synced_at: string;
6375
6518
  }
6376
6519
 
@@ -6462,6 +6605,18 @@ interface DbIngestErrorRow {
6462
6605
  // Row Mappers (snake_case -> camelCase)
6463
6606
  // ─────────────────────────────────────────────────────────────────────────────
6464
6607
 
6608
+ function parseWorkspaceNested(raw: string | null): string[] {
6609
+ if (!raw) return [];
6610
+ try {
6611
+ const parsed: unknown = JSON.parse(raw);
6612
+ return Array.isArray(parsed)
6613
+ ? parsed.filter((value): value is string => typeof value === "string")
6614
+ : [];
6615
+ } catch {
6616
+ return [];
6617
+ }
6618
+ }
6619
+
6465
6620
  function mapCollectionRow(row: DbCollectionRow): CollectionRow {
6466
6621
  return {
6467
6622
  name: row.name,
@@ -6473,6 +6628,10 @@ function mapCollectionRow(row: DbCollectionRow): CollectionRow {
6473
6628
  languageHint: row.language_hint,
6474
6629
  egressPolicy: row.egress_policy,
6475
6630
  egressPolicySource: row.egress_policy_source,
6631
+ realPath: row.real_path ?? null,
6632
+ workspaceRoot: row.workspace_root ?? null,
6633
+ workspaceSource: row.workspace_source ?? "none",
6634
+ workspaceNested: parseWorkspaceNested(row.workspace_nested ?? null),
6476
6635
  syncedAt: row.synced_at,
6477
6636
  };
6478
6637
  }