Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,19 @@ the labels and controls it still uses. `agent-map-authority.spec.ts` includes om
and exact keyboard tabs; `project-altitude.spec.ts` preserves pane geometry,
Steps restoration, independent disclosure and map/agent Back/Forward navigation.

SAP-3091 extracts the retained owners before deleting server composition:

| Retained module | Responsibility |
| --- | --- |
| `shared/workspace-scope.ts` | Scope keys, the public `WorkspaceScopeSummary` shape, and browser-safe workspace-relative identities. |
| `core/workspace-scope-catalog.ts` | Stable canonical-root keys and allowlisted root resolution. |
| `core/workspace-path.ts` | Source containment and canonical source-root selection for shared discovery/watchers. |
| `core/canvas-interconnections.ts` | Per-agent invocation modes and source scanning for Canvas. |

The public PackageInventory contract remains in `@sapiom/agent`. Canonical path
caching, accepted discovery evidence, shared watch leases and individual-agent
Canvas extraction keep their existing owners and regression coverage.

## Evidence required before browser deletion

Attach results to SAP-3089 at the reviewed PR head. Do not treat the presence of
Expand Down
5 changes: 2 additions & 3 deletions packages/harness/src/core/canvas-interconnections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import ts from "typescript";

import type { AgentInvocationMode } from "../shared/system-graph.js";

export type { AgentInvocationMode } from "../shared/system-graph.js";
/** Invocation modes displayed by an individual agent's Canvas. */
export type AgentInvocationMode = "blocking" | "async";

const SKIP_DIR_NAMES = new Set([
"node_modules",
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/core/studio-project-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
type StudioProjectId,
type StudioProjectSummary,
} from "../shared/agent-map.js";
import type { WorkspaceScopeSummary } from "../shared/system-graph.js";
import type { WorkspaceScopeSummary } from "../shared/workspace-scope.js";
import { resolveProjectRootForPath } from "../shared/project-roots.js";
import { pathComparisonKey } from "../shared/paths.js";
import { canonicalGraphPath } from "./canonical-graph-path.js";
Expand Down
2 changes: 1 addition & 1 deletion packages/harness/src/core/studio-workspace-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type StudioWorkspacePreference,
type StudioWorkspaceSelection,
} from "../shared/agent-map.js";
import { workspaceRelativeLocalKey } from "../shared/system-graph.js";
import { workspaceRelativeLocalKey } from "../shared/workspace-scope.js";
import { isStudioProjectId } from "./studio-project-catalog.js";

