@sapiom/harness 0.8.8 → 0.9.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 (88) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +11 -1
  3. package/dist/core/agent-project-discovery.d.ts +235 -1
  4. package/dist/core/agent-project-discovery.d.ts.map +1 -1
  5. package/dist/core/agent-project-discovery.js +303 -1
  6. package/dist/core/agent-project-discovery.js.map +1 -1
  7. package/dist/core/canvas-interconnections.d.ts +31 -0
  8. package/dist/core/canvas-interconnections.d.ts.map +1 -1
  9. package/dist/core/canvas-interconnections.js +294 -21
  10. package/dist/core/canvas-interconnections.js.map +1 -1
  11. package/dist/core/canvas-render.d.ts +32 -0
  12. package/dist/core/canvas-render.d.ts.map +1 -1
  13. package/dist/core/canvas-render.js +50 -41
  14. package/dist/core/canvas-render.js.map +1 -1
  15. package/dist/core/definition-name.d.ts +12 -0
  16. package/dist/core/definition-name.d.ts.map +1 -1
  17. package/dist/core/definition-name.js +14 -5
  18. package/dist/core/definition-name.js.map +1 -1
  19. package/dist/core/system-graph-inventory.d.ts +68 -0
  20. package/dist/core/system-graph-inventory.d.ts.map +1 -0
  21. package/dist/core/system-graph-inventory.js +312 -0
  22. package/dist/core/system-graph-inventory.js.map +1 -0
  23. package/dist/core/system-graph-relationships.d.ts +41 -0
  24. package/dist/core/system-graph-relationships.d.ts.map +1 -0
  25. package/dist/core/system-graph-relationships.js +98 -0
  26. package/dist/core/system-graph-relationships.js.map +1 -0
  27. package/dist/core/system-graph-store.d.ts +45 -0
  28. package/dist/core/system-graph-store.d.ts.map +1 -0
  29. package/dist/core/system-graph-store.js +222 -0
  30. package/dist/core/system-graph-store.js.map +1 -0
  31. package/dist/core/system-graph-watcher.d.ts +51 -0
  32. package/dist/core/system-graph-watcher.d.ts.map +1 -0
  33. package/dist/core/system-graph-watcher.js +448 -0
  34. package/dist/core/system-graph-watcher.js.map +1 -0
  35. package/dist/core/system-graph.d.ts +43 -0
  36. package/dist/core/system-graph.d.ts.map +1 -0
  37. package/dist/core/system-graph.js +321 -0
  38. package/dist/core/system-graph.js.map +1 -0
  39. package/dist/core/workflow-registry.d.ts +84 -6
  40. package/dist/core/workflow-registry.d.ts.map +1 -1
  41. package/dist/core/workflow-registry.js +191 -91
  42. package/dist/core/workflow-registry.js.map +1 -1
  43. package/dist/core/workspace-watcher.d.ts +21 -13
  44. package/dist/core/workspace-watcher.d.ts.map +1 -1
  45. package/dist/core/workspace-watcher.js +79 -81
  46. package/dist/core/workspace-watcher.js.map +1 -1
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/profiles/default.d.ts.map +1 -1
  51. package/dist/profiles/default.js +4 -2
  52. package/dist/profiles/default.js.map +1 -1
  53. package/dist/server/agent-move.d.ts +134 -0
  54. package/dist/server/agent-move.d.ts.map +1 -0
  55. package/dist/server/agent-move.js +343 -0
  56. package/dist/server/agent-move.js.map +1 -0
  57. package/dist/server/index.d.ts.map +1 -1
  58. package/dist/server/index.js +275 -22
  59. package/dist/server/index.js.map +1 -1
  60. package/dist/server/rest.d.ts +3 -0
  61. package/dist/server/rest.d.ts.map +1 -1
  62. package/dist/server/rest.js +3 -0
  63. package/dist/server/rest.js.map +1 -1
  64. package/dist/server/studio-rail.d.ts +76 -0
  65. package/dist/server/studio-rail.d.ts.map +1 -0
  66. package/dist/server/studio-rail.js +234 -0
  67. package/dist/server/studio-rail.js.map +1 -0
  68. package/dist/server/system-graph.d.ts +14 -0
  69. package/dist/server/system-graph.d.ts.map +1 -0
  70. package/dist/server/system-graph.js +40 -0
  71. package/dist/server/system-graph.js.map +1 -0
  72. package/dist/server/workflow-graph.d.ts +43 -0
  73. package/dist/server/workflow-graph.d.ts.map +1 -0
  74. package/dist/server/workflow-graph.js +200 -0
  75. package/dist/server/workflow-graph.js.map +1 -0
  76. package/dist/shared/system-graph.d.ts +59 -0
  77. package/dist/shared/system-graph.d.ts.map +1 -0
  78. package/dist/shared/system-graph.js +76 -0
  79. package/dist/shared/system-graph.js.map +1 -0
  80. package/dist/shared/types.d.ts +45 -0
  81. package/dist/shared/types.d.ts.map +1 -1
  82. package/dist/shared/types.js.map +1 -1
  83. package/dist/web/assets/index-2Fab6dzf.js +508 -0
  84. package/dist/web/assets/index-D0doaFvT.css +32 -0
  85. package/dist/web/index.html +2 -2
  86. package/package.json +5 -5
  87. package/dist/web/assets/index-DrTPW10E.js +0 -494
  88. package/dist/web/assets/index-jGBbXx9R.css +0 -32
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { createServer as createHttpServer, } from "node:http";
10
10
  import { readFileSync } from "node:fs";
