yarramate 0.23.0 → 1.0.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 (159) hide show
  1. package/README.md +13 -4
  2. package/catalogues/core-enrichment.yaml +79 -55
  3. package/dist/adapter-mapping.js +6 -1
  4. package/dist/adapters/likec4-cli.js +36 -7
  5. package/dist/adapters/visual/client.d.ts +2 -2
  6. package/dist/adapters/visual/client.js +79 -10
  7. package/dist/adapters/visual/protocol-contract.d.ts +124 -10
  8. package/dist/adapters/visual/protocol-contract.js +12 -1
  9. package/dist/adapters/visual/protocol.d.ts +63 -0
  10. package/dist/adapters/visual/protocol.js +96 -5
  11. package/dist/adapters/visual/session-server.d.ts +1 -6
  12. package/dist/adapters/visual/session-server.js +243 -143
  13. package/dist/adapters/visual/session-store.d.ts +9 -0
  14. package/dist/adapters/visual/session-store.js +14 -5
  15. package/dist/adapters/visual/view-identity.d.ts +135 -0
  16. package/dist/adapters/visual/view-identity.js +200 -0
  17. package/dist/adapters/visual/wire.d.ts +19 -4
  18. package/dist/adapters/visual/workspace-model.d.ts +116 -0
  19. package/dist/adapters/visual/workspace-model.js +212 -0
  20. package/dist/adapters/visual-cli.d.ts +1 -1
  21. package/dist/adapters/visual-cli.js +30 -21
  22. package/dist/apply-cli.d.ts +2 -0
  23. package/dist/apply-cli.js +83 -0
  24. package/dist/apply-command.d.ts +84 -3
  25. package/dist/apply-command.js +218 -109
  26. package/dist/archimate-relationships.generated.d.ts +22 -0
  27. package/dist/archimate-relationships.generated.js +156 -0
  28. package/dist/ask-command.js +18 -6
  29. package/dist/attestation-staleness.js +2 -2
  30. package/dist/changed.js +1 -1
  31. package/dist/check-command.js +13 -5
  32. package/dist/cli-support.d.ts +1 -1
  33. package/dist/cli-support.js +1 -1
  34. package/dist/cli.js +1 -1
  35. package/dist/compiler.d.ts +31 -3
  36. package/dist/compiler.js +321 -81
  37. package/dist/concept-drafting.d.ts +27 -0
  38. package/dist/concept-drafting.js +75 -0
  39. package/dist/core-contract.js +6 -1
  40. package/dist/deletion-drafting.d.ts +52 -0
  41. package/dist/deletion-drafting.js +100 -0
  42. package/dist/evidence.js +6 -1
  43. package/dist/graph-projection.d.ts +14 -0
  44. package/dist/graph-projection.js +4 -0
  45. package/dist/index.d.ts +8 -1
  46. package/dist/index.js +8 -1
  47. package/dist/interrogate-command.js +63 -17
  48. package/dist/kind-label.d.ts +4 -3
  49. package/dist/kind-label.js +4 -3
  50. package/dist/nesting.d.ts +15 -0
  51. package/dist/nesting.js +2 -0
  52. package/dist/notation/archimate.d.ts +1 -1
  53. package/dist/notation/archimate.js +108 -9
  54. package/dist/profile.d.ts +9 -12
  55. package/dist/profile.js +17 -26
  56. package/dist/projection.d.ts +62 -3
  57. package/dist/projection.js +127 -43
  58. package/dist/relationship-drafting.d.ts +51 -0
  59. package/dist/relationship-drafting.js +92 -0
  60. package/dist/relationship-matrix.d.ts +14 -0
  61. package/dist/relationship-matrix.js +74 -0
  62. package/dist/shipped-profile.d.ts +1 -1
  63. package/dist/shipped-profile.js +19 -5
  64. package/dist/source-store.d.ts +95 -0
  65. package/dist/source-store.js +170 -0
  66. package/dist/subject-references.js +1 -1
  67. package/dist/visual-app/assets/index-BLcpI8WN.js +394 -0
  68. package/dist/visual-app/assets/index-DLutIWES.css +1 -0
  69. package/dist/visual-app/index.html +2 -2
  70. package/dist/visual-app-lib/editor.d.ts +1 -0
  71. package/dist/visual-app-lib/editor.js +90968 -0
  72. package/dist/visual-app-lib/styles.css +2 -0
  73. package/dist/visual-app-lib/types/adapter-mapping.d.ts +40 -0
  74. package/dist/visual-app-lib/types/adapters/visual/protocol-contract.d.ts +455 -0
  75. package/dist/visual-app-lib/types/adapters/visual/view-identity.d.ts +135 -0
  76. package/dist/visual-app-lib/types/adapters/visual/wire.d.ts +141 -0
  77. package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +116 -0
  78. package/dist/visual-app-lib/types/apply-command.d.ts +93 -0
  79. package/dist/visual-app-lib/types/archimate-relationships.generated.d.ts +22 -0
  80. package/dist/visual-app-lib/types/brief.d.ts +5 -0
  81. package/dist/visual-app-lib/types/compiler.d.ts +155 -0
  82. package/dist/visual-app-lib/types/concept-drafting.d.ts +27 -0
  83. package/dist/visual-app-lib/types/deletion-drafting.d.ts +52 -0
  84. package/dist/visual-app-lib/types/evidence.d.ts +62 -0
  85. package/dist/visual-app-lib/types/graph-claims.d.ts +18 -0
  86. package/dist/visual-app-lib/types/graph-projection.d.ts +77 -0
  87. package/dist/visual-app-lib/types/kind-label.d.ts +9 -0
  88. package/dist/visual-app-lib/types/nesting.d.ts +15 -0
  89. package/dist/visual-app-lib/types/notation/archimate.d.ts +71 -0
  90. package/dist/visual-app-lib/types/operations.d.ts +157 -0
  91. package/dist/visual-app-lib/types/profile.d.ts +47 -0
  92. package/dist/visual-app-lib/types/projection.d.ts +113 -0
  93. package/dist/visual-app-lib/types/relationship-drafting.d.ts +51 -0
  94. package/dist/visual-app-lib/types/relationship-matrix.d.ts +14 -0
  95. package/dist/visual-app-lib/types/shipped-profile.d.ts +2 -0
  96. package/dist/visual-app-lib/types/source-document.d.ts +36 -0
  97. package/dist/visual-app-lib/types/source-store.d.ts +95 -0
  98. package/dist/visual-app-lib/types/subject-references.d.ts +92 -0
  99. package/dist/visual-app-lib/types/visual-app/App.d.ts +15 -0
  100. package/dist/visual-app-lib/types/visual-app/badges.d.ts +7 -0
  101. package/dist/visual-app-lib/types/visual-app/changeset-tray.d.ts +106 -0
  102. package/dist/visual-app-lib/types/visual-app/confirm-dialog.d.ts +14 -0
  103. package/dist/visual-app-lib/types/visual-app/connection-panel.d.ts +23 -0
  104. package/dist/visual-app-lib/types/visual-app/context-menu-model.d.ts +187 -0
  105. package/dist/visual-app-lib/types/visual-app/context-menu.d.ts +24 -0
  106. package/dist/visual-app-lib/types/visual-app/describe-query.d.ts +9 -0
  107. package/dist/visual-app-lib/types/visual-app/editor-host.d.ts +70 -0
  108. package/dist/visual-app-lib/types/visual-app/faults.d.ts +26 -0
  109. package/dist/visual-app-lib/types/visual-app/graph-canvas.d.ts +73 -0
  110. package/dist/visual-app-lib/types/visual-app/kind-icons.d.ts +3 -0
  111. package/dist/visual-app-lib/types/visual-app/local-host.d.ts +38 -0
  112. package/dist/visual-app-lib/types/visual-app/mount.d.ts +56 -0
  113. package/dist/visual-app-lib/types/visual-app/prompt-dialog.d.ts +24 -0
  114. package/dist/visual-app-lib/types/visual-app/query-fields.d.ts +69 -0
  115. package/dist/visual-app-lib/types/visual-app/query-panel.d.ts +142 -0
  116. package/dist/visual-app-lib/types/visual-app/quick-filter.d.ts +11 -0
  117. package/dist/visual-app-lib/types/visual-app/relationship-kind-options.d.ts +50 -0
  118. package/dist/visual-app-lib/types/visual-app/save-view.d.ts +86 -0
  119. package/dist/visual-app-lib/types/visual-app/section-stack.d.ts +59 -0
  120. package/dist/visual-app-lib/types/visual-app/session-client.d.ts +38 -0
  121. package/dist/visual-app-lib/types/visual-app/state.d.ts +328 -0
  122. package/dist/visual-app-lib/types/visual-app/subject-draft-panel.d.ts +22 -0
  123. package/dist/visual-app-lib/types/visual-app/subject-form.d.ts +42 -0
  124. package/dist/visual-app-lib/types/visual-app/view-tree-model.d.ts +122 -0
  125. package/dist/visual-app-lib/types/visual-app/view-tree.d.ts +32 -0
  126. package/dist/visual-app-lib/types/visual-app/workspace-state.d.ts +268 -0
  127. package/dist/visual-app-lib/types/workspace.d.ts +29 -0
  128. package/dist/workspace.js +7 -1
  129. package/docs/CONSUMING-YARRAMATE.md +51 -2
  130. package/package.json +9 -2
  131. package/schema/yarramate-adapter-mapping.schema.json +1 -1
  132. package/schema/yarramate-ask-result.schema.json +24 -3
  133. package/schema/yarramate-check-result.schema.json +9 -0
  134. package/schema/yarramate-diagnostic-result.schema.json +9 -0
  135. package/schema/yarramate-document.schema.json +6 -0
  136. package/schema/yarramate-evidence-report.schema.json +1 -1
  137. package/schema/yarramate-evidence.schema.json +1 -1
  138. package/schema/yarramate-graph-v2.schema.json +34 -9
  139. package/schema/yarramate-likec4-generated-project-v2.schema.json +1 -1
  140. package/schema/yarramate-likec4-generated-project.schema.json +1 -1
  141. package/schema/yarramate-likec4-project.schema.json +1 -1
  142. package/schema/yarramate-projection-result.schema.json +3 -7
  143. package/schema/yarramate-projection.schema.json +16 -16
  144. package/schema/yarramate-reconciliation-report.schema.json +2 -2
  145. package/schema/yarramate-rtm.schema.json +1 -1
  146. package/schema/yarramate-state-comparison.schema.json +1 -1
  147. package/schema/yarramate-visual-diagnostic-result.schema.json +9 -0
  148. package/schema/yarramate-visual-event.schema.json +40 -41
  149. package/schema/yarramate-visual-graph.schema.json +8 -0
  150. package/schema/yarramate-visual-handoff.schema.json +3 -3
  151. package/schema/yarramate-visual-session-descriptor.schema.json +6 -6
  152. package/schema/yarramate-visual-session-started.schema.json +10 -9
  153. package/schema/yarramate-visual-status.schema.json +3 -3
  154. package/skills/yarramate-architecture/SKILL.md +3 -3
  155. package/skills/yarramate-architecture/references/modelling-patterns.md +23 -44
  156. package/skills/yarramate-architecture/references/native-authoring.md +32 -49
  157. package/skills/yarramate-architecture/references/visual-conversations.md +13 -10
  158. package/dist/visual-app/assets/index-CRd1Khma.js +0 -354
  159. package/dist/visual-app/assets/index-tZSHlU-a.css +0 -1