interface PrivateAgentBinding extends StudioWorkspaceAgentSummary {
Expand Down
18 changes: 0 additions & 18 deletions packages/harness/src/core/system-graph-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ import { describe, expect, it, vi } from "vitest";
import type { RegistryWorkflowInfo as WorkflowInfo } from "./workflow-registry.js";
import {
dirtyGraphSourceRoots,
graphSourceRootsWithinScope,
HarnessRegistryInventoryProvider,
inventorySourceRoot,
type AgentInventoryResult,
type HarnessRegistryInventoryProviderOptions,
type WorkspaceScope,
} from "./system-graph-inventory.js";
import { workspaceRelativeLocalKey } from "../shared/system-graph.js";
import type {
ManifestNameInspection,
ManifestNameInspectionOptions,
Expand Down Expand Up @@ -281,15 +279,6 @@ describe("HarnessRegistryInventoryProvider", () => {
).toBe("\\\\server\\share\\workspace\\nested\\agent");
});

it("uses a checkout-invariant shared local key for a scope-root agent", () => {
expect(workspaceRelativeLocalKey("/checkouts/one", "/checkouts/one")).toBe(
"local:root",
);
expect(
workspaceRelativeLocalKey("/different/name", "/different/name"),
).toBe("local:root");
});

it("returns linked agents provisionally before source inspection starts", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
Expand Down Expand Up @@ -1388,13 +1377,6 @@ describe("HarnessRegistryInventoryProvider", () => {
]);
await fs.symlink(workspaceRoot, linkedRoot, "dir");

expect(
graphSourceRootsWithinScope(linkedRoot, [
agentRoot,
nestedRoot,
outsideRoot,
]),
).toEqual([await fs.realpath(agentRoot), await fs.realpath(nestedRoot)]);
expect(
dirtyGraphSourceRoots(
linkedRoot,
Expand Down
40 changes: 5 additions & 35 deletions packages/harness/src/core/system-graph-inventory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type { WorkspaceScope } from "./workspace-scope-catalog.js";
import type { WorkspaceKey } from "../shared/workspace-scope.js";
export type { WorkspaceScope } from "./workspace-scope-catalog.js";
import { isWithinWorkspacePath as isWithinGraphPath, sourceRootsWithinScope as graphSourceRootsWithinScope } from "./workspace-path.js";
export { isWithinWorkspacePath as isWithinGraphPath, sourceRootsWithinScope as graphSourceRootsWithinScope } from "./workspace-path.js";
import { createHash } from "node:crypto";
import * as path from "node:path";

Expand All @@ -11,7 +16,6 @@ import {
import {
type AgentKey,
type GraphWarning,
type WorkspaceKey,
} from "../shared/system-graph.js";
import type { RegistryWorkflowInfo as WorkflowInfo } from "./workflow-registry.js";
import { fingerprintWorkflowSources } from "./canvas-cache.js";
Expand All @@ -24,11 +28,6 @@ import { inspectAgentProjectMarker } from "./agent-project-discovery.js";

export { canonicalGraphPath } from "./canonical-graph-path.js";

export interface WorkspaceScope {
workspaceKey: WorkspaceKey;
root: string;
}

/** Harness-only evidence paired with one public inventory record. */
export interface AgentInventoryContextItem {
agentKey: AgentKey;
Expand Down Expand Up @@ -189,35 +188,6 @@ export function inventorySourceRoot(
return canonicalGraphPath(joined);
}

export function isWithinGraphPath(root: string, candidate: string): boolean {
if (isWindowsAbsolute(root) !== isWindowsAbsolute(candidate)) return false;
const api = pathApi(root);
const relative = api.relative(root, candidate);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${api.sep}`) &&
!api.isAbsolute(relative))
);
}

/** Canonical registered roots contained by a workspace, safe for symlinked scopes. */
export function graphSourceRootsWithinScope(
scopeRoot: string,
sourceRoots: readonly string[],
): string[] {
const canonicalScopeRoot = canonicalGraphPath(scopeRoot);
return [
...new Set(
sourceRoots
.map(canonicalGraphPath)
.filter((sourceRoot) =>
isWithinGraphPath(canonicalScopeRoot, sourceRoot),
),
),
].sort();
}

/**
* Attribute exact source edits to the deepest registered project roots.
* A null path list is the polling/ambiguous-event fallback.
Expand Down
40 changes: 0 additions & 40 deletions packages/harness/src/core/system-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { WorkflowInfo } from "../shared/types.js";
import {
HarnessRegistryInventoryProvider,
CachedAgentInvocationProvider,
LocalWorkspaceScopeCatalog,
StaticSystemGraphBuilder,
type AgentInventoryProvider,
type AgentInventoryResult,
Expand Down Expand Up @@ -166,45 +165,6 @@ function inventoryResult(
};
}

describe("LocalWorkspaceScopeCatalog", () => {
it("gives a canonical root a stable opaque key and rejects unknown keys", async () => {
const catalog = new LocalWorkspaceScopeCatalog(() => [
FIXTURE,
path.join(FIXTURE, "."),
]);
const scopes = await catalog.list();

expect(scopes).toHaveLength(1);
expect(scopes[0]!.workspaceKey).toMatch(/^workspace-[a-f0-9]{16}$/);
expect(scopes[0]!.workspaceKey).not.toContain(FIXTURE);
expect((await catalog.resolve(scopes[0]!.workspaceKey))?.root).toBe(
FIXTURE,
);
await expect(catalog.resolve("workspace-not-known")).resolves.toBeNull();
});

it("does not collide when two roots share a basename", async () => {
const catalog = new LocalWorkspaceScopeCatalog(() => [
"/tmp/one/project",
"/tmp/two/project",
]);
expect(
new Set((await catalog.list()).map((scope) => scope.workspaceKey)).size,
).toBe(2);
});

it("can resolve persisted workspace roots without a live session", async () => {
const listRoots = vi.fn(async () => [FIXTURE]);
const catalog = new LocalWorkspaceScopeCatalog(listRoots);
const [scope] = await catalog.list();

await expect(catalog.resolve(scope!.workspaceKey)).resolves.toEqual({
workspaceKey: scope!.workspaceKey,
root: FIXTURE,
});
});
});

describe("StaticSystemGraphBuilder", () => {
const scope: WorkspaceScope = {
workspaceKey: "workspace-fixture",
Expand Down
53 changes: 2 additions & 51 deletions packages/harness/src/core/system-graph.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";

export { LocalWorkspaceScopeCatalog } from "./workspace-scope-catalog.js";
export type { WorkspaceScopeResolver, WorkspaceScopeCatalog } from "./workspace-scope-catalog.js";
import { packageInventorySchema } from "@sapiom/agent";

import type {
Expand All @@ -8,7 +8,6 @@ import type {
SystemGraph,
SystemGraphNavigationTarget,
WorkspaceKey,
WorkspaceScopeSummary,
} from "../shared/system-graph.js";
import {
canonicalGraphPath,
Expand Down Expand Up @@ -45,14 +44,6 @@ export type {
AgentInvocationWarning,
} from "./system-graph-relationships.js";

export interface WorkspaceScopeResolver {
resolve(workspaceKey: WorkspaceKey): Promise<WorkspaceScope | null>;
}

export interface WorkspaceScopeCatalog extends WorkspaceScopeResolver {
list(): Promise<WorkspaceScopeSummary[]>;
}

export interface SystemGraphBuilder {
build(scope: WorkspaceScope): Promise<SystemGraphBuildResult>;
/** Optional lifecycle hook for builders with workspace-scoped caches. */
Expand All @@ -69,46 +60,6 @@ export interface SystemGraphBuildResult {
afterCommit?: () => void;
}

function workspaceKeyForRoot(root: string): WorkspaceKey {
return `workspace-${createHash("sha256").update(root).digest("hex").slice(0, 16)}`;
}

/**
* Resolves only roots the running Studio already knows about. A caller cannot
* manufacture a key and turn the graph endpoint into an arbitrary path scan.
*/
export class LocalWorkspaceScopeCatalog implements WorkspaceScopeCatalog {
constructor(
private readonly listRoots: () =>
| readonly string[]
| Promise<readonly string[]>,
) {}

async list(): Promise<WorkspaceScopeSummary[]> {
const byRoot = new Map<string, WorkspaceScopeSummary>();
for (const root of await this.listRoots()) {
const canonical = canonicalGraphPath(root);
byRoot.set(canonical, {
workspaceKey: workspaceKeyForRoot(canonical),
cwd: root,
});
}
return [...byRoot.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([, summary]) => summary);
}

async resolve(workspaceKey: WorkspaceKey): Promise<WorkspaceScope | null> {
for (const root of await this.listRoots()) {
const canonical = canonicalGraphPath(root);
if (workspaceKeyForRoot(canonical) === workspaceKey) {
return { workspaceKey, root: canonical };
}
}
return null;
}
}

function warningOrder(left: GraphWarning, right: GraphWarning): number {
return (
left.code.localeCompare(right.code) ||
Expand Down
49 changes: 49 additions & 0 deletions packages/harness/src/core/workspace-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { describe, expect, it } from "vitest";
import { isWithinWorkspacePath, sourceRootsWithinScope } from "./workspace-path.js";

describe("workspace source containment", () => {
it.each([
["/repo", "/repo", true],
["/repo", "/repo/agent", true],
["/repo", "/repo-other/agent", false],
["/repo", "/repo/../outside", false],
["C:/Repo", "c:/repo/Agent", true],
["C:/Repo", "D:/Repo/Agent", false],
["//server/share/repo", "//server/other/repo/Agent", false],
] as const)("contains %s → %s: %s", (root, candidate, expected) => {
expect(isWithinWorkspacePath(root, candidate)).toBe(expected);
});

it("matches canonical workflow roots beneath a symlinked workspace", async () => {
if (process.platform === "win32") return;
const tempRoot = await fs.mkdtemp(
path.join(os.tmpdir(), "workspace-symlink-"),
);
try {
const workspaceRoot = path.join(tempRoot, "real-workspace");
const agentRoot = path.join(workspaceRoot, "agent");
const nestedRoot = path.join(agentRoot, "nested-agent");
const outsideRoot = path.join(tempRoot, "outside", "agent");
const linkedRoot = path.join(tempRoot, "linked-workspace");
await Promise.all([
fs.mkdir(nestedRoot, { recursive: true }),
fs.mkdir(outsideRoot, { recursive: true }),
]);
await fs.symlink(workspaceRoot, linkedRoot, "dir");

expect(
sourceRootsWithinScope(linkedRoot, [
agentRoot,
nestedRoot,
outsideRoot,
]),
).toEqual([await fs.realpath(agentRoot), await fs.realpath(nestedRoot)]);
} finally {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});

});
42 changes: 42 additions & 0 deletions packages/harness/src/core/workspace-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as path from "node:path";
import { canonicalGraphPath } from "./canonical-graph-path.js";

function isWindowsAbsolute(input: string): boolean {
return (
/^[A-Za-z]:[\\/]/.test(input) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(input)
);
}

function pathApi(input: string): typeof path.posix {
return isWindowsAbsolute(input) ? path.win32 : path.posix;
}

export function isWithinWorkspacePath(root: string, candidate: string): boolean {
if (isWindowsAbsolute(root) !== isWindowsAbsolute(candidate)) return false;
const api = pathApi(root);
const relative = api.relative(root, candidate);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..${api.sep}`) &&
!api.isAbsolute(relative))
);
}

/** Canonical registered roots contained by a workspace, safe for symlinked scopes. */
export function sourceRootsWithinScope(
scopeRoot: string,
sourceRoots: readonly string[],
): string[] {
const canonicalScopeRoot = canonicalGraphPath(scopeRoot);
return [
...new Set(
sourceRoots
.map(canonicalGraphPath)
.filter((sourceRoot) =>
isWithinWorkspacePath(canonicalScopeRoot, sourceRoot),
),
),
].sort();
}

Loading
Loading