11
- import { dirname, join, sep } from "node:path";
11
+ import { dirname, join, resolve, sep } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import express from "express";
14
14
  import { WebSocketServer } from "ws";
@@ -21,6 +21,7 @@ import { TaskManager } from "../core/task-manager.js";
21
21
  import { createClaudeCodeAdapter } from "../core/adapters/claude-code.js";
22
22
  import { createCodexAdapter } from "../core/adapters/codex.js";
23
23
  import { WorkflowRegistry, createWorkflowsRouter, } from "../core/workflow-registry.js";
24
+ import { AgentProjectScanBudget } from "../core/agent-project-discovery.js";
24
25
  import { DEFAULT_MACROS } from "../core/macros.js";
25
26
  import { createEventStore } from "../core/collector/store.js";
26
27
  import { createClaudeTranscriptEnricher, createSessionRecordReader, } from "../core/session-record.js";
@@ -36,7 +37,7 @@ import { findRolloutFile, tailCodexRollout, } from "../core/collector/codex-tail
36
37
  import { getOrCreateMachineId } from "../cli/machine-id.js";
37
38
  import { loadSettings, pruneDeadRecentDirs } from "../cli/settings.js";
38
39
  import { generateClaudeSettings } from "../core/inject/claude-settings.js";
39
- import { generateMcpConfig } from "../core/inject/mcp-config.js";
40
+ import { generateMcpConfig, } from "../core/inject/mcp-config.js";
40
41
  import { generateSystemPromptFile } from "../core/inject/system-prompt.js";
41
42
  import { generateSkillsPlugin } from "../core/inject/skills-plugin.js";
42
43
  import { removeGeneratedSessionDir, sweepGeneratedDirs, } from "../core/inject/retention.js";