@@ -2,18 +2,19 @@ import { createHash, createHmac, randomBytes as randomSource, timingSafeEqual, }
2
2
  import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { lstat, readFile } from "node:fs/promises";
4
4
  import { createServer, } from "node:http";
5
- import { basename, extname, join, resolve, sep } from "node:path";
5
+ import { basename, extname, join, relative, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import Ajv2020Module from "ajv/dist/2020.js";
8
8
  import { WebSocketServer } from "ws";
9
9
  import { parse, stringify } from "yaml";
10
- import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, digestOf, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, visualBrowserInputType, } from "./protocol.js";
10
+ import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, digestOf, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, toWireFileUri, visualBrowserInputType, } from "./protocol.js";
11
11
  import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from "./session-store.js";
12
- import { loadProjection, evaluateProjection, } from "../../projection.js";
12
+ import { loadProjection, } from "../../projection.js";
13
+ export { VISUAL_SERVER_DOCUMENT } from "./workspace-model.js";
14
+ import { adoptLandedViews, exclusionsOf, matchedIdsOf, planViewWrites, published, renderedWorkspaceOf, serverDiagnostic, viewSummaryOf, } from "./workspace-model.js";
13
15
  import { compileWorkspaceWithProfileContext, } from "../../compiler.js";
14
- import { applyOperations } from "../../apply-command.js";
15
- import { projectGraphForCanvas } from "../../graph-projection.js";
16
- import { kindLabelOf } from "../../kind-label.js";
16
+ import { planOperations, posixDirectoryOf, writeConflictDiagnostics, } from "../../apply-command.js";
17
+ import { createFileSystemStore } from "../../source-store.js";
17
18
  import { loadWorkspaceManifest, } from "../../workspace.js";
