Compare commits

...

3 Commits

Author SHA1 Message Date
fullstack-engineer 0ae8887f2a feat(files): Phase 1 /agent-home stub for Files API (RFC internal#425)
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
publish-runtime-autobump / pr-validate (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m50s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m14s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 38s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 53s
CI / Platform (Go) (pull_request) Failing after 26m36s
CI / Detect changes (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
Phase 1 stub so canvas can design against the shape without a full
implementation in place.

- Add /agent-home to allowedRoots (templates.go:21)
- Add isAgentHomeStubRequest() helper function
- Short-circuit all four verbs (ListFiles/ReadFile/WriteFile/DeleteFile)
  to 501 Not Implemented when root=/agent-home

RFC internal#425 must NOT be closed on this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:13:20 +00:00
fullstack-engineer d3d5a71d09 test(canvas): add WORKSPACE_PROVISIONING parentId+coord coverage (3 cases)
CI / all-required (pull_request) Blocked by required conditions
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
audit-force-merge / audit (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 27s
CI / Detect changes (pull_request) Successful in 1m10s
Harness Replays / detect-changes (pull_request) Successful in 47s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m37s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 53s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m33s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 2m16s
gate-check-v3 / gate-check (pull_request) Successful in 46s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 49s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m21s
sop-tier-check / tier-check (pull_request) Successful in 39s
sop-checklist / all-items-acked (pull_request) Successful in 47s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m41s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m17s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m39s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m16s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m31s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 31s
Harness Replays / Harness Replays (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 25s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 18m35s
CI / Platform (Go) (pull_request) Failing after 19m22s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m28s
CI / Python Lint & Test (pull_request) Successful in 7m47s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
Adds three tests that cover the conditional branching in the
WORKSPACE_PROVISIONING handler:
1. finalX/finalY + parent in store → trusts server coords verbatim,
   sets parentId so the node renders nested inside the parent card.
2. parent_id present but parent NOT in store (WS-reorder race) →
   falls back to grid slot; does not crash; parentId not set.
3. parent in store but no x/y in payload → grid slot; parentId
   stays null (not undefined) since the server has no position.

None of these were previously exercised in the test suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:19:23 +00:00
fullstack-engineer 9529fc9eb7 test(canvas): add growParentsToFitChildren unit tests (11 cases)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 22s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
gate-check-v3 / gate-check (pull_request) Successful in 27s
qa-review / approved (pull_request) Successful in 29s
security-review / approved (pull_request) Successful in 24s
Harness Replays / Harness Replays (pull_request) Successful in 6s
sop-checklist / all-items-acked (pull_request) Successful in 18s
sop-tier-check / tier-check (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 57s
E2E API Smoke Test / detect-changes (pull_request) Successful in 55s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 51s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
CI / Platform (Go) (pull_request) Failing after 13m14s
CI / Canvas (Next.js) (pull_request) Successful in 13m33s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
Covers canvas.ts growParentsToFitChildren store action:
- Standalone root with no parentId → skipped, same reference
- Orphan parentId (no children reference it) → skipped, same reference
- Collapsed parent with overflowing children → skipped entirely
- Child fits exactly within parent → no-op, same reference
- Child overflows width only → grows width, height unchanged
- Child overflows height only → grows height, width unchanged
- Child overflows both dimensions → grows both
- Child with no measured/width/height → uses CHILD_DEFAULT 240×130
- Child with explicit width/height (no measured) → uses explicit dims
- Child with both measured and explicit → measured takes precedence
- Multiple children → grows to fit furthest extent in each dimension

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 21:25:19 +00:00
3 changed files with 499 additions and 4 deletions
@@ -332,6 +332,105 @@ describe("handleCanvasEvent WORKSPACE_PROVISIONING", () => {
const bPos = lastNodes.find((n) => n.id === "ws-b")!.position;
expect(bPos).toEqual({ x: 420, y: 100 }); // idx 1 = (100 + 320, 100)
});
it("uses finalX/finalY from payload when parentId is set and parent exists in store", () => {
// Org-import child lands with explicit coords — these are server-computed
// parent-relative positions. The handler must trust them verbatim.
const parent = makeNode("parent-root", { name: "Root" });
const { get, set } = makeStore([parent]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "child-org",
payload: {
name: "Org Child",
tier: 2,
parent_id: "parent-root",
x: 500,
y: 300,
},
}),
get,
set
);
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
expect(newNodes).toHaveLength(2);
const child = newNodes.find((n) => n.id === "child-org")!;
// Must use the server-provided coords, not grid
expect(child.position).toEqual({ x: 500, y: 300 });
// Must bind parentId so RF renders it nested inside the parent card
expect(child.parentId).toBe("parent-root");
expect(child.data.parentId).toBe("parent-root");
expect(child.data.name).toBe("Org Child");
expect(child.data.status).toBe("provisioning");
});
it("uses grid position when parentId is set but parent is NOT in store yet", () => {
// Rare WS-reorder: child event arrives before parent's PROVISIONING event.
// Must not crash — uses grid slot as fallback. Parent will reparent
// the child when it lands.
const { get, set } = makeStore([]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "orphan-child",
payload: {
name: "Orphan",
parent_id: "unknown-parent",
x: 999,
y: 888,
},
}),
get,
set
);
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
const child = newNodes.find((n) => n.id === "orphan-child")!;
// Must NOT use finalX/finalY — parent isn't in store so grid slot is used
expect(child.position).not.toEqual({ x: 999, y: 888 });
// Grid slot for idx 0: (100, 100)
expect(child.position).toEqual({ x: 100, y: 100 });
// parentId is NOT set on the node when parent is unknown:
// the node will be reparented when the parent eventually lands
expect(child.data.parentId).not.toBe("unknown-parent");
});
it("no-op cascade: parent in store but no finalX/Y → grid position, no parentId", () => {
// Parent exists but payload has no x/y → must not crash, uses grid slot.
// parentId is NOT set because we don't have parent-relative coords.
const parent = makeNode("parent-exists");
const { get, set } = makeStore([parent]);
handleCanvasEvent(
makeMsg({
event: "WORKSPACE_PROVISIONING",
workspace_id: "child-no-coords",
payload: {
name: "No Coords",
parent_id: "parent-exists",
// no x or y
},
}),
get,
set
);
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
const child = newNodes.find((n) => n.id === "child-no-coords")!;
// Grid slot for idx 0: (100, 100)
expect(child.position).toEqual({ x: 100, y: 100 });
// parentId stays null (not undefined) when no finalX/Y — server has no
// position for this node, and the handler initialises parentId=null
expect(child.parentId).toBeUndefined();
expect(child.data.parentId).toBeNull();
});
});
// ---------------------------------------------------------------------------
+368
View File
@@ -848,6 +848,374 @@ describe("hydrationError", () => {
});
});
// ---------- growParentsToFitChildren ----------
//
// growParentsToFitChildren walks every parent node and expands its width/height
// so all children fit inside with padding. Collapsed parents are skipped (grow-
// only, never shrink). Returns the same array reference when no changes are
// needed, a new array when at least one parent grew.
//
// Constants (from canvas-topology.ts):
// CHILD_DEFAULT_WIDTH = 240
// CHILD_DEFAULT_HEIGHT = 130
// PARENT_SIDE_PADDING = 16
// PARENT_BOTTOM_PADDING = 16
//
// For a child at (childX, childY) with size (childW, childH):
// requiredParentW = childX + childW + PARENT_SIDE_PADDING
// requiredParentH = childY + childH + PARENT_BOTTOM_PADDING
//
// Coverage targets:
// - Node with no parentId → skipped entirely (returns same node)
// - Parent with no children → skipped (kids.length === 0 → returns n)
// - Collapsed parent → skipped even when children overflow
// - Child fits within existing parent → no-op (requiredW <= currentW && requiredH <= currentH)
// - Child overflows parent width → grows width only
// - Child overflows parent height → grows height only
// - Child overflows both → grows both
// - Missing measured.width (falls back to width, then CHILD_DEFAULT_WIDTH)
// - Missing measured.height (falls back to height, then CHILD_DEFAULT_HEIGHT)
// - Missing parent measured.width (falls back to width, then 0)
// - Missing parent measured.height (falls back to height, then 0)
// - No change at all → returns same array reference (changed=false path)
describe("growParentsToFitChildren", () => {
it("skips nodes with no parentId (standalone roots)", () => {
useCanvasStore.setState({
nodes: [
{
id: "root",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Root", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 200, height: 150 },
},
],
});
const before = useCanvasStore.getState().nodes;
useCanvasStore.getState().growParentsToFitChildren();
const after = useCanvasStore.getState().nodes;
// Same array reference (no change needed)
expect(after).toBe(before);
});
it("skips parent with no children (orphan parentId)", () => {
useCanvasStore.setState({
nodes: [
{
id: "orphan",
type: "workspaceNode",
position: { x: 0, y: 0 },
parentId: "nonexistent",
data: { name: "Orphan", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 100, height: 100 },
},
],
});
const before = useCanvasStore.getState().nodes;
useCanvasStore.getState().growParentsToFitChildren();
const after = useCanvasStore.getState().nodes;
// Same array reference (parentId exists but no children reference it)
expect(after).toBe(before);
expect(after[0].measured).toEqual({ width: 100, height: 100 });
});
it("skips collapsed parents even when children overflow", () => {
// Child at (500, 400) → requires parent 500+240+16=756w, 400+130+16=546h
// Parent is collapsed AND tiny — must NOT grow
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: true, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 200, height: 150 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 500, y: 400 },
parentId: "parent",
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
],
});
const before = useCanvasStore.getState().nodes;
useCanvasStore.getState().growParentsToFitChildren();
const after = useCanvasStore.getState().nodes;
// Same reference (collapsed → skipped entirely)
expect(after).toBe(before);
const parent = after.find((n) => n.id === "parent")!;
expect(parent.measured).toEqual({ width: 200, height: 150 });
});
it("no-op when child fits within existing parent size", () => {
// Child at (0,0) 240x130 → requires 0+240+16=256w, 0+130+16=146h
// Parent is exactly 256×146 → fits perfectly
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 256, height: 146 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 0, y: 0 },
parentId: "parent",
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
],
});
const before = useCanvasStore.getState().nodes;
useCanvasStore.getState().growParentsToFitChildren();
const after = useCanvasStore.getState().nodes;
// Same array reference (no change needed)
expect(after).toBe(before);
const parent = after.find((n) => n.id === "parent")!;
expect(parent.measured).toEqual({ width: 256, height: 146 });
});
it("grows parent width only when child overflows width but not height", () => {
// Child at (100, 0) 240x130 → requires 100+240+16=356w, 0+130+16=146h
// Parent is 256×146 → fits height, overflows width → grows to 356×146
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 256, height: 146 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 100, y: 0 },
parentId: "parent",
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
],
});
useCanvasStore.getState().growParentsToFitChildren();
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
expect(parent.width).toBe(356); // 100+240+16
expect(parent.height).toBe(146); // unchanged
});
it("grows parent height only when child overflows height but not width", () => {
// Child at (0, 50) 240x130 → requires 0+240+16=256w, 50+130+16=196h
// Parent is 256×146 → fits width, overflows height → grows to 256×196
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 256, height: 146 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 0, y: 50 },
parentId: "parent",
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
],
});
useCanvasStore.getState().growParentsToFitChildren();
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
expect(parent.width).toBe(256); // unchanged
expect(parent.height).toBe(196); // 50+130+16
});
it("grows parent in both dimensions when child overflows both", () => {
// Child at (200, 100) 240x130 → requires 200+240+16=456w, 100+130+16=246h
// Parent is 256×146 → grows to 456×246
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 256, height: 146 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 200, y: 100 },
parentId: "parent",
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
],
});
useCanvasStore.getState().growParentsToFitChildren();
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
expect(parent.width).toBe(456); // 200+240+16
expect(parent.height).toBe(246); // 100+130+16
});
it("uses CHILD_DEFAULT_WIDTH/HEIGHT when child has no measured or explicit dimensions", () => {
// Child with NO measured, NO width/height → falls back to 240×130 defaults
// Child at (500, 200) → requires 500+240+16=756w, 200+130+16=346h
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 100, height: 100 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 500, y: 200 },
parentId: "parent",
// No measured, no width/height → uses CHILD_DEFAULT_WIDTH=240, CHILD_DEFAULT_HEIGHT=130
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
},
],
});
useCanvasStore.getState().growParentsToFitChildren();
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
expect(parent.width).toBe(756); // 500+240+16
expect(parent.height).toBe(346); // 200+130+16
});
it("uses explicit width/height when measured is absent on child", () => {
// Child has width/height but NOT measured
// Child at (300, 50) with explicit 200×100 → requires 300+200+16=516w, 50+100+16=166h
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 200, height: 100 },
},
{
id: "child",
type: "workspaceNode",
position: { x: 300, y: 50 },
parentId: "parent",
width: 200,
height: 100,
// No measured → falls back to width=200, height=100
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
},
],
});
useCanvasStore.getState().growParentsToFitChildren();
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
expect(parent.width).toBe(516); // 300+200+16
expect(parent.height).toBe(166); // 50+100+16
});
it("uses measured when present (takes precedence over explicit width/height)", () => {
// Child has both measured AND explicit width/height — measured should win
// Child at (0,0) measured=240×130 explicit=100×50 → uses measured
// Required: 0+240+16=256w, 0+130+16=146h
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 256, height: 146 }, // fits exactly
},
{
id: "child",
type: "workspaceNode",
position: { x: 0, y: 0 },
parentId: "parent",
width: 100, // ignored (measured present)
height: 50, // ignored
measured: { width: 240, height: 130 },
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
},
],
});
const before = useCanvasStore.getState().nodes;
useCanvasStore.getState().growParentsToFitChildren();
const after = useCanvasStore.getState().nodes;
// Same reference (measured fits exactly)
expect(after).toBe(before);
});
it("multiple children: grows to fit the furthest child in each dimension", () => {
// Child 1 at (0, 0) 240×130 → maxRight=240, maxBottom=130
// Child 2 at (300, 200) 240×130 → maxRight=540, maxBottom=330
// Required: 540+16=556w, 330+16=346h
useCanvasStore.setState({
nodes: [
{
id: "parent",
type: "workspaceNode",
position: { x: 0, y: 0 },
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 100, height: 100 },
},
{
id: "child1",
type: "workspaceNode",
position: { x: 0, y: 0 },
parentId: "parent",
data: { name: "Child1", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
{
id: "child2",
type: "workspaceNode",
position: { x: 300, y: 200 },
parentId: "parent",
data: { name: "Child2", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
measured: { width: 240, height: 130 },
},
],
});
useCanvasStore.getState().growParentsToFitChildren();
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
expect(parent.width).toBe(556); // max(0+240, 300+240)+16
expect(parent.height).toBe(346); // max(0+130, 200+130)+16
});
});
// ---------- ACTIVITY_LOGGED event ----------
describe("ACTIVITY_LOGGED event", () => {
@@ -19,15 +19,23 @@ import (
// allowedRoots are the container paths that the Files API can browse.
var allowedRoots = map[string]bool{
"/configs": true,
"/workspace": true,
"/home": true,
"/plugins": true,
"/configs": true,
"/workspace": true,
"/home": true,
"/plugins": true,
"/agent-home": true, // Phase 1 stub (RFC internal#425); full implementation to follow
}
// maxUploadFiles limits the number of files in a single import/replace.
const maxUploadFiles = 200
// isAgentHomeStubRequest returns true when the rootPath is /agent-home,
// which is a Phase 1 stub (RFC internal#425). Canvas designs against the
// shape; the full implementation will follow in a later phase.
func isAgentHomeStubRequest(rootPath string) bool {
return rootPath == "/agent-home"
}
type TemplatesHandler struct {
configsDir string
docker *client.Client
@@ -218,6 +226,11 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
// ?path= — subdirectory to list (relative to root, default: "")
// ?depth= — max depth to recurse (default: 1, max: 5)
rootPath := c.DefaultQuery("root", "/configs")
// Phase 1 stub — RFC internal#425
if isAgentHomeStubRequest(rootPath) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
return
}
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
@@ -382,6 +395,11 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
// Phase 1 stub — RFC internal#425
if isAgentHomeStubRequest(rootPath) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
return
}
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
@@ -495,6 +513,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
// Phase 1 stub — RFC internal#425
if isAgentHomeStubRequest(rootPath) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
return
}
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return
@@ -572,6 +595,11 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
ctx := c.Request.Context()
rootPath := c.DefaultQuery("root", "/configs")
// Phase 1 stub — RFC internal#425
if isAgentHomeStubRequest(rootPath) {
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
return
}
if !allowedRoots[rootPath] {
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
return