@@ -50,12 +51,17 @@ import { prepareHarnessContextForResume, writeHarnessContext, writeHarnessContex
50
51
  import { ensureCanvasTemplate } from "../core/canvas-template.js";
51
52
  import { renderCanvasForSession } from "../core/canvas-render.js";
52
53
  import { invalidateExtractionCache } from "../core/canvas-cache.js";
54
+ import { CachedAgentRelationshipProvider, HarnessRegistryInventoryProvider, LocalWorkspaceScopeCatalog, SourceAgentRelationshipProvider, StaticSystemGraphBuilder, } from "../core/system-graph.js";
55
+ import { canonicalGraphPath, dirtyGraphSourceRoots, graphSourceRootsWithinScope, isWithinGraphPath, } from "../core/system-graph-inventory.js";
56
+ import { SystemGraphStore } from "../core/system-graph-store.js";
57
+ import { SystemGraphWatcherManager } from "../core/system-graph-watcher.js";
53
58
  import { sweepNdjson } from "../core/collector/store-retention.js";
54
59
  import { createDefinitionSlugResolver, resolveAgentsBaseUrl, } from "../core/definition-slug-resolver.js";
55
- import { resolveManifestName } from "../core/definition-name.js";
60
+ import { inspectManifestName, resolveManifestName, } from "../core/definition-name.js";
56
61
  import { createBootTokenMiddleware } from "./auth.js";
57
62
  import { createApiKeyProvider } from "../core/api-key-provider.js";
58
63
  import { createRestRouter } from "./rest.js";
64
+ import { createSystemGraphRouter } from "./system-graph.js";
59
65
  import { createStaticRouter } from "./static.js";
60
66
  import { createTerminalWebSocketHandler } from "./terminal-ws.js";
61
67
  import { createEventsWebSocketHandler } from "./events-ws.js";
@@ -63,13 +69,16 @@ import { attachWebSocketRouters } from "./ws-router.js";
63
69
  import { createIngestRouter, processIngest, } from "./ingest.js";
64
70
  import { createCanvasRouter } from "./canvas.js";
65
71
  import { createCanvasRenderRouter } from "./canvas-render.js";
72
+ import { createWorkflowGraphRouter } from "./workflow-graph.js";
73
+ import { createStudioRailRouter } from "./studio-rail.js";
74
+ import { createAgentMoveRouter, moveTargetDirs, remapSessions, } from "./agent-move.js";
66
75
  import { createMacrosRouter } from "./macros.js";
67
76
  import { createFsRouter } from "./fs.js";
68
77
  import { createRunsRouter } from "./runs.js";
69
78
  import { createTemplatesRouter } from "./templates.js";
70
79
  import { createAccountRouter } from "./account.js";
71
80
  import { createActionsRouter } from "./actions.js";
72
- import { createAuthRouter, createMutableAuthState, } from "./auth-routes.js";
81
+ import { createAuthRouter, createMutableAuthState } from "./auth-routes.js";
73
82
  // resolveAgentsBaseUrl is imported above from definition-slug-resolver.js
74
83
  // (an identical helper); the runs router reuses it for its agents base URL.
75
84
  /**
@@ -102,6 +111,19 @@ const NDJSON_RETENTION_SWEEP_MS = 6 * 60 * 60 * 1000; // every 6 hours
102
111
  function packageRoot() {
103
112
  return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
104
113
  }
114
+ /**
115
+ * One line per scan, on stderr with the rest of the harness's operational log.
116
+ * Deliberately unconditional rather than debug-gated: a scan happens a handful
117
+ * of times per session, and the one question this log answers ("what put that
118
+ * there?") is only ever asked after the fact.
119
+ */
120
+ function logAgentScan(reason, root, found, budget) {
121
+ const truncated = budget.truncated
122
+ ? `, TRUNCATED at depth ${String(budget.truncatedAtDepth)} (envelope ${budget.envelopeDepth})`
123
+ : "";
124
+ console.error(`[harness] agent scan (${reason}) ${root}: ${found} agent(s), ` +
125
+ `${budget.visited} dirs${truncated}`);
126
+ }
105
127
  /** Whether two workflow lists are equivalent for the rail's purposes —
106
128
  * compares the fields the SPA actually renders/keys on, order-insensitive, so
107
129
  * a rescan that turned up nothing new doesn't trigger a needless broadcast. */
@@ -164,7 +186,11 @@ function createDefaultBuildLaunchOpts(apiKey, generatedRoot, rehydration, sapiom
164
186
  // dim text loses contrast. Only when the theme is known: an unthemed path
165
187
  // (server-side auto-create, a legacy session resumed before this existed)
166
188
  // omits it and Claude keeps its default 256-color rendering, exactly as before.
167
- const claudeTheme = req.theme === "light" ? "light-ansi" : req.theme === "dark" ? "dark-ansi" : undefined;
189
+ const claudeTheme = req.theme === "light"
190
+ ? "light-ansi"
191
+ : req.theme === "dark"
192
+ ? "dark-ansi"
193
+ : undefined;
168
194
  const [settings, mcpConfigFile, systemPromptFile, pluginDir] = await Promise.all([
169
195
  generateClaudeSettings({
170
196
  harnessSessionId,
@@ -191,7 +217,9 @@ function createDefaultBuildLaunchOpts(apiKey, generatedRoot, rehydration, sapiom
191
217
  ...(pluginDir ? { pluginDir } : {}),
192
218
  // Set on BOTH channels: the post-ready path hasn't delivered yet, but a
193
219
  // brief exists and will, and this is the flag that tells it to.
194
- ...(brief !== null && rehydrateFrom ? { rehydratedFrom: rehydrateFrom } : {}),
220
+ ...(brief !== null && rehydrateFrom
221
+ ? { rehydratedFrom: rehydrateFrom }
222
+ : {}),
195
223
  };
196
224
  };
197
225
  }
@@ -439,7 +467,8 @@ export const startServer = async (options) => {
439
467
  return null;
440
468
  try {
441
469
  const rows = await adapter.listPastSessions(record.cwd);
442
- return rows.find((row) => row.agentSessionId === record.agentSessionId)?.gitBranch ?? null;
470
+ return (rows.find((row) => row.agentSessionId === record.agentSessionId)
471
+ ?.gitBranch ?? null);
443
472
  }
444
473
  catch {
445
474
  return null;
@@ -472,7 +501,11 @@ export const startServer = async (options) => {
472
501
  title: prior?.title ?? null,
473
502
  gitBranch: await priorGitBranch(record),
474
503
  workflow: workflow
475
- ? { name: workflow.name, path: workflow.path, definitionId: workflow.definitionId }
504
+ ? {
505
+ name: workflow.name,
506
+ path: workflow.path,
507
+ definitionId: workflow.definitionId,
508
+ }
476
509
  : null,
477
510
  };
478
511
  },
@@ -500,6 +533,43 @@ export const startServer = async (options) => {
500
533
  ensureCanvasTemplate,
501
534
  });
502
535
  await sessionManager.init();
536
+ const workspaceScopeCatalog = new LocalWorkspaceScopeCatalog(async () => [
537
+ ...(await loadSettings(statePaths.settings)).recentDirs,
538
+ ...sessionManager.list().map((session) => session.cwd),
539
+ ]);
540
+ const systemGraphRelationships = new CachedAgentRelationshipProvider(new SourceAgentRelationshipProvider());
541
+ const systemGraphStore = new SystemGraphStore(new StaticSystemGraphBuilder(new HarnessRegistryInventoryProvider({
542
+ listWorkflows: () => workflowsCache,
543
+ inspectManifestName,
544
+ }), systemGraphRelationships), {
545
+ onChange: ({ workspaceKey, revision, state }) => {
546
+ bus.publish({
547
+ type: "system-graph.changed",
548
+ workspaceKey,
549
+ revision,
550
+ state,
551
+ });
552
+ },
553
+ });
554
+ const activeSystemGraphScopes = new Map();
555
+ const refreshSystemGraphScopesForRoot = (changedRoot, excludedWorkspaceKey) => {
556
+ const canonicalChangedRoot = canonicalGraphPath(changedRoot);
557
+ for (const scope of activeSystemGraphScopes.values()) {
558
+ if (scope.workspaceKey === excludedWorkspaceKey)
559
+ continue;
560
+ if (!systemGraphStore.peek(scope.workspaceKey))
561
+ continue;
562
+ const scopeRoot = canonicalGraphPath(scope.root);
563
+ if (!isWithinGraphPath(scopeRoot, canonicalChangedRoot) &&
564
+ !isWithinGraphPath(canonicalChangedRoot, scopeRoot)) {
565
+ continue;
566
+ }
567
+ systemGraphStore.requestRefresh({
568
+ workspaceKey: scope.workspaceKey,
569
+ root: scopeRoot,
570
+ });
571
+ }
572
+ };
503
573
  const sessionSweepTimer = setInterval(() => sessionManager.sweepDeadSessions(), SESSION_LIVENESS_SWEEP_MS);
504
574
  sessionSweepTimer.unref?.();
505
575
  // Portable continue, second channel: for a harness whose adapter never puts
@@ -518,7 +588,8 @@ export const startServer = async (options) => {
518
588
  const rehydratedFrom = session.rehydratedFrom;
519
589
  if (!session.ready || !rehydratedFrom)
520
590
  return;
521
- if (systemPromptDeliveryFor(adapters[session.harness]) !== "post-ready-injection")
591
+ if (systemPromptDeliveryFor(adapters[session.harness]) !==
592
+ "post-ready-injection")
522
593
  return;
523
594
  if (briefsDelivered.has(session.id))
524
595
  return;
@@ -571,7 +642,9 @@ export const startServer = async (options) => {
571
642
  readRecord: (harnessSessionId) => sessionRecordReader.read(harnessSessionId),
572
643
  getSession: (harnessSessionId) => {
573
644
  const session = sessionManager.get(harnessSessionId);
574
- return session ? { harness: session.harness, cwd: session.cwd } : undefined;
645
+ return session
646
+ ? { harness: session.harness, cwd: session.cwd }
647
+ : undefined;
575
648
  },
576
649
  runTask: (req) => taskManager.run(req),
577
650
  });
@@ -606,9 +679,15 @@ export const startServer = async (options) => {
606
679
  return;
607
680
  const before = workflowsCache;
608
681
  await workflowRegistry.prune();
609
- await workflowRegistry.scan(session.cwd);
682
+ const rescanBudget = new AgentProjectScanBudget();
683
+ const rescanFound = await workflowRegistry.scan(session.cwd, rescanBudget);
684
+ logAgentScan("workspace-change", session.cwd, rescanFound.length, rescanBudget);
610
685
  const after = await workflowRegistry.list();
611
686
  workflowsCache = after;
687
+ const inventoryChanged = !workflowListsEqual(before, after);
688
+ if (inventoryChanged) {
689
+ await refreshSystemGraphScopesForRoot(session.cwd);
690
+ }
612
691
  // A removed project must not leave a live session permanently bound to a
613
692
  // path that the registry can no longer resolve. Clear only stale bindings;
614
693
  // the triggering session may immediately auto-bind to another candidate
@@ -646,7 +725,7 @@ export const startServer = async (options) => {
646
725
  });
647
726
  }
648
727
  }
649
- if (workflowListsEqual(before, after))
728
+ if (!inventoryChanged)
650
729
  return;
651
730
  await Promise.all(sessionManager.list().map((s) => writeSessionContext(s)));
652
731
  bus.publish({ type: "workflows.changed" });
@@ -773,17 +852,92 @@ export const startServer = async (options) => {
773
852
  // merged registry) — callers use this to decide whether THIS scan turned
774
853
  // up something new worth an unprompted canvas render, without conflating
775
854
  // it with unrelated workflows some earlier scan already found elsewhere.
776
- const scanWorkflowsAndBroadcast = async (root) => {
777
- const found = await workflowRegistry.scan(root);
778
- workflowsCache = await workflowRegistry.list();
855
+ //
856
+ // Every call names WHY it is scanning. The registry is the thing the user
857
+ // asked "how am I finding my agents?" about, and until this line existed the
858
+ // only honest answer available after the fact was "something scanned
859
+ // something". One install ended up with 88 agents across twelve repositories
860
+ // nobody had opened, and reconstructing which root did it took a filesystem
861
+ // archaeology session. Now every scan says its root, its reason, what it
862
+ // found, what it cost, and what it declined to enter.
863
+ const scanWorkflowsAndBroadcast = async (root, reason, options = {}) => {
864
+ const before = workflowsCache;
865
+ const budget = new AgentProjectScanBudget();
866
+ const found = await workflowRegistry.scan(root, budget);
867
+ logAgentScan(reason, root, found.length, budget);
868
+ const after = await workflowRegistry.list();
869
+ workflowsCache = after;
870
+ const changed = !workflowListsEqual(before, after);
779
871
  // Rewrite every open session's context file before broadcasting — a
780
872
  // listener reacting to workflows.changed (the SPA, or an agent that
781
873
  // happens to re-read the file right then) must never see the
782
874
  // notification before the file it describes is actually updated.
783
- await Promise.all(sessionManager.list().map((session) => writeSessionContext(session)));
784
- bus.publish({ type: "workflows.changed" });
875
+ if (changed) {
876
+ await Promise.all(sessionManager.list().map((session) => writeSessionContext(session)));
877
+ }
878
+ if (options.refreshGraphs)
879
+ await options.refreshGraphs();
880
+ else if (changed)
881
+ refreshSystemGraphScopesForRoot(root);
882
+ if (changed)
883
+ bus.publish({ type: "workflows.changed" });
785
884
  return found;
786
885
  };
886
+ const refreshSystemGraphInventory = async (scope) => {
887
+ const canonicalScope = {
888
+ workspaceKey: scope.workspaceKey,
889
+ root: canonicalGraphPath(scope.root),
890
+ };
891
+ let refreshPromise = null;
892
+ const refreshGraph = () => {
893
+ refreshSystemGraphScopesForRoot(canonicalScope.root, canonicalScope.workspaceKey);
894
+ refreshPromise = systemGraphStore.refresh(canonicalScope);
895
+ return refreshPromise.then(() => undefined);
896
+ };
897
+ await scanWorkflowsAndBroadcast(canonicalScope.root, "graph-refresh", {
898
+ refreshGraphs: refreshGraph,
899
+ });
900
+ return await (refreshPromise ?? systemGraphStore.refresh(canonicalScope));
901
+ };
902
+ const workflowRootsForGraphScope = (scope) => graphSourceRootsWithinScope(scope.root, workflowsCache.map((workflow) => workflow.path));
903
+ const systemGraphWatcher = new SystemGraphWatcherManager({
904
+ listSourceRoots: workflowRootsForGraphScope,
905
+ onSourceChange: (scope, sourcePaths) => {
906
+ const canonicalScope = {
907
+ workspaceKey: scope.workspaceKey,
908
+ root: canonicalGraphPath(scope.root),
909
+ };
910
+ const dirtyRoots = dirtyGraphSourceRoots(canonicalScope.root, workflowsCache.map((workflow) => workflow.path), sourcePaths);
911
+ if (dirtyRoots.length === 0)
912
+ return;
913
+ for (const workflowRoot of dirtyRoots) {
914
+ systemGraphRelationships.invalidateSource(workflowRoot);
915
+ }
916
+ systemGraphStore.requestRefresh(canonicalScope);
917
+ },
918
+ onInventoryChange: async (scope) => {
919
+ try {
920
+ await refreshSystemGraphInventory(scope);
921
+ }
922
+ catch (err) {
923
+ systemGraphStore.reportRefreshFailure(scope);
924
+ console.error("[harness] workspace graph inventory refresh failed");
925
+ throw err;
926
+ }
927
+ },
928
+ });
929
+ const listWorkspaceScopesAndRetain = async () => {
930
+ const scopes = await workspaceScopeCatalog.list();
931
+ const retained = new Set(scopes.map((scope) => scope.workspaceKey));
932
+ systemGraphWatcher.retain(retained);
933
+ systemGraphStore.retain(retained);
934
+ for (const workspaceKey of activeSystemGraphScopes.keys()) {
935
+ if (!retained.has(workspaceKey)) {
936
+ activeSystemGraphScopes.delete(workspaceKey);
937
+ }
938
+ }
939
+ return scopes;
940
+ };
787
941
  /** Enrich only the bound workflow before a Canvas render. Canvas extraction
788
942
  * needs the registry snapshot to resolve the binding, but its cloud badge
789
943
  * needs the same mutable build projection exposed by /api/state. Limiting
@@ -838,7 +992,7 @@ export const startServer = async (options) => {
838
992
  const outcome = await renderCanvasForSession(session, await canvasWorkflowsForSession(session), { surfaceErrorOnMissingDeps: true });
839
993
  reactToRenderOutcome(session, outcome);
840
994
  };
841
- const initialWorkflowScan = scanWorkflowsAndBroadcast(launchDir).catch((err) => {
995
+ const initialWorkflowScan = scanWorkflowsAndBroadcast(launchDir, "boot").catch((err) => {
842
996
  console.error("[harness] initial agent scan failed:", err);
843
997
  return [];
844
998
  });
@@ -941,13 +1095,14 @@ export const startServer = async (options) => {
941
1095
  }
942
1096
  : null,
943
1097
  listWorkflows: () => workflowRegistry.list().then(enrichWorkflows),
1098
+ listWorkspaceScopes: listWorkspaceScopesAndRetain,
944
1099
  listMacros: () => DEFAULT_MACROS,
945
1100
  findWorkflow: (workflowPath) => workflowsCache.find((w) => w.path === workflowPath) ?? null,
946
1101
  writeWorkspaceContext: writeSessionContext,
947
1102
  renderCanvas,
948
1103
  onTelemetryOptInChange: (optIn) => batcher.setTelemetryOptIn(optIn),
949
1104
  onSessionCreated: (cwd, harnessSessionId) => {
950
- scanWorkflowsAndBroadcast(cwd)
1105
+ scanWorkflowsAndBroadcast(cwd, "session-create")
951
1106
  .then((found) => {
952
1107
  // Only auto-render when THIS session's own directory turned up a
953
1108
  // workflow — an unrelated project scanned earlier elsewhere in
@@ -982,6 +1137,23 @@ export const startServer = async (options) => {
982
1137
  },
983
1138
  settingsPath: statePaths.settings,
984
1139
  }));
1140
+ app.use("/api", createSystemGraphRouter({
1141
+ scopeResolver: workspaceScopeCatalog,
1142
+ store: systemGraphStore,
1143
+ onScopeAccess: (scope) => {
1144
+ activeSystemGraphScopes.set(scope.workspaceKey, scope);
1145
+ return systemGraphWatcher.start(scope);
1146
+ },
1147
+ onScopeRefresh: async (scope) => {
1148
+ try {
1149
+ return await refreshSystemGraphInventory(scope);
1150
+ }
1151
+ catch {
1152
+ console.error("[harness] workspace graph manual refresh failed");
1153
+ return systemGraphStore.reportRefreshFailure(scope);
1154
+ }
1155
+ },
1156
+ }));
985
1157
  app.use("/api", createCanvasRenderRouter({
986
1158
  getSession: (harnessSessionId) => sessionManager.get(harnessSessionId),
987
1159
  listWorkflows: canvasWorkflowsForSession,
@@ -1000,8 +1172,23 @@ export const startServer = async (options) => {
1000
1172
  // as WorkflowRegistryLike so this wrapper needs no unsafe cast.
1001
1173
  const enrichedWorkflowRegistry = {
1002
1174
  list: () => workflowRegistry.list().then(enrichWorkflows),
1003
- scan: (root) => workflowRegistry.scan(root),
1004
- connectPath: (inputPath) => workflowRegistry.connectPath(inputPath),
1175
+ scan: (root) => scanWorkflowsAndBroadcast(root, "requested"),
1176
+ connectPath: async (inputPath) => {
1177
+ const workflow = await workflowRegistry.connectPath(inputPath);
1178
+ workflowsCache = await workflowRegistry.list();
1179
+ await Promise.all(sessionManager.list().map((session) => writeSessionContext(session)));
1180
+ await refreshSystemGraphScopesForRoot(workflow.path);
1181
+ bus.publish({ type: "workflows.changed" });
1182
+ return workflow;
1183
+ },
1184
+ scanWithBoundaries: async (root) => {
1185
+ const budget = new AgentProjectScanBudget();
1186
+ const found = await workflowRegistry.scan(root, budget);
1187
+ logAgentScan("requested", root, found.length, budget);
1188
+ // The boundaries the walk stopped at travel with the result so the rail
1189
+ // can explain an empty project instead of misdescribing one.
1190
+ return { found, repositoryBoundaries: budget.repositoryBoundaries };
1191
+ },
1005
1192
  };
1006
1193
  app.use(createRunsRouter({
1007
1194
  // Pass the provider (not a static key) so the live-status path can refresh
@@ -1044,7 +1231,69 @@ export const startServer = async (options) => {
1044
1231
  // which the watcher's directory-set diff cannot see — rescan that project
1045
1232
  // so the Draft→Deployed chip and the deploy-gated actions update.
1046
1233
  onLinked: async (workflow) => {
1047
- await scanWorkflowsAndBroadcast(workflow.path);
1234
+ await scanWorkflowsAndBroadcast(workflow.path, "agent-linked");
1235
+ },
1236
+ }));
1237
+ // IA-01: the session-free, workflow-keyed canvas route. Same derivation the
1238
+ // session-bound render uses, keyed by the agent's absolute path instead of a
1239
+ // session id — so a board can be read for an agent that has never hosted a
1240
+ // session. Resolution goes through the same live cache actions.ts uses, so
1241
+ // only registered agents are ever read from disk.
1242
+ app.use(createWorkflowGraphRouter({
1243
+ resolveWorkflow: (agentPath) => workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null,
1244
+ }));
1245
+ // SAP-2929: the Group axis's stored arrangement, one `.sapiom/studio-rail.json`
1246
+ // per project root, plus the launch edges it seeds from. Writable roots are
1247
+ // exactly the roots the rail can SHOW — recentDirs, the configured project
1248
+ // root, and live session cwds — so the route cannot be aimed anywhere the
1249
+ // studio has not already been pointed.
1250
+ app.use(createStudioRailRouter({
1251
+ listKnownRoots: async () => {
1252
+ const stored = await loadSettings(statePaths.settings);
1253
+ return [
1254
+ ...stored.recentDirs,
1255
+ ...(stored.projectRoot ? [stored.projectRoot] : []),
1256
+ ...sessionManager.list().map((session) => session.cwd),
1257
+ ];
1258
+ },
1259
+ listWorkflows: () => workflowsCache,
1260
+ }));
1261
+ // SAP-2930: the Project axis's drag, performed on disk. Its own guards live
1262
+ // in the module — a planner is not a permission system, so the route stats
1263
+ // the destination itself. Only a REGISTERED agent may be moved, resolved
1264
+ // through the same live cache the canvas and action routes use, and it may
1265
+ // only be moved INTO a directory the rail can show: the roots studio-rail
1266
+ // already treats as writable, plus the branching directories the Project
1267
+ // axis draws between a root and an agent. Both sides of the move are
1268
+ // therefore server-authored paths, not strings off the request.
1269
+ app.use(createAgentMoveRouter({
1270
+ resolveAgent: (agentPath) => workflowsCache.find((w) => resolve(w.path) === agentPath) ?? null,
1271
+ listMoveTargetDirs: async () => {
1272
+ const stored = await loadSettings(statePaths.settings);
1273
+ return moveTargetDirs([
1274
+ ...stored.recentDirs,
1275
+ ...(stored.projectRoot ? [stored.projectRoot] : []),
1276
+ ...sessionManager.list().map((session) => session.cwd),
1277
+ ], workflowsCache.map((w) => w.path));
1278
+ },
1279
+ // Everything under the moved directory travelled with it, so every live
1280
+ // session whose cwd sat inside follows — a session left pointing at a
1281
+ // directory that no longer exists is the whole reason this remap exists.
1282
+ // Then prune the path that is gone and rescan the destination, which
1283
+ // broadcasts `workflows.changed`: the rail re-derives the tree from the
1284
+ // NEW path rather than from a stale registry row.
1285
+ onMoved: async (from, to) => {
1286
+ remapSessions(sessionManager.list(), from, to);
1287
+ await workflowRegistry.prune();
1288
+ await scanWorkflowsAndBroadcast(dirname(to), "agent-moved", {
1289
+ refreshGraphs: async () => {
1290
+ // A cross-project move changes two containment inventories. Refresh
1291
+ // every active parent/nested graph touching either side so neither
1292
+ // keeps a ghost node or misses the arrival.
1293
+ refreshSystemGraphScopesForRoot(dirname(from));
1294
+ refreshSystemGraphScopesForRoot(dirname(to));
1295
+ },
1296
+ });
1048
1297
  },
1049
1298
  }));
1050
1299
  app.use(createWorkflowsRouter(enrichedWorkflowRegistry), createFsRouter(), createMacrosRouter({
@@ -1320,6 +1569,10 @@ export const startServer = async (options) => {
1320
1569
  clearInterval(ndjsonRetentionTimer);
1321
1570
  canvasWatcher.stopAll();
1322
1571
  workspaceWatcher.stopAll();
1572
+ systemGraphWatcher.stopAll();
1573
+ activeSystemGraphScopes.clear();
1574
+ systemGraphRelationships.clear();
1575
+ systemGraphStore.clear();
1323
1576
  installWatcher.stopAll();
1324
1577
  for (const tailer of codexTailers.values())
1325
1578
  tailer.stop();