18
19
  import visualLayoutSchema from "../../../schema/yarramate-visual-layout.schema.json" with { type: "json" };
19
20
  import visualProjectionSchema from "../../../schema/yarramate-projection.schema.json" with { type: "json" };
@@ -26,12 +27,6 @@ const Ajv2020 = Ajv2020Module.default;
26
27
  const layoutAjv = new Ajv2020({ allErrors: true });
27
28
  layoutAjv.addSchema(visualProjectionSchema);
28
29
  const validateVisualLayout = layoutAjv.compile(visualLayoutSchema);
29
- /**
30
- * Document name reported by diagnostics the server itself raises — a refused
31
- * frame, a frozen queue, or a transport violation that no protocol document
32
- * owns.
33
- */
34
- export const VISUAL_SERVER_DOCUMENT = "visual-session-server";
35
30
  /**
36
31
  * Returned on every response. The policy admits no external origin at all, so
37
32
  * neither a compromised model nor a hostile chat message can reach the network,
@@ -152,15 +147,17 @@ const serverError = (code, message) => new Error(`${code}: ${message}`);
152
147
  * `/`, because these diagnostics are published in `visual-diagnostic-result/v1`
153
148
  * documents and read back by the one-shot agent clients.
154
149
  */
155
- const serverDiagnostic = (code, message, pointer = "/") => ({
156
- severity: "error",
157
- code,
158
- message,
159
- path: VISUAL_SERVER_DOCUMENT,
160
- pointer,
161
- line: 1,
162
- column: 1,
163
- });
150
+ /**
151
+ * Names the subjects a Core diagnostic is about, so the canvas can mark the
152
+ * element a rule refused rather than reporting a failure with nothing on
153
+ * screen changed.
154
+ *
155
+ * Core derives this at the boundary that publishes a result rather than inside
156
+ * `compileWorkspace`, which keeps compiler diagnostics a pure function of the
157
+ * model. `check` has always done it. This adapter is the consumer the
158
+ * derivation was written for and never did, so every diagnostic it sent to a
159
+ * browser arrived anchored to a byte offset the browser cannot use.
160
+ */
164
161
  /**
165
162
  * Compares two capabilities without leaking where they differ, or how long the
166
163
  * presented one is: both sides are reduced to a fixed-width digest first, so
@@ -248,8 +245,6 @@ const eventFrom = (input, envelope) => {
248
245
  return { ...envelope, type: input.type, payload: input.payload };
249
246
  case "filter.query":
250
247
  return { ...envelope, type: input.type, payload: input.payload };
251
- case "view.save":
252
- return { ...envelope, type: input.type, payload: input.payload };
253
248
  case "changeset.commit":
254
249
  return { ...envelope, type: input.type, payload: input.payload };
255
250
  case "layout.save":
@@ -257,27 +252,70 @@ const eventFrom = (input, envelope) => {
257
252
  }
258
253
  };
259
254
  /**
260
- * Turns a view title into a schema-valid projection id: lowercase,
261
- * hyphen-separated, letter-led. A title with no letters or digits degrades
262
- * to "view" rather than producing an id the schema would reject.
255
+ * What a batch that changes no subject reports.
256
+ *
257
+ * A commit carrying only view operations never reaches Core: an operations
258
+ * document with an empty list is refused by the operations schema (`YM201`),
259
+ * and rightly - "apply nothing" is not a batch. The receipt still has to name
260
+ * the documents that changed, which here are the projections.
263
261
  */
264
- const slugify = (title) => {
265
- const cleaned = title
266
- .toLowerCase()
267
- .replace(/[^a-z0-9]+/g, "-")
268
- .replace(/^-+|-+$/g, "");
269
- if (cleaned.length === 0)
270
- return "view";
271
- return /^[a-z]/.test(cleaned) ? cleaned : `view-${cleaned}`;
272
- };
273
- /** Appends a numeric suffix only when the base id collides with a known view. */
274
- const uniqueViewId = (base, taken) => {
275
- if (!taken.has(base))
276
- return base;
277
- let suffix = 2;
278
- while (taken.has(`${base}-${suffix}`))
279
- suffix += 1;
280
- return `${base}-${suffix}`;
262
+ const noModelChange = (workspace, documents) => ({
263
+ format: "yarramate/apply-result/v1",
264
+ workspace,
265
+ applied: {
266
+ addedConcepts: 0,
267
+ addedRelationships: 0,
268
+ updatedConcepts: 0,
269
+ updatedRelationships: 0,
270
+ deletedConcepts: 0,
271
+ deletedRelationships: 0,
272
+ renamedConcepts: 0,
273
+ renamedRelationships: 0,
274
+ addedObservations: 0,
275
+ updatedObservations: 0,
276
+ deletedObservations: 0,
277
+ },
278
+ documents,
279
+ });
280
+ /**
281
+ * Lands a planned batch plus the view writes beside it, as one `writeAll`.
282
+ *
283
+ * `landOperations` would write the model's half on its own, which is the one
284
+ * thing this must not do: two calls are two batches, and a view removed in one
285
+ * and a subject edited in the other can half land.
286
+ */
287
+ const landPlanned = (store, planned, viewWrites, operationsPath, workspaceId) => {
288
+ if (planned !== null && !planned.ok)
289
+ return planned.outcome;
290
+ const writes = [...(planned?.writes ?? []), ...viewWrites];
291
+ const viewPaths = viewWrites.map((write) => write.path);
292
+ const outcome = planned === null
293
+ ? {
294
+ ok: true,
295
+ sources: [],
296
+ result: noModelChange(workspaceId, viewPaths),
297
+ }
298
+ : {
299
+ ...planned.outcome,
300
+ // Core names the documents IT rewrote, which is the model's half.
301
+ // The projections went out in the same `writeAll`, so a receipt that
302
+ // named only Core's half would report one file where two landed -
303
+ // and a mixed batch is the ordinary case now that creating a subject
304
+ // also puts it in the view that created it (#255).
305
+ result: {
306
+ ...planned.outcome.result,
307
+ documents: [...planned.outcome.result.documents, ...viewPaths],
308
+ },
309
+ };
310
+ if (writes.length === 0)
311
+ return outcome;
312
+ const written = store.writeAll(writes);
313
+ if (written.ok)
314
+ return outcome;
315
+ return {
316
+ ok: false,
317
+ diagnostics: writeConflictDiagnostics(written.conflicts, operationsPath),
318
+ };
281
319
  };
282
320
  /**
283
321
  * Serves one authenticated visual session over loopback. The handle owns the
@@ -344,10 +382,16 @@ export const startVisualServer = async (options) => {
344
382
  }
345
383
  return abandon(serverError("YMVS132", `Session requires a resolvable workspace manifest at ${manifestPath}`));
346
384
  })();
347
- // Saved views join this list as they are written (see `view.save`), because
348
- // a reconnecting browser is handed it in its snapshot and `layout.save`
349
- // checks projection ids against it.
350
- const views = resolvedWorkspace.projections.flatMap((projectionPath) => {
385
+ /**
386
+ * One view, read off disk. `undefined` for a projection that is unreadable
387
+ * or that the schema refuses — a broken saved view skips rather than failing
388
+ * the session, the same way a broken layout sidecar does.
389
+ *
390
+ * `subjectCount` is left at zero here and refreshed by
391
+ * `renderedWorkspaceOf`: counting needs the compiled graph, which does not
392
+ * exist yet when the session builds its first list.
393
+ */
394
+ const readViewSummary = (projectionPath) => {
351
395
  try {
352
396
  const projectionSource = readFileSync(resolve(options.cwd, projectionPath), "utf8");
353
397
  const loaded = loadProjection({
@@ -355,22 +399,19 @@ export const startVisualServer = async (options) => {
355
399
  source: projectionSource,
356
400
  });
357
401
  if (!loaded.ok)
358
- return [];
359
- const { projection } = loaded;
360
- return [
361
- {
362
- id: projection.id,
363
- title: projection.presentation?.title ?? projection.id,
364
- description: projection.presentation?.description ?? "",
365
- query: projection.query,
366
- presentation: projection.presentation,
367
- },
368
- ];
402
+ return undefined;
403
+ return viewSummaryOf(loaded.projection, projectionPath);
369
404
  }
370
405
  catch {
371
- // Skipped projection: session continues with remaining views.
372
- return [];
406
+ return undefined;
373
407
  }
408
+ };
409
+ // Saved views join this list as a commit lands them (see `adoptLandedViews`),
410
+ // because a reconnecting browser is handed it in its snapshot and
411
+ // `layout.save` checks projection ids against it.
412
+ const views = resolvedWorkspace.projections.flatMap((projectionPath) => {
413
+ const summary = readViewSummary(projectionPath);
414
+ return summary === undefined ? [] : [summary];
374
415
  });
375
416
  // Drag positions are adapter-owned presentation state (ADR 0023): never
376
417
  // validated by Core. An invalid or unreadable sidecar is skipped exactly
@@ -416,6 +457,10 @@ export const startVisualServer = async (options) => {
416
457
  vocabulary: { conceptKinds: [], relationshipKinds: [] },
417
458
  layouts,
418
459
  sourceDigests: request.initialModel.sourceDigests,
460
+ // The request's model has no projections in it - `visual-model/v1` carries
461
+ // a graph, not a workspace - so the fallback states nothing rather than
462
+ // guessing. `recompileWorkspace` below fills it from disk immediately.
463
+ projectionDigests: {},
419
464
  };
420
465
  const capabilities = {
421
466
  chat: request.chatEnabled,
@@ -440,15 +485,49 @@ export const startVisualServer = async (options) => {
440
485
  * Returns whether it compiled, so a post-write failure can freeze the
441
486
  * session instead of serving a stale graph.
442
487
  */
443
- const recompileWorkspace = () => {
488
+ const workspaceSources = () => {
444
489
  try {
445
- const sources = [
490
+ return [
446
491
  ...resolvedWorkspace.profiles,
447
492
  ...resolvedWorkspace.documents,
448
493
  ].map((path) => ({
449
494
  path,
450
495
  source: readFileSync(resolve(options.cwd, path), "utf8"),
451
496
  }));
497
+ }
498
+ catch {
499
+ // A source that cannot be read is one whose diagnostics belong to no
500
+ // subject anyway, so an empty list is the right answer rather than a
501
+ // throw from a path that is only trying to add detail.
502
+ return [];
503
+ }
504
+ };
505
+ /**
506
+ * The digest of every projection the session knows about, read now.
507
+ *
508
+ * Read from `views` rather than from `resolvedWorkspace.projections`,
509
+ * because a view created mid-session joins the former and never the latter:
510
+ * the manifest is resolved once at startup and never again. One that has
511
+ * been removed is simply absent, which is the same thing the map says about
512
+ * one that never existed — and the browser reads both as "no pin", which is
513
+ * correct for a create and refused by the store for a delete.
514
+ */
515
+ const projectionDigestsNow = () => {
516
+ const digests = {};
517
+ for (const view of views) {
518
+ try {
519
+ digests[view.path] = digestOf(readFileSync(resolve(options.cwd, view.path), "utf8"));
520
+ }
521
+ catch {
522
+ // Gone from disk: nothing to pin against, and the commit's own
523
+ // conflict check is what reports it rather than a guess made here.
524
+ }
525
+ }
526
+ return digests;
527
+ };
528
+ const recompileWorkspace = () => {
529
+ try {
530
+ const sources = workspaceSources();
452
531
  const compiled = compileWorkspaceWithProfileContext(sources);
453
532
  if (!compiled.ok) {
454
533
  compiledWorkspace = undefined;
@@ -458,23 +537,24 @@ export const startVisualServer = async (options) => {
458
537
  graph: compiled.graph,
459
538
  profileContext: compiled.profileContext,
460
539
  };
461
- const conceptKinds = [
462
- ...compiled.profileContext.conceptKindLineages.keys(),
463
- ].map((id) => ({ id, label: kindLabelOf(id) }));
464
- const relationshipKinds = [
465
- ...compiled.profileContext.relationshipKindLineages.keys(),
466
- ].map((id) => ({ id, label: kindLabelOf(id) }));
467
- rendered = {
540
+ const workspaceModel = renderedWorkspaceOf(compiledWorkspace, views, {
468
541
  authority: rendered.authority,
469
542
  initialView: rendered.initialView,
470
- graph: projectGraphForCanvas(compiled.graph, compiled.profileContext),
471
543
  documents: resolvedWorkspace.documents,
472
- vocabulary: { conceptKinds, relationshipKinds },
473
544
  layouts: rendered.layouts,
474
545
  // Minted from the bytes this compile just read, so what the browser
475
546
  // renders and what it can later claim it rendered are the same read.
476
547
  sourceDigests: Object.fromEntries(sources.map(({ path, source }) => [path, digestOf(source)])),
477
- };
548
+ // Kept apart from `sourceDigests`, which means what this graph was
549
+ // compiled from and is what `YMVS112` checks — a projection is not
550
+ // that. A staged view operation pins against these (ADR 0103), and a
551
+ // projection missing from the map is one the commit will create.
552
+ projectionDigests: projectionDigestsNow(),
553
+ });
554
+ // Closures below retain this array, so refresh its contents without
555
+ // replacing the identity the session started with.
556
+ views.splice(0, views.length, ...workspaceModel.views);
557
+ rendered = workspaceModel.model;
478
558
  return true;
479
559
  }
480
560
  catch {
@@ -483,12 +563,23 @@ export const startVisualServer = async (options) => {
483
563
  }
484
564
  };
485
565
  recompileWorkspace();
486
- const filterMatchedIds = (query) => {
487
- if (compiledWorkspace === undefined)
488
- return [];
489
- const result = evaluateProjection(compiledWorkspace.graph, { format: "yarramate/projection/v1", id: "ad-hoc", version: "0", query }, compiledWorkspace.profileContext);
490
- return result.subjects.map(({ id }) => id);
491
- };
566
+ const filterMatchedIds = (query) => compiledWorkspace === undefined
567
+ ? []
568
+ : matchedIdsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext);
569
+ /**
570
+ * Why a query dropped what it dropped, as the editor's "excluded, and why"
571
+ * list reads it (#248).
572
+ *
573
+ * Resolved here rather than in the browser for the same reason the match set
574
+ * is: a `ProjectionQuery` needs the semantic graph to answer, and the browser
575
+ * has the rendered model rather than the graph. One evaluator, one answer -
576
+ * `explainProjection` shares `conceptSelector` with `evaluateProjection`, so
577
+ * the reason the editor shows and the set the canvas draws cannot come from
578
+ * two readings of the same query.
579
+ */
580
+ const filterExclusions = (query) => compiledWorkspace === undefined
581
+ ? []
582
+ : exclusionsOf(compiledWorkspace.graph, query, compiledWorkspace.profileContext);
492
583
  let listening = false;
493
584
  let bootstrapSpent = false;
494
585
  let agentAttached = false;
@@ -996,64 +1087,11 @@ export const startVisualServer = async (options) => {
996
1087
  result: {
997
1088
  query: event.payload.query,
998
1089
  matchedIds: filterMatchedIds(event.payload.query),
1090
+ excluded: filterExclusions(event.payload.query),
999
1091
  },
1000
1092
  });
1001
1093
  return;
1002
1094
  }
1003
- if (event.type === "view.save") {
1004
- // Saving a view is a pure filesystem write plus schema validation: it
1005
- // never asks the agent anything, so it is answered here directly
1006
- // rather than through the pending queue a poll would drain.
1007
- const existingIds = new Set(views.map((view) => view.id));
1008
- const id = event.payload.id ??
1009
- uniqueViewId(slugify(event.payload.title), existingIds);
1010
- const presentation = {
1011
- ...(event.payload.presentation ?? {}),
1012
- title: event.payload.title,
1013
- description: event.payload.description,
1014
- };
1015
- const candidate = {
1016
- format: "yarramate/projection/v1",
1017
- id,
1018
- version: "1.0",
1019
- query: event.payload.query,
1020
- presentation,
1021
- };
1022
- const path = `.yarramate/projections/${id}.yaml`;
1023
- const source = stringify(candidate);
1024
- const loaded = loadProjection({ path, source });
1025
- if (!loaded.ok) {
1026
- sendFrame(socket, {
1027
- kind: "view-save-result",
1028
- result: { ok: false, diagnostics: loaded.diagnostics },
1029
- });
1030
- return;
1031
- }
1032
- mkdirSync(resolve(options.cwd, ".yarramate/projections"), {
1033
- recursive: true,
1034
- });
1035
- writeFileSync(resolve(options.cwd, path), source, "utf8");
1036
- // On disk is not enough: the list handed to a reconnecting browser and
1037
- // checked by `layout.save` lives here, so an overwrite replaces its
1038
- // summary in place and a new view joins the end.
1039
- const summary = {
1040
- id,
1041
- title: event.payload.title,
1042
- description: event.payload.description,
1043
- query: event.payload.query,
1044
- presentation,
1045
- };
1046
- const saved = views.findIndex((view) => view.id === id);
1047
- if (saved === -1)
1048
- views.push(summary);
1049
- else
1050
- views[saved] = summary;
1051
- sendFrame(socket, {
1052
- kind: "view-save-result",
1053
- result: { ok: true, id, path },
1054
- });
1055
- return;
1056
- }
1057
1095
  if (event.type === "changeset.commit") {
1058
1096
  // Landing a batch is mechanical: Core's `applyOperations` never asks
1059
1097
  // the agent anything, so it is answered here directly rather than
@@ -1073,7 +1111,14 @@ export const startVisualServer = async (options) => {
1073
1111
  // state is decoration.
1074
1112
  const pins = event.payload.sourceDigests;
1075
1113
  const refused = [];
1076
- for (const path of new Set(event.payload.operations.map((operation) => operation.document))) {
1114
+ for (const path of new Set([
1115
+ ...event.payload.operations.map((operation) => operation.document),
1116
+ // A staged view is a document this batch touches like any other, so
1117
+ // it answers to the same precondition (ADR 0103). Without this a
1118
+ // projection someone edited on disk would be overwritten by a
1119
+ // reviewer who never saw the edit.
1120
+ ...event.payload.viewOperations.map((operation) => operation.path),
1121
+ ])) {
1077
1122
  let held;
1078
1123
  try {
1079
1124
  held = digestOf(readFileSync(resolve(options.cwd, path), "utf8"));
@@ -1106,29 +1151,84 @@ export const startVisualServer = async (options) => {
1106
1151
  result: { ok: false, diagnostics: refused },
1107
1152
  });
1108
1153
  if (recompileWorkspace())
1109
- broadcast({ kind: "model", model: rendered });
1154
+ broadcast({ kind: "model", model: rendered, views });
1110
1155
  return;
1111
1156
  }
1112
1157
  const operationsSource = stringify({
1113
1158
  format: "yarramate/operations/v1",
1114
1159
  operations: event.payload.operations,
1115
1160
  });
1116
- const outcome = applyOperations({ path: "changeset.yaml", source: operationsSource }, { path: manifestPath, source: readFileSync(manifestPath, "utf8") }, options.cwd);
1161
+ // The pin check above is about the reviewer's view: whether the value
1162
+ // on screen is still the value on disk. What follows is about the
1163
+ // bytes: Core is handed the sources, returns the ones it changed, and
1164
+ // the store writes them only if each still holds what Core was shown
1165
+ // (ADR 0100). That second check is what closes the window the pin
1166
+ // check alone left open, which used to span a whole workspace compile
1167
+ // between the digest that satisfied it and the write that acted on it.
1168
+ const loadedWorkspace = loadWorkspaceManifest({ path: manifestPath, source: readFileSync(manifestPath, "utf8") }, options.cwd);
1169
+ if (!loadedWorkspace.ok) {
1170
+ sendFrame(socket, {
1171
+ kind: "apply-result",
1172
+ result: {
1173
+ ok: false,
1174
+ diagnostics: published(loadedWorkspace.diagnostics, workspaceSources()),
1175
+ },
1176
+ });
1177
+ return;
1178
+ }
1179
+ const store = createFileSystemStore(options.cwd);
1180
+ // Core is asked nothing when nothing about the model changed: an
1181
+ // operations document with an empty list is refused by the operations
1182
+ // schema, and a batch of views only is a legitimate thing to commit.
1183
+ const planned = event.payload.operations.length === 0
1184
+ ? null
1185
+ : planOperations(store, {
1186
+ workspace: loadedWorkspace.workspace,
1187
+ operations: {
1188
+ path: "changeset.yaml",
1189
+ source: operationsSource,
1190
+ },
1191
+ manifestDirectory: posixDirectoryOf(relative(options.cwd, manifestPath)),
1192
+ });
1193
+ // The views' own writes, composed and validated the same way
1194
+ // `view.save` used to compose one, but not yet written: both halves go
1195
+ // to the store in a single batch so a view and the subjects it shows
1196
+ // land together or not at all (ADR 0103).
1197
+ const viewWrites = planViewWrites(event.payload.viewOperations, loadedWorkspace.workspace, store);
1198
+ if (!viewWrites.ok) {
1199
+ sendFrame(socket, {
1200
+ kind: "apply-result",
1201
+ result: { ok: false, diagnostics: viewWrites.diagnostics },
1202
+ });
1203
+ return;
1204
+ }
1205
+ const outcome = landPlanned(store, planned, viewWrites.writes, "changeset.yaml", loadedWorkspace.workspace.id);
1117
1206
  if (!outcome.ok) {
1118
1207
  // Nothing landed: forward Core's diagnostics verbatim (ADR 0062)
1119
1208
  // rather than recompiling or broadcasting anything.
1120
1209
  sendFrame(socket, {
1121
1210
  kind: "apply-result",
1122
- result: { ok: false, diagnostics: outcome.diagnostics },
1211
+ result: {
1212
+ ok: false,
1213
+ diagnostics: published(outcome.diagnostics, workspaceSources()),
1214
+ },
1123
1215
  });
1124
1216
  return;
1125
1217
  }
1218
+ // The views list is maintained by hand, because nothing else will:
1219
+ // `resolvedWorkspace` was resolved once at startup and never again, so
1220
+ // a recompile re-reads the documents it already knew about and learns
1221
+ // nothing about a projection this batch just created or removed. The
1222
+ // rail, `layout.save`'s id check, and a reconnecting browser all read
1223
+ // this list, so leaving it stale is how they come to disagree with the
1224
+ // disk. Preserve the mutable list identity that session closures hold.
1225
+ views.splice(0, views.length, ...adoptLandedViews(views, event.payload.viewOperations, readViewSummary));
1126
1226
  sendFrame(socket, {
1127
1227
  kind: "apply-result",
1128
1228
  result: { ok: true, result: outcome.result },
1129
1229
  });
1130
1230
  if (recompileWorkspace()) {
1131
- broadcast({ kind: "model", model: rendered });
1231
+ broadcast({ kind: "model", model: rendered, views });
1132
1232
  return;
1133
1233
  }
1134
1234
  // A post-write compile failure is a bug, not a user error: the batch
@@ -1781,17 +1881,17 @@ export const startVisualServer = async (options) => {
1781
1881
  let started;
1782
1882
  try {
1783
1883
  await writeVisualSessionDescriptor(paths, {
1784
- format: "yarramate/visual-session-descriptor/v1",
1884
+ format: "yarramate/visual-session-descriptor/v2",
1785
1885
  protocolVersion: VISUAL_PROTOCOL_VERSION,
1786
1886
  sessionId,
1787
1887
  origin,
1788
1888
  agentCapability: session.agentToken,
1789
- sessionRoot: paths.root,
1790
- journalPath: paths.journal,
1889
+ sessionRoot: toWireFileUri(paths.root),
1890
+ journalPath: toWireFileUri(paths.journal),
1791
1891
  createdAt: stamp(),
1792
1892
  });
1793
1893
  started = {
1794
- format: "yarramate/visual-session-started/v1",
1894
+ format: "yarramate/visual-session-started/v2",
1795
1895
  protocolVersion: VISUAL_PROTOCOL_VERSION,
1796
1896
  sessionId,
1797
1897
  authority: request.authority,
@@ -1800,8 +1900,8 @@ export const startVisualServer = async (options) => {
1800
1900
  browserUrl: `${origin}/bootstrap?key=${session.browserToken}`,
1801
1901
  webSocketUrl,
1802
1902
  origin,
1803
- descriptorPath: paths.descriptor,
1804
- sessionRoot: paths.root,
1903
+ descriptorPath: toWireFileUri(paths.descriptor),
1904
+ sessionRoot: toWireFileUri(paths.root),
1805
1905
  capabilities,
1806
1906
  startedAt: stamp(),
1807
1907
  };
@@ -61,6 +61,15 @@ export interface VisualAppendRejected {
61
61
  readonly diagnostics: readonly VisualDiagnostic[];
62
62
  }
63
63
  export type VisualAppendResult = VisualAppendAccepted | VisualAppendRejected;
64
+ /**
65
+ * The one place `VisualSessionPaths` is constructed. Every field stays a
66
+ * native filesystem path — the form `fs`/`path` APIs, the in-process state
67
+ * maps keyed by `paths.journal`, and directory cleanup all need — because
68
+ * this is consumed as a filesystem path far more often than it is ever
69
+ * serialized. The few sites that write one of these fields into a
70
+ * schema-checked wire document encode it there, with `toWireFileUri`, rather
71
+ * than here.
72
+ */
64
73
  export declare const visualSessionPaths: (root: string) => VisualSessionPaths;
65
74
  export declare const createVisualSession: (request: VisualSessionRequest, deps: SessionDependencies) => Promise<VisualSessionCreated>;
66
75
  /**
@@ -1,7 +1,7 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, truncate, writeFile, } from 'node:fs/promises';
3
3
  import { basename, dirname, join, resolve } from 'node:path';
4
- import { VISUAL_LIMITS, parseVisualEvent, parseVisualHandoff, parseVisualResponse, parseVisualSessionDescriptor, parseVisualSessionRequest, } from './protocol.js';
4
+ import { VISUAL_LIMITS, parseVisualEvent, parseVisualHandoff, parseVisualResponse, parseVisualSessionDescriptor, parseVisualSessionRequest, toWireFileUri, } from './protocol.js';
5
5
  import { compileWorkspaceWithProfileContext, } from '../../compiler.js';
6
6
  import { projectGraphForCanvas } from '../../graph-projection.js';
7
7
  export const VISUAL_SESSION_MARKER_FORMAT = 'yarramate/visual-session-marker/v1';
@@ -105,6 +105,15 @@ const writePrivateJson = async (path, value) => {
105
105
  await rename(temporary, path);
106
106
  await syncDirectory(dirname(path));
107
107
  };
108
+ /**
109
+ * The one place `VisualSessionPaths` is constructed. Every field stays a
110
+ * native filesystem path — the form `fs`/`path` APIs, the in-process state
111
+ * maps keyed by `paths.journal`, and directory cleanup all need — because
112
+ * this is consumed as a filesystem path far more often than it is ever
113
+ * serialized. The few sites that write one of these fields into a
114
+ * schema-checked wire document encode it there, with `toWireFileUri`, rather
115
+ * than here.
116
+ */
108
117
  export const visualSessionPaths = (root) => {
109
118
  const base = resolve(root);
110
119
  return {
@@ -298,8 +307,8 @@ export const writeVisualSessionDescriptor = async (paths, descriptor) => {
298
307
  if (validated.value.sessionId !== marker.id) {
299
308
  throw storeError('YMVS126', `Descriptor belongs to session "${validated.value.sessionId}", not "${marker.id}"`);
300
309
  }
301
- if (validated.value.sessionRoot !== paths.root ||
302
- validated.value.journalPath !== paths.journal) {
310
+ if (validated.value.sessionRoot !== toWireFileUri(paths.root) ||
311
+ validated.value.journalPath !== toWireFileUri(paths.journal)) {
303
312
  throw storeError('YMVS125', `Descriptor for session "${marker.id}" names artefacts outside "${paths.root}"`);
304
313
  }
305
314
  await writePrivateJson(paths.descriptor, validated.value);
@@ -521,7 +530,7 @@ export const recoverVisualSession = async (paths, includeTranscript = false) =>
521
530
  : 'cancelled';
522
531
  const last = journal.records.at(-1);
523
532
  const handoff = {
524
- format: 'yarramate/visual-handoff/v1',
533
+ format: 'yarramate/visual-handoff/v2',
525
534
  sessionId: marker.id,
526
535
  authority: marker.authority,
527
536
  decision,
@@ -533,7 +542,7 @@ export const recoverVisualSession = async (paths, includeTranscript = false) =>
533
542
  requestedChanges: summary?.requestedChanges ?? [],
534
543
  unresolvedQuestions: summary?.unresolvedQuestions ?? [],
535
544
  finalViews: summary?.finalViews ?? visited.slice(-256),
536
- transcriptPath: paths.journal,
545
+ transcriptPath: toWireFileUri(paths.journal),
537
546
  completedAt: last?.timestamp ?? marker.createdAt,
538
547
  // Present and undefined rather than absent, so a caller cannot mistake the
539
548
  // summary-only handoff for one whose transcript it forgot to read.