Compare commits
19 Commits
fe59d8d097
...
013c8cfe58
| Author | SHA1 | Date | |
|---|---|---|---|
| 013c8cfe58 | |||
| d20147300b | |||
| 4d32736e25 | |||
| 691d341fbb | |||
| ef42e17224 | |||
| b13c9f94f1 | |||
| 600f88b172 | |||
| df94fd1764 | |||
| 8346b06291 | |||
| b7da21063e | |||
| 2f7b5ad871 | |||
| 213ea06840 | |||
| f07dfa7af6 | |||
| 93f5a4aac3 | |||
| e5d6e45ab1 | |||
| a1cf56cdab | |||
| 436fae8949 | |||
| 2d1a853bf9 | |||
| 5551ef40e3 |
@@ -128,6 +128,7 @@ fi
|
||||
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
|
||||
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
|
||||
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
|
||||
PR_BASE_SHA=$(jq -r '.base.sha // ""' "$PR_JSON")
|
||||
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
|
||||
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
|
||||
@@ -136,6 +137,10 @@ if [ "$PR_STATE" != "open" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_HEAD_SHA" = "$PR_BASE_SHA" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} has no diff (head == base) — exiting 0 (empty PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable"
|
||||
exit 0
|
||||
|
||||
@@ -101,7 +101,8 @@ jobs:
|
||||
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
|
||||
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
|
||||
runs-on: [self-hosted, macos-self-hosted]
|
||||
# ADVISORY: never blocks. See safety contract point 3.
|
||||
# ADVISORY: never blocks. See safety contract point 3. mc#774
|
||||
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
|
||||
continue-on-error: true
|
||||
# event_name gate: functional (only meaningful on push/PR) AND keeps
|
||||
# this job out of ci-required-drift.py:ci_job_names() so F1 can never
|
||||
|
||||
@@ -25,7 +25,7 @@ permissions:
|
||||
jobs:
|
||||
shellcheck-arm64:
|
||||
name: shellcheck-arm64 (pilot)
|
||||
runs-on: [self-hosted, arm64]
|
||||
runs-on: [self-hosted, arm64-darwin]
|
||||
# NOT a required check; safe to sit pending until Mac runner is up.
|
||||
# If the Mac runner has trouble pulling actions/checkout we fall
|
||||
# back to a plain git clone (see step 'fallback clone').
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install shellcheck (arm64)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -eu
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
@@ -71,11 +72,16 @@ jobs:
|
||||
shellcheck --version | head -2
|
||||
|
||||
- name: Run shellcheck on .gitea/scripts/*.sh
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -eu
|
||||
# Only the scripts we control under .gitea/scripts. Pilot
|
||||
# scope is intentionally narrow — broaden in a follow-up
|
||||
# once the lane is proven.
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "WARN: shellcheck binary not found — skipping (pilot mode)"
|
||||
exit 0
|
||||
fi
|
||||
mapfile -t TARGETS < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
|
||||
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
||||
echo "No .sh files found under .gitea/scripts — nothing to check"
|
||||
|
||||
@@ -73,6 +73,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Keep Docker auth/buildx state inside the job temp dir. Publish
|
||||
# runners can inherit a HOME/DOCKER_CONFIG path that is host-owned
|
||||
# and not writable from the job container; docker login otherwise
|
||||
# fails before the image build starts.
|
||||
- name: Prepare writable Docker config
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export DOCKER_CONFIG="$RUNNER_TEMP/docker-config"
|
||||
mkdir -p "$DOCKER_CONFIG/buildx/certs"
|
||||
echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to ECR
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -234,6 +234,8 @@ jobs:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#774
|
||||
continue-on-error: true
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
|
||||
@@ -33,6 +33,8 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
@@ -68,6 +70,10 @@ export function CreateWorkspaceButton() {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
const [displayEnabled, setDisplayEnabled] = useState(false);
|
||||
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
|
||||
const [displayRootGB, setDisplayRootGB] = useState("80");
|
||||
const [displayResolution, setDisplayResolution] = useState("1920x1080");
|
||||
// Templates fetched from /api/templates — drives the dynamic provider
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
// selected template declares `runtime_config.providers` in its
|
||||
@@ -223,6 +229,10 @@ export function CreateWorkspaceButton() {
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setDisplayEnabled(false);
|
||||
setDisplayInstanceType("t3.xlarge");
|
||||
setDisplayRootGB("80");
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
@@ -264,6 +274,8 @@ export function CreateWorkspaceButton() {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
const [displayWidth, displayHeight] = displayResolution.split("x").map((v) => parseInt(v, 10));
|
||||
const parsedRootGB = parseInt(displayRootGB, 10);
|
||||
|
||||
const createResp = await api.post<{
|
||||
id: string;
|
||||
@@ -280,6 +292,21 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
|
||||
...(displayEnabled
|
||||
? {
|
||||
compute: {
|
||||
instance_type: displayInstanceType,
|
||||
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
|
||||
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
@@ -447,6 +474,73 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExternal && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3">
|
||||
<div className="mb-2 text-[11px] font-medium text-ink-mid">
|
||||
Container Config
|
||||
</div>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-medium text-ink">Display</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayEnabled}
|
||||
onChange={(e) => setDisplayEnabled(e.target.checked)}
|
||||
aria-label="Enable display"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</label>
|
||||
{displayEnabled && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label htmlFor="display-instance-type" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Instance
|
||||
</label>
|
||||
<select
|
||||
id="display-instance-type"
|
||||
value={displayInstanceType}
|
||||
onChange={(e) => setDisplayInstanceType(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="t3.large">t3.large</option>
|
||||
<option value="t3.xlarge">t3.xlarge</option>
|
||||
<option value="m6i.xlarge">m6i.xlarge</option>
|
||||
<option value="c6i.xlarge">c6i.xlarge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="display-root-gb" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Disk GB
|
||||
</label>
|
||||
<input
|
||||
id="display-root-gb"
|
||||
type="number"
|
||||
min="30"
|
||||
max="500"
|
||||
value={displayRootGB}
|
||||
onChange={(e) => setDisplayRootGB(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label htmlFor="display-resolution" className="mb-1 block text-[11px] text-ink-mid">
|
||||
Resolution
|
||||
</label>
|
||||
<select
|
||||
id="display-resolution"
|
||||
value={displayResolution}
|
||||
onChange={(e) => setDisplayResolution(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-2 py-2 text-xs text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="1920x1080">1920 x 1080</option>
|
||||
<option value="1600x900">1600 x 900</option>
|
||||
<option value="1280x720">1280 x 720</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
Parent Workspace
|
||||
|
||||
@@ -123,6 +123,46 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.parent_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits compute config by default", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Plain Agent" },
|
||||
});
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.compute).toBeUndefined();
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
});
|
||||
|
||||
it("sends display compute profile when desktop display is enabled", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Desktop Agent" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Enable display"));
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("anthropic:claude-opus-4-7");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
display: {
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DisplayStatus {
|
||||
protocol?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
viewer_url?: string;
|
||||
}
|
||||
|
||||
interface DisplayControlStatus {
|
||||
@@ -93,6 +94,31 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const releaseControl = async () => {
|
||||
const generation = requestGeneration.current;
|
||||
const controlPath = `/workspaces/${workspaceId}/display/control`;
|
||||
setControlBusy(true);
|
||||
setControlError(null);
|
||||
try {
|
||||
const next = await api.post<DisplayControlStatus>(`${controlPath}/release`, {});
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(next);
|
||||
} catch (err) {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControlError("Failed to release control");
|
||||
try {
|
||||
const latest = await api.get<DisplayControlStatus>(controlPath);
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(latest);
|
||||
} catch {
|
||||
if (requestGeneration.current !== generation) return;
|
||||
setControl(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestGeneration.current === generation) setControlBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
@@ -185,7 +211,97 @@ export function DisplayTab({ workspaceId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-[360px] flex-col bg-surface-sunken/30">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line/50 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium text-ink">Desktop</h3>
|
||||
<p className="mt-0.5 font-mono text-[10px] text-ink-mid">
|
||||
{status.mode || "desktop-control"} · {status.protocol || "display"}
|
||||
</p>
|
||||
</div>
|
||||
<DisplayControlBar
|
||||
control={control}
|
||||
controlBusy={controlBusy}
|
||||
controlError={controlError}
|
||||
onAcquire={acquireControl}
|
||||
onRelease={releaseControl}
|
||||
/>
|
||||
</div>
|
||||
{status.viewer_url ? (
|
||||
<iframe
|
||||
title="Workspace desktop"
|
||||
src={status.viewer_url}
|
||||
className="min-h-0 flex-1 border-0 bg-black"
|
||||
allow="clipboard-read; clipboard-write; fullscreen; pointer-lock"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-center">
|
||||
<div>
|
||||
<h3 className="mb-1.5 text-sm font-medium text-ink">Display session is not ready.</h3>
|
||||
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
|
||||
This workspace has display configuration, but the desktop session URL is not available yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayControlBar({
|
||||
control,
|
||||
controlBusy,
|
||||
controlError,
|
||||
onAcquire,
|
||||
onRelease,
|
||||
}: {
|
||||
control: DisplayControlStatus | null;
|
||||
controlBusy: boolean;
|
||||
controlError: string | null;
|
||||
onAcquire: () => void;
|
||||
onRelease: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{control && (
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-[11px] font-medium text-ink">
|
||||
{control.controller === "none"
|
||||
? "No active controller"
|
||||
: `Controlled by ${displayControlActorLabel(control)}`}
|
||||
</p>
|
||||
{control.expires_at && (
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-ink-mid">
|
||||
Until {new Date(control.expires_at).toLocaleTimeString()}
|
||||
</p>
|
||||
)}
|
||||
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
|
||||
</div>
|
||||
)}
|
||||
{control?.controller === "none" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAcquire}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Take control
|
||||
</button>
|
||||
)}
|
||||
{control?.controller === "user" && control.controlled_by === "admin-token" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRelease}
|
||||
disabled={controlBusy}
|
||||
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Release
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function displayControlActorLabel(control: DisplayControlStatus): string {
|
||||
|
||||
@@ -71,6 +71,61 @@ describe("DisplayTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the desktop stream when a display session is available", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "dcv",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
viewer_url: "https://display.example.test/session/ws-display",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Workspace desktop")).toBeTruthy();
|
||||
});
|
||||
const frame = screen.getByTitle("Workspace desktop") as HTMLIFrameElement;
|
||||
expect(frame.src).toBe("https://display.example.test/session/ws-display");
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "dcv",
|
||||
viewer_url: "https://display.example.test/session/ws-display",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Release" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Release" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/release", {});
|
||||
});
|
||||
|
||||
it("renders active display control locks as observe-only", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -90,8 +90,6 @@ Poll `GET /workspaces/:id/delegations` to check results. Each entry includes `de
|
||||
|
||||
This is the recommended way for agents to delegate work — it works for all runtimes (Claude Code, LangGraph, etc.) since it operates at the platform level.
|
||||
|
||||
Workspace creation also assigns an `awareness_namespace` on the workspace row. That namespace is later injected into the provisioned runtime.
|
||||
|
||||
### Registry
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|
||||
@@ -103,7 +103,7 @@ Migration files live in `workspace-server/migrations/` (latest: `022_workspace_s
|
||||
|
||||
| Table | Description |
|
||||
|-------|-------------|
|
||||
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `awareness_namespace`, `workspace_dir` |
|
||||
| `workspaces` | Core entity — status, runtime, `agent_card` JSONB, heartbeat columns, `current_task`, `workspace_dir` |
|
||||
| `canvas_layouts` | Per-workspace x/y canvas position |
|
||||
| `structure_events` | Append-only event log (workspace lifecycle, agent, approval events) |
|
||||
| `activity_logs` | A2A communications, task updates, agent logs, errors. `error_detail` is populated by the scheduler so cron run history can surface failure reasons. |
|
||||
|
||||
@@ -111,12 +111,13 @@ const maxProxyResponseBody = 10 << 20
|
||||
// a generic 502 page to canvas. 10s is well above realistic intra-region
|
||||
// latencies and well below CF's edge timeout.
|
||||
//
|
||||
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
|
||||
// 3. Transport.ResponseHeaderTimeout — 5min default. From request-body-end
|
||||
// to response-headers-start. Configurable via
|
||||
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
|
||||
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
|
||||
// turns (big context + internal delegate_task round-trips routinely exceed
|
||||
// the old 60s ceiling). Body streaming after headers is governed by the
|
||||
// turns and Codex scheduled tasks (big context + internal delegate_task
|
||||
// round-trips routinely exceed the old 60s/180s ceilings). Body streaming
|
||||
// after headers is governed by the
|
||||
// per-request context deadline, NOT this timeout — so multi-minute agent
|
||||
// responses still work fine.
|
||||
//
|
||||
@@ -131,7 +132,7 @@ var a2aClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
|
||||
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 5*time.Minute),
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
|
||||
// fan-in is bounded by the platform's broadcaster fan-out, not by
|
||||
|
||||
@@ -28,8 +28,8 @@ type proxyDispatchBuildError struct{ err error }
|
||||
func (e *proxyDispatchBuildError) Error() string { return e.err.Error() }
|
||||
|
||||
// handleA2ADispatchError translates a forward-call failure into a proxyA2AError,
|
||||
// runs the reactive container-health check, and (when `logActivity` is true)
|
||||
// schedules a detached LogActivity goroutine for the failed attempt.
|
||||
// runs the reactive container-health check, and records the outcome. Busy
|
||||
// targets that are successfully queued are logged as queued, not failed.
|
||||
func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, err error, durationMs int, logActivity bool) (int, []byte, *proxyA2AError) {
|
||||
// Build-time failure (couldn't even create the http.Request) — return
|
||||
// a 500 without the reactive-health / busy-retry paths.
|
||||
@@ -45,10 +45,10 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
|
||||
containerDead := h.maybeMarkContainerDead(ctx, workspaceID)
|
||||
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
if containerDead {
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{"error": "workspace agent unreachable — container restart triggered", "restarting": true},
|
||||
@@ -108,6 +108,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
ctx, workspaceID, callerID, PriorityTask, body, a2aMethod, idempotencyKey, expiresAt,
|
||||
); qerr == nil {
|
||||
log.Printf("ProxyA2A: target %s busy — enqueued as %s (depth=%d)", workspaceID, qid, depth)
|
||||
if logActivity {
|
||||
h.logA2ABusyQueued(ctx, workspaceID, callerID, body, a2aMethod, durationMs)
|
||||
}
|
||||
respBody, _ := json.Marshal(gin.H{
|
||||
"queued": true,
|
||||
"queue_id": qid,
|
||||
@@ -121,6 +124,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
// make delegation silently disappear.
|
||||
log.Printf("ProxyA2A: enqueue for %s failed (%v) — falling back to 503", workspaceID, qerr)
|
||||
}
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
|
||||
@@ -131,6 +137,9 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
},
|
||||
}
|
||||
}
|
||||
if logActivity {
|
||||
h.logA2AFailure(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs)
|
||||
}
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusBadGateway,
|
||||
Response: gin.H{"error": "failed to reach workspace agent"},
|
||||
@@ -311,6 +320,33 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
})
|
||||
}
|
||||
|
||||
// logA2ABusyQueued records that a push attempt reached a live but busy
|
||||
// workspace and was durably queued for heartbeat drain.
|
||||
func (h *WorkspaceHandler) logA2ABusyQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string, durationMs int) {
|
||||
var wsName string
|
||||
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||
if wsName == "" {
|
||||
wsName = workspaceID
|
||||
}
|
||||
summary := a2aMethod + " → " + wsName + " (queued: target busy)"
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: nilIfEmpty(callerID),
|
||||
TargetID: &workspaceID,
|
||||
Method: &a2aMethod,
|
||||
Summary: &summary,
|
||||
RequestBody: json.RawMessage(body),
|
||||
DurationMs: &durationMs,
|
||||
Status: "ok",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
|
||||
// 2xx/3xx responses) broadcasts an A2A_RESPONSE event so the frontend can
|
||||
// receive the reply without polling.
|
||||
|
||||
@@ -1779,6 +1779,58 @@ func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BusyEnqueueLogsQueuedNotFailure(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
waitForHandlerAsyncBeforeDBCleanup(t, handler)
|
||||
|
||||
mock.ExpectQuery(`INSERT INTO a2a_queue`).
|
||||
WithArgs("ws-busy", nil, PriorityTask, "{}", "message/send", nil, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("11111111-1111-1111-1111-111111111111"))
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue`).
|
||||
WithArgs("ws-busy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
|
||||
WithArgs("ws-busy").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Busy Target"))
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
"ws-busy",
|
||||
"a2a_receive",
|
||||
nil,
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
nil,
|
||||
nil,
|
||||
sqlmock.AnyArg(),
|
||||
"ok",
|
||||
nil,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
status, body, perr := handler.handleA2ADispatchError(
|
||||
context.Background(), "ws-busy", "", []byte("{}"), "message/send",
|
||||
context.DeadlineExceeded, 180002, true,
|
||||
)
|
||||
if perr != nil {
|
||||
t.Fatalf("expected busy enqueue success, got proxy error: %+v", perr)
|
||||
}
|
||||
if status != http.StatusAccepted {
|
||||
t.Fatalf("got status %d, want 202", status)
|
||||
}
|
||||
if !bytes.Contains(body, []byte(`"queued":true`)) {
|
||||
t.Fatalf("expected queued response body, got %s", string(body))
|
||||
}
|
||||
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations; busy enqueue must log status=ok, not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleA2ADispatchError_BuildError(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -2354,7 +2406,7 @@ func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
|
||||
// ==================== a2aClient ResponseHeaderTimeout config ====================
|
||||
|
||||
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
const defaultTimeout = 180 * time.Second
|
||||
const defaultTimeout = 5 * time.Minute
|
||||
|
||||
// Default (unset env) — a2aClient was initialised at package load time.
|
||||
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
|
||||
@@ -2378,7 +2430,7 @@ func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
|
||||
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
|
||||
// Simulate what envx.Duration does with an invalid value.
|
||||
var fallback = 180 * time.Second
|
||||
var fallback = 5 * time.Minute
|
||||
override := fallback
|
||||
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
|
||||
@@ -159,7 +159,8 @@ func generateAppInstallationToken() (string, time.Time, error) {
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signed)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -69,7 +69,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
|
||||
mock.ExpectBegin()
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -291,7 +291,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
|
||||
@@ -364,11 +364,11 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
// Expect transaction begin for atomic workspace+secrets creation
|
||||
mock.ExpectBegin()
|
||||
|
||||
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace).
|
||||
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime).
|
||||
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
|
||||
// delivery_mode defaults to "push" when payload omits it (#2339).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Expect transaction commit (no secrets in this payload)
|
||||
@@ -412,24 +412,17 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
if resp["id"] == nil || resp["id"] == "" {
|
||||
t.Error("expected non-empty id in response")
|
||||
}
|
||||
if resp["awareness_namespace"] != "workspace:"+resp["id"].(string) {
|
||||
t.Errorf("expected awareness namespace derived from workspace id, got %v", resp["awareness_namespace"])
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
||||
func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
// runtime_image_pins reader removed by RFC internal#617 / task #335
|
||||
// — CP is the SSOT for runtime image pins. No DB lookup here anymore.
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
t.Setenv("AWARENESS_URL", "http://awareness:37800")
|
||||
t.Setenv("WORKSPACE_DIR", "/tmp/workspace")
|
||||
|
||||
cfg := handler.buildProvisionerConfig(
|
||||
@@ -440,17 +433,10 @@ func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
|
||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
|
||||
map[string]string{"OPENAI_API_KEY": "sk-test"},
|
||||
"/tmp/plugins",
|
||||
"workspace:ws-123",
|
||||
)
|
||||
|
||||
if cfg.AwarenessURL != "http://awareness:37800" {
|
||||
t.Fatalf("expected awareness URL to be injected, got %q", cfg.AwarenessURL)
|
||||
}
|
||||
if cfg.AwarenessNamespace != "workspace:ws-123" {
|
||||
t.Fatalf("expected awareness namespace to be injected, got %q", cfg.AwarenessNamespace)
|
||||
}
|
||||
if cfg.WorkspacePath != "/tmp/workspace" {
|
||||
t.Fatalf("expected workspace path from env, got %q", cfg.WorkspacePath)
|
||||
t.Fatalf("expected workspace path from payload, got %q", cfg.WorkspacePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ func (h *MemoryHandler) List(c *gin.Context) {
|
||||
entry.Value = json.RawMessage(value)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Memory list iteration error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query iteration failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, entries)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -74,6 +75,34 @@ func TestMemoryList_DBError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryList_RowsErr_Returns500 verifies that a rows.Err() set during
|
||||
// iteration causes the handler to return 500 rather than partial results.
|
||||
func TestMemoryList_RowsErr_Returns500(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewMemoryHandler()
|
||||
|
||||
cols := []string{"key", "value", "version", "expires_at", "updated_at"}
|
||||
mock.ExpectQuery("SELECT key, value, version, expires_at, updated_at").
|
||||
WithArgs("ws-rowerr").
|
||||
WillReturnRows(sqlmock.NewRows(cols).
|
||||
AddRow("ok-key", []byte(`"val"`), int64(1), nil, time.Now()).
|
||||
RowError(0, errors.New("storage engine fault")))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-rowerr"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-rowerr/memory", nil)
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("rows.Err() must yield 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GET /workspaces/:id/memory/:key (Get) ====================
|
||||
|
||||
func TestMemoryGet_Success(t *testing.T) {
|
||||
|
||||
@@ -799,13 +799,12 @@ func (h *OrgHandler) Import(c *gin.Context) {
|
||||
if len(tmpl.GlobalMemories) > 0 && len(results) > 0 {
|
||||
rootID, _ := results[0]["id"].(string)
|
||||
if rootID != "" {
|
||||
rootNS := workspaceAwarenessNamespace(rootID)
|
||||
// Force scope to GLOBAL regardless of what the YAML says.
|
||||
globalSeeds := make([]models.MemorySeed, len(tmpl.GlobalMemories))
|
||||
for i, gm := range tmpl.GlobalMemories {
|
||||
globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"}
|
||||
}
|
||||
seedInitialMemories(context.Background(), rootID, globalSeeds, rootNS)
|
||||
seedInitialMemories(context.Background(), rootID, globalSeeds)
|
||||
log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
awarenessNS := workspaceAwarenessNamespace(id)
|
||||
|
||||
var role interface{}
|
||||
if ws.Role != "" {
|
||||
@@ -168,13 +167,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
// EXACTLY for Postgres to consider the index applicable.
|
||||
var insertedID string
|
||||
err := db.DB.QueryRowContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
|
||||
WHERE status != 'removed'
|
||||
DO NOTHING
|
||||
RETURNING id
|
||||
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
|
||||
`, id, ws.Name, role, tier, runtime, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Skip path — a non-removed row already exists for
|
||||
// (parent_id, name). Re-select its id; idempotency-friendly
|
||||
@@ -259,7 +258,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
|
||||
if len(wsMemories) == 0 {
|
||||
wsMemories = defaults.InitialMemories
|
||||
}
|
||||
seedInitialMemories(ctx, id, wsMemories, awarenessNS)
|
||||
seedInitialMemories(ctx, id, wsMemories)
|
||||
|
||||
// Handle external workspaces
|
||||
if ws.External {
|
||||
|
||||
@@ -216,7 +216,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
awarenessNamespace := workspaceAwarenessNamespace(id)
|
||||
if h.IsSaaS() {
|
||||
// SaaS hard gate: every hosted workspace gets its own sibling
|
||||
// EC2 instance, so T4 is the only meaningful runtime boundary.
|
||||
@@ -448,10 +447,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// returns the actually-persisted name (which we MUST thread back into
|
||||
// payload + broadcast so the canvas displays what the DB has).
|
||||
const insertWorkspaceSQL = `
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12)
|
||||
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
|
||||
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
|
||||
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx,
|
||||
tx,
|
||||
@@ -572,7 +571,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
|
||||
// Seed initial memories from the create payload (issue #1050).
|
||||
// Non-fatal: failures are logged but don't block workspace creation.
|
||||
seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace)
|
||||
seedInitialMemories(ctx, id, payload.InitialMemories)
|
||||
|
||||
// Broadcast provisioning event. Include `runtime` so the canvas can
|
||||
// populate the Runtime pill on the side panel immediately — without it
|
||||
@@ -707,10 +706,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": id,
|
||||
"status": "provisioning",
|
||||
"awareness_namespace": awarenessNamespace,
|
||||
"workspace_access": workspaceAccess,
|
||||
"id": id,
|
||||
"status": "provisioning",
|
||||
"workspace_access": workspaceAccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,6 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
nil, // role
|
||||
3, // tier (default, workspace.go create-handler)
|
||||
"langgraph", // runtime
|
||||
sqlmock.AnyArg(), // awareness_namespace
|
||||
(*string)(nil), // parent_id
|
||||
nil, // workspace_dir
|
||||
"none", // workspace_access
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
@@ -15,6 +18,10 @@ import (
|
||||
const (
|
||||
workspaceComputeDiskFloorGB = 30
|
||||
workspaceComputeDiskCeilingGB = 500
|
||||
workspaceDisplayMinWidth = 800
|
||||
workspaceDisplayMaxWidth = 3840
|
||||
workspaceDisplayMinHeight = 600
|
||||
workspaceDisplayMaxHeight = 2160
|
||||
)
|
||||
|
||||
type workspaceDisplayResponse struct {
|
||||
@@ -25,6 +32,7 @@ type workspaceDisplayResponse struct {
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ViewerURL string `json:"viewer_url,omitempty"`
|
||||
}
|
||||
|
||||
var workspaceComputeInstanceAllowlist = map[string]struct{}{
|
||||
@@ -54,12 +62,12 @@ func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch compute.Display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if compute.Display.Width < 0 || compute.Display.Height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
if err := validateWorkspaceDisplayDimensions(compute.Display.Width, compute.Display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -71,13 +79,26 @@ func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) erro
|
||||
return fmt.Errorf("unsupported compute.display.mode")
|
||||
}
|
||||
switch display.Protocol {
|
||||
case "", "dcv":
|
||||
case "", "dcv", "novnc":
|
||||
default:
|
||||
return fmt.Errorf("unsupported compute.display.protocol")
|
||||
}
|
||||
if display.Width < 0 || display.Height < 0 {
|
||||
if err := validateWorkspaceDisplayDimensions(display.Width, display.Height); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceDisplayDimensions(width, height int) error {
|
||||
if width < 0 || height < 0 {
|
||||
return fmt.Errorf("compute.display width/height must be non-negative")
|
||||
}
|
||||
if width != 0 && (width < workspaceDisplayMinWidth || width > workspaceDisplayMaxWidth) {
|
||||
return fmt.Errorf("compute.display.width must be between %d and %d", workspaceDisplayMinWidth, workspaceDisplayMaxWidth)
|
||||
}
|
||||
if height != 0 && (height < workspaceDisplayMinHeight || height > workspaceDisplayMaxHeight) {
|
||||
return fmt.Errorf("compute.display.height must be between %d and %d", workspaceDisplayMinHeight, workspaceDisplayMaxHeight)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -196,6 +217,18 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if viewerURL := workspaceDisplayViewerURL(workspaceID); viewerURL != "" {
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: true,
|
||||
Mode: compute.Display.Mode,
|
||||
Protocol: compute.Display.Protocol,
|
||||
Width: compute.Display.Width,
|
||||
Height: compute.Display.Height,
|
||||
Status: "ready",
|
||||
ViewerURL: viewerURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(200, workspaceDisplayResponse{
|
||||
Available: false,
|
||||
Reason: "display_session_unavailable",
|
||||
@@ -206,3 +239,15 @@ func (h *WorkspaceHandler) Display(c *gin.Context) {
|
||||
Status: "not_configured",
|
||||
})
|
||||
}
|
||||
|
||||
func workspaceDisplayViewerURL(workspaceID string) string {
|
||||
base := strings.TrimRight(os.Getenv("DISPLAY_VIEWER_BASE_URL"), "/")
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return base + "/" + url.PathEscape(workspaceID)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceCompute_RejectsOutOfRangeDisplayDimensions(t *testing.T) {
|
||||
for _, display := range []models.WorkspaceComputeDisplay{
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 799, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 3841, Height: 1080},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 599},
|
||||
{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 2161},
|
||||
} {
|
||||
compute := models.WorkspaceCompute{Display: display}
|
||||
if err := validateWorkspaceCompute(compute); err == nil {
|
||||
t.Fatalf("validateWorkspaceCompute accepted display size %dx%d", display.Width, display.Height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
|
||||
got, err := workspaceComputeJSON(models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
@@ -141,11 +155,11 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
Compute: models.WorkspaceCompute{
|
||||
InstanceType: "m6i.xlarge",
|
||||
Volume: models.WorkspaceComputeVolume{RootGB: 100},
|
||||
Display: models.WorkspaceComputeDisplay{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
t.TempDir(),
|
||||
"workspace:ws-compute",
|
||||
)
|
||||
|
||||
if cfg.InstanceType != "m6i.xlarge" {
|
||||
@@ -154,6 +168,12 @@ func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
|
||||
if cfg.DiskGB != 100 {
|
||||
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
|
||||
}
|
||||
if cfg.Display.Mode != "desktop-control" || cfg.Display.Protocol != "novnc" {
|
||||
t.Errorf("cfg.Display mode/protocol = %q/%q, want desktop-control/novnc", cfg.Display.Mode, cfg.Display.Protocol)
|
||||
}
|
||||
if cfg.Display.Width != 1920 || cfg.Display.Height != 1080 {
|
||||
t.Errorf("cfg.Display size = %dx%d, want 1920x1080", cfg.Display.Width, cfg.Display.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
|
||||
@@ -217,7 +237,7 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -242,8 +262,8 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
if resp["status"] != "not_configured" {
|
||||
t.Fatalf("status = %v, want not_configured", resp["status"])
|
||||
}
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
|
||||
if resp["mode"] != "desktop-control" || resp["protocol"] != "novnc" {
|
||||
t.Fatalf("mode/protocol = %v/%v, want desktop-control/novnc", resp["mode"], resp["protocol"])
|
||||
}
|
||||
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
|
||||
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
|
||||
@@ -256,6 +276,83 @@ func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithViewerBaseReturnsAvailableSession(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_VIEWER_BASE_URL", "https://display.example.test/sessions")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != true {
|
||||
t.Fatalf("available = %v, want true", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != "https://display.example.test/sessions/ws-display" {
|
||||
t.Fatalf("viewer_url = %v, want workspace viewer URL", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != nil {
|
||||
t.Fatalf("reason = %v, want omitted", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_DisplayConfiguredWithInvalidViewerBaseReturnsUnavailable(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
t.Setenv("DISPLAY_VIEWER_BASE_URL", "http://display.example.test/sessions")
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
workspaceID := "ws-display"
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(workspaceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/display", nil)
|
||||
|
||||
handler.Display(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse display response: %v", err)
|
||||
}
|
||||
if resp["available"] != false {
|
||||
t.Fatalf("available = %v, want false", resp["available"])
|
||||
}
|
||||
if resp["viewer_url"] != nil {
|
||||
t.Fatalf("viewer_url = %v, want omitted for invalid viewer base", resp["viewer_url"])
|
||||
}
|
||||
if resp["reason"] != "display_session_unavailable" {
|
||||
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -263,7 +360,7 @@ func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-display-sizing-drift").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"novnc","width":1920,"height":1080}}`))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -103,13 +103,13 @@ func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) {
|
||||
// TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision
|
||||
// exercises the helper end-to-end against a real Postgres:
|
||||
//
|
||||
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
|
||||
// 2. Run insertWorkspaceWithNameRetry with the same name —
|
||||
// partial-unique violation fires, helper retries with
|
||||
// " (2)", that succeeds.
|
||||
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
|
||||
// 4. Run helper AGAIN — second collision, helper retries with
|
||||
// " (3)".
|
||||
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
|
||||
// 2. Run insertWorkspaceWithNameRetry with the same name —
|
||||
// partial-unique violation fires, helper retries with
|
||||
// " (2)", that succeeds.
|
||||
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
|
||||
// 4. Run helper AGAIN — second collision, helper retries with
|
||||
// " (3)".
|
||||
//
|
||||
// This is the live-test that proves the partial-index behaviour
|
||||
// matches the migration's intent — sqlmock cannot reach this depth.
|
||||
@@ -130,9 +130,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
|
||||
// targets + the NOT NULL columns required by the schema).
|
||||
firstID := uuid.New().String()
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
|
||||
`, firstID, baseName, "workspace:"+firstID); err != nil {
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
|
||||
`, firstID, baseName); err != nil {
|
||||
t.Fatalf("seed first row: %v", err)
|
||||
}
|
||||
|
||||
@@ -145,10 +145,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
|
||||
}
|
||||
secondID := uuid.New().String()
|
||||
query := `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
|
||||
`
|
||||
args := []any{secondID, baseName, "workspace:" + secondID}
|
||||
args := []any{secondID, baseName}
|
||||
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx, tx, beginTx, baseName, 1, query, args,
|
||||
)
|
||||
@@ -179,7 +179,7 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
|
||||
t.Fatalf("begin tx3: %v", err)
|
||||
}
|
||||
thirdID := uuid.New().String()
|
||||
args3 := []any{thirdID, baseName, "workspace:" + thirdID}
|
||||
args3 := []any{thirdID, baseName}
|
||||
persistedName3, finalTx3, err := insertWorkspaceWithNameRetry(
|
||||
ctx, tx3, beginTx, baseName, 1, query, args3,
|
||||
)
|
||||
@@ -216,9 +216,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
|
||||
// Seed a row, then tombstone it.
|
||||
firstID := uuid.New().String()
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'removed')
|
||||
`, firstID, baseName, "workspace:"+firstID); err != nil {
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'removed')
|
||||
`, firstID, baseName); err != nil {
|
||||
t.Fatalf("seed tombstoned row: %v", err)
|
||||
}
|
||||
|
||||
@@ -231,10 +231,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
|
||||
}
|
||||
secondID := uuid.New().String()
|
||||
query := `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
|
||||
`
|
||||
args := []any{secondID, baseName, "workspace:" + secondID}
|
||||
args := []any{secondID, baseName}
|
||||
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
|
||||
ctx, tx, beginTx, baseName, 1, query, args,
|
||||
)
|
||||
|
||||
@@ -128,7 +128,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
workspaceID, filepath.Base(runtimeTemplate))
|
||||
templatePath = runtimeTemplate
|
||||
// Rebuild cfg with the recovered template path so Start() sees it.
|
||||
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath, prepared.AwarenessNamespace)
|
||||
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath)
|
||||
cfg.ResetClaudeSession = resetClaudeSession
|
||||
recovered = true
|
||||
break
|
||||
@@ -194,10 +194,11 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
// a ~64k context window worth of text — but small enough to prevent abuse.
|
||||
const maxMemoryContentLength = 100_000 // ~100 KiB of text
|
||||
|
||||
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed, awarenessNamespace string) {
|
||||
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed) {
|
||||
if len(memories) == 0 {
|
||||
return
|
||||
}
|
||||
namespace := workspaceMemoryNamespace(workspaceID)
|
||||
for _, mem := range memories {
|
||||
scope := strings.ToUpper(mem.Scope)
|
||||
if scope == "" {
|
||||
@@ -223,33 +224,27 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, workspaceID, redactedContent, scope, awarenessNamespace); err != nil {
|
||||
`, workspaceID, redactedContent, scope, namespace); err != nil {
|
||||
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
|
||||
}
|
||||
}
|
||||
log.Printf("seedInitialMemories: seeded %d memories for workspace %s", len(memories), workspaceID)
|
||||
}
|
||||
|
||||
func workspaceAwarenessNamespace(workspaceID string) string {
|
||||
// workspaceMemoryNamespace returns the canonical v2 memory namespace
|
||||
// string for a workspace. Matches the form produced by
|
||||
// internal/memory/namespace/resolver.go for self-reads (issue #1735).
|
||||
func workspaceMemoryNamespace(workspaceID string) string {
|
||||
return fmt.Sprintf("workspace:%s", workspaceID)
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) loadAwarenessNamespace(ctx context.Context, workspaceID string) string {
|
||||
var awarenessNamespace string
|
||||
err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(awareness_namespace, '') FROM workspaces WHERE id = $1`, workspaceID).Scan(&awarenessNamespace)
|
||||
if err != nil || awarenessNamespace == "" {
|
||||
return workspaceAwarenessNamespace(workspaceID)
|
||||
}
|
||||
return awarenessNamespace
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
ctx context.Context,
|
||||
workspaceID, templatePath string,
|
||||
configFiles map[string][]byte,
|
||||
payload models.CreateWorkspacePayload,
|
||||
envVars map[string]string,
|
||||
pluginsPath, awarenessNamespace string,
|
||||
pluginsPath string,
|
||||
) provisioner.WorkspaceConfig {
|
||||
// Per-workspace workspace_dir takes priority over global WORKSPACE_DIR env var.
|
||||
// If neither is set, the provisioner creates an isolated Docker volume.
|
||||
@@ -288,20 +283,24 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
}
|
||||
|
||||
return provisioner.WorkspaceConfig{
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
AwarenessURL: os.Getenv("AWARENESS_URL"),
|
||||
AwarenessNamespace: awarenessNamespace,
|
||||
WorkspaceID: workspaceID,
|
||||
TemplatePath: templatePath,
|
||||
ConfigFiles: configFiles,
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
DiskGB: int32(payload.Compute.Volume.RootGB),
|
||||
Display: provisioner.WorkspaceDisplayConfig{
|
||||
Mode: payload.Compute.Display.Mode,
|
||||
Width: payload.Compute.Display.Width,
|
||||
Height: payload.Compute.Display.Height,
|
||||
Protocol: payload.Compute.Display.Protocol,
|
||||
},
|
||||
EnvVars: envVars,
|
||||
PlatformURL: h.platformURL,
|
||||
// Image left empty — molecule-core's runtime_image_pins table (mig
|
||||
// 047, dead reader removed by RFC internal#617 / task #335) was an
|
||||
// aspirational SSOT that never received a writer. CP's
|
||||
|
||||
@@ -85,10 +85,9 @@ func readOrLazyHealInboundSecret(ctx context.Context, workspaceID, opLabel strin
|
||||
// prepareProvisionContext when the caller proceeds; nil + non-empty
|
||||
// abort message when the caller must mark the workspace failed.
|
||||
type preparedProvisionContext struct {
|
||||
EnvVars map[string]string
|
||||
PluginsPath string
|
||||
AwarenessNamespace string
|
||||
Config provisioner.WorkspaceConfig
|
||||
EnvVars map[string]string
|
||||
PluginsPath string
|
||||
Config provisioner.WorkspaceConfig
|
||||
}
|
||||
|
||||
// provisionAbort describes why prepareProvisionContext refused to
|
||||
@@ -170,7 +169,6 @@ func (h *WorkspaceHandler) prepareProvisionContext(
|
||||
}
|
||||
|
||||
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
|
||||
awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID)
|
||||
|
||||
// Per-agent git identity (#1957) — must run after secret loads so
|
||||
// a workspace_secret named GIT_AUTHOR_NAME can override.
|
||||
@@ -231,14 +229,13 @@ func (h *WorkspaceHandler) prepareProvisionContext(
|
||||
}
|
||||
}
|
||||
|
||||
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath, awarenessNamespace)
|
||||
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath)
|
||||
cfg.ResetClaudeSession = resetClaudeSession
|
||||
|
||||
return &preparedProvisionContext{
|
||||
EnvVars: envVars,
|
||||
PluginsPath: pluginsPath,
|
||||
AwarenessNamespace: awarenessNamespace,
|
||||
Config: cfg,
|
||||
EnvVars: envVars,
|
||||
PluginsPath: pluginsPath,
|
||||
Config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ==================== workspaceAwarenessNamespace ====================
|
||||
// ==================== workspaceMemoryNamespace ====================
|
||||
|
||||
func TestWorkspaceAwarenessNamespace(t *testing.T) {
|
||||
func TestWorkspaceMemoryNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
workspaceID string
|
||||
expected string
|
||||
@@ -31,9 +31,9 @@ func TestWorkspaceAwarenessNamespace(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.workspaceID, func(t *testing.T) {
|
||||
result := workspaceAwarenessNamespace(tt.workspaceID)
|
||||
result := workspaceMemoryNamespace(tt.workspaceID)
|
||||
if result != tt.expected {
|
||||
t.Errorf("workspaceAwarenessNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
|
||||
t.Errorf("workspaceMemoryNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -645,7 +645,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
}
|
||||
|
||||
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), workspaceID, memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
@@ -674,7 +674,7 @@ func TestSeedInitialMemories_RedactsSecrets(t *testing.T) {
|
||||
WithArgs(workspaceID, wantRedacted, "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), workspaceID, memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet DB expectations: %v", err)
|
||||
@@ -691,7 +691,7 @@ func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) {
|
||||
{Content: "this should be skipped", Scope: "NOT_A_REAL_SCOPE"},
|
||||
}
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-bad-scope", memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-bad-scope", memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB calls for invalid scope: %v", err)
|
||||
@@ -704,7 +704,7 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectationsWereMet()
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-nil", nil, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-nil", nil)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB calls for nil slice: %v", err)
|
||||
@@ -733,7 +733,6 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
|
||||
models.CreateWorkspacePayload{Tier: 1, Runtime: "langgraph"},
|
||||
map[string]string{"API_KEY": "secret"},
|
||||
pluginsPath,
|
||||
"workspace:ws-basic",
|
||||
)
|
||||
|
||||
if cfg.WorkspaceID != "ws-basic" {
|
||||
@@ -748,9 +747,6 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
|
||||
if cfg.PlatformURL != "http://localhost:8080" {
|
||||
t.Errorf("expected PlatformURL 'http://localhost:8080', got %q", cfg.PlatformURL)
|
||||
}
|
||||
if cfg.AwarenessNamespace != "workspace:ws-basic" {
|
||||
t.Errorf("expected AwarenessNamespace 'workspace:ws-basic', got %q", cfg.AwarenessNamespace)
|
||||
}
|
||||
if cfg.PluginsPath != pluginsPath {
|
||||
t.Errorf("expected PluginsPath %q, got %q", pluginsPath, cfg.PluginsPath)
|
||||
}
|
||||
@@ -775,7 +771,6 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
|
||||
|
||||
workspaceDir := t.TempDir()
|
||||
t.Setenv("WORKSPACE_DIR", workspaceDir)
|
||||
t.Setenv("AWARENESS_URL", "http://awareness:37800")
|
||||
|
||||
pluginsPath := t.TempDir()
|
||||
cfg := handler.buildProvisionerConfig(
|
||||
@@ -786,15 +781,11 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
|
||||
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
|
||||
nil,
|
||||
pluginsPath,
|
||||
"workspace:ws-env",
|
||||
)
|
||||
|
||||
if cfg.WorkspacePath != workspaceDir {
|
||||
t.Errorf("expected WorkspacePath from env, got %q", cfg.WorkspacePath)
|
||||
}
|
||||
if cfg.AwarenessURL != "http://awareness:37800" {
|
||||
t.Errorf("expected AwarenessURL from env, got %q", cfg.AwarenessURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== issueAndInjectToken (issue #418) ====================
|
||||
@@ -1007,7 +998,7 @@ func TestSeedInitialMemories_Truncation(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), expectTruncated, "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-1066-test", memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-1066-test", memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v\n"+
|
||||
@@ -1027,7 +1018,7 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), "short content", "TEAM", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-1066-under", memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-1066-under", memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
@@ -1052,7 +1043,7 @@ func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), atLimitContent, "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-boundary", memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
@@ -1068,7 +1059,7 @@ func TestSeedInitialMemories_EmptyContent(t *testing.T) {
|
||||
}
|
||||
|
||||
// seedInitialMemories skips empty content at line 234 — no DB call expected.
|
||||
seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-empty", memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
@@ -1092,7 +1083,7 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns")
|
||||
seedInitialMemories(context.Background(), "ws-secrets", memories)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
|
||||
@@ -342,7 +342,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
|
||||
// Transaction begins, workspace INSERT fails, transaction is rolled back.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
mock.ExpectRollback()
|
||||
|
||||
@@ -375,7 +375,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
// Expect workspace INSERT with defaulted tier=3 (Privileged — the
|
||||
// handler default in workspace.go), runtime="langgraph"
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
@@ -423,7 +423,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -464,7 +464,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Secret inserted inside the same transaction.
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
@@ -576,7 +576,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// External URL update (localhost is explicitly allowed by validateAgentURL).
|
||||
@@ -615,7 +615,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// Pre-register flow: awaiting_agent + runtime preserved as "kimi"
|
||||
@@ -1639,7 +1639,7 @@ runtime_config:
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes",
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1696,7 +1696,7 @@ model: anthropic:claude-sonnet-4-5
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Legacy Agent", nil, 3, "langgraph",
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1749,7 +1749,7 @@ runtime_config:
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes",
|
||||
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
@@ -1894,7 +1894,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
|
||||
@@ -17,7 +17,6 @@ type Workspace struct {
|
||||
Name string `json:"name" db:"name"`
|
||||
Role sql.NullString `json:"role" db:"role"`
|
||||
Tier int `json:"tier" db:"tier"`
|
||||
AwarenessNamespace sql.NullString `json:"awareness_namespace" db:"awareness_namespace"`
|
||||
Status string `json:"status" db:"status"`
|
||||
SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"`
|
||||
AgentCard json.RawMessage `json:"agent_card" db:"agent_card"`
|
||||
@@ -207,7 +206,8 @@ type CreateWorkspacePayload struct {
|
||||
} `json:"canvas"`
|
||||
// InitialMemories is an optional list of memories to seed into the
|
||||
// workspace immediately after creation. Each entry is inserted into
|
||||
// agent_memories with the workspace's awareness namespace. Issue #1050.
|
||||
// agent_memories under the workspace's v2 memory namespace
|
||||
// ("workspace:<id>"). Issue #1050.
|
||||
InitialMemories []MemorySeed `json:"initial_memories"`
|
||||
}
|
||||
|
||||
|
||||
@@ -152,14 +152,15 @@ func (p *CPProvisioner) adminAuthHeaders(req *http.Request) {
|
||||
}
|
||||
|
||||
type cpProvisionRequest struct {
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
OrgID string `json:"org_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Runtime string `json:"runtime"`
|
||||
Tier int `json:"tier"`
|
||||
InstanceType string `json:"instance_type,omitempty"`
|
||||
DiskGB int32 `json:"disk_gb,omitempty"`
|
||||
Display WorkspaceDisplayConfig `json:"display,omitempty"`
|
||||
PlatformURL string `json:"platform_url"`
|
||||
Env map[string]string `json:"env"`
|
||||
// ConfigFiles are template + generated config files to write into the
|
||||
// EC2 instance's /configs directory. OFFSEC-010: collected by
|
||||
// collectCPConfigFiles which rejects symlinks and non-regular files
|
||||
@@ -214,6 +215,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
Tier: cfg.Tier,
|
||||
InstanceType: cfg.InstanceType,
|
||||
DiskGB: cfg.DiskGB,
|
||||
Display: cfg.Display,
|
||||
PlatformURL: cfg.PlatformURL,
|
||||
Env: env,
|
||||
ConfigFiles: configFiles,
|
||||
|
||||
@@ -197,6 +197,12 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
if body.DiskGB != 100 {
|
||||
t.Errorf("disk_gb = %d, want 100", body.DiskGB)
|
||||
}
|
||||
if body.Display.Mode != "desktop-control" || body.Display.Protocol != "novnc" {
|
||||
t.Errorf("display mode/protocol = %q/%q, want desktop-control/novnc", body.Display.Mode, body.Display.Protocol)
|
||||
}
|
||||
if body.Display.Width != 1920 || body.Display.Height != 1080 {
|
||||
t.Errorf("display size = %dx%d, want 1920x1080", body.Display.Width, body.Display.Height)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
|
||||
}))
|
||||
@@ -212,6 +218,7 @@ func TestStart_HappyPath(t *testing.T) {
|
||||
id, err := p.Start(context.Background(), WorkspaceConfig{
|
||||
WorkspaceID: "ws-1", Runtime: "python", Tier: 1, PlatformURL: "http://tenant",
|
||||
InstanceType: "m6i.xlarge", DiskGB: 100,
|
||||
Display: WorkspaceDisplayConfig{Mode: "desktop-control", Protocol: "novnc", Width: 1920, Height: 1080},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
|
||||
@@ -97,13 +97,12 @@ type WorkspaceConfig struct {
|
||||
PluginsPath string // Host path to plugins directory (mounted at /plugins)
|
||||
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
|
||||
Tier int
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Runtime string // "langgraph" (default) or "claude-code", "codex", "ollama", "custom"
|
||||
InstanceType string // Optional CP EC2 instance type override (SaaS only)
|
||||
DiskGB int32 // Optional CP root volume size override in GiB (SaaS only)
|
||||
Display WorkspaceDisplayConfig
|
||||
EnvVars map[string]string // Additional env vars (API keys, etc.)
|
||||
PlatformURL string
|
||||
AwarenessURL string
|
||||
AwarenessNamespace string
|
||||
WorkspaceAccess string // #65: "none" (default), "read_only", or "read_write"
|
||||
ResetClaudeSession bool // #12: if true, discard the claude-sessions volume before start (fresh session dir)
|
||||
|
||||
@@ -122,6 +121,13 @@ type WorkspaceConfig struct {
|
||||
Image string
|
||||
}
|
||||
|
||||
type WorkspaceDisplayConfig struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
// selectImage resolves the final Docker image ref for a workspace. The handler
|
||||
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
|
||||
// supplied by CP, the SSOT for runtime image pins; molecule-core's own
|
||||
@@ -706,10 +712,6 @@ func buildContainerEnv(cfg WorkspaceConfig) []string {
|
||||
// still override (Dockerfile ENV is overridden by docker -e at runtime).
|
||||
"PYTHONPATH=/app",
|
||||
}
|
||||
if cfg.AwarenessNamespace != "" && cfg.AwarenessURL != "" {
|
||||
env = append(env, fmt.Sprintf("AWARENESS_NAMESPACE=%s", cfg.AwarenessNamespace))
|
||||
env = append(env, fmt.Sprintf("AWARENESS_URL=%s", cfg.AwarenessURL))
|
||||
}
|
||||
// #1687: track explicit GH_TOKEN / GITHUB_TOKEN so they win over GH_PAT
|
||||
// alias. These are normally stripped by the SCM-write guard below, but
|
||||
// when a user explicitly sets them we preserve the value.
|
||||
|
||||
@@ -692,39 +692,6 @@ func TestBuildContainerEnv_MoleculeAIURLAlwaysMatchesPlatformURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerEnv_AwarenessOnlyWhenBothSet(t *testing.T) {
|
||||
// Both set → both injected.
|
||||
cfg := WorkspaceConfig{
|
||||
WorkspaceID: "ws-x",
|
||||
PlatformURL: "http://localhost:8080",
|
||||
AwarenessURL: "http://awareness:9000",
|
||||
AwarenessNamespace: "ns-1",
|
||||
}
|
||||
env := buildContainerEnv(cfg)
|
||||
hasNS := false
|
||||
hasURL := false
|
||||
for _, e := range env {
|
||||
if e == "AWARENESS_NAMESPACE=ns-1" {
|
||||
hasNS = true
|
||||
}
|
||||
if e == "AWARENESS_URL=http://awareness:9000" {
|
||||
hasURL = true
|
||||
}
|
||||
}
|
||||
if !hasNS || !hasURL {
|
||||
t.Errorf("both awareness vars must be present: env=%v", env)
|
||||
}
|
||||
|
||||
// Only namespace set → neither injected (must be both-or-nothing).
|
||||
cfg.AwarenessURL = ""
|
||||
env2 := buildContainerEnv(cfg)
|
||||
for _, e := range env2 {
|
||||
if strings.HasPrefix(e, "AWARENESS_") {
|
||||
t.Errorf("awareness vars must NOT be injected when URL is missing: got %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContainerEnv_CustomEnvVarsAppended(t *testing.T) {
|
||||
// NOTE: this test previously asserted GITHUB_TOKEN passed through
|
||||
// verbatim. That assertion encoded the forensic #145 latent leak as
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- Reverse of 20260523130000_drop_workspaces_awareness_namespace.up.sql.
|
||||
--
|
||||
-- Restores the workspaces.awareness_namespace column verbatim from
|
||||
-- migration 010_workspace_awareness.sql so a down-cycle leaves the
|
||||
-- schema bit-identical to the pre-drop state. The column will be
|
||||
-- NULL on all rows after re-add — handlers no longer write to it and
|
||||
-- callers no longer read it, so this is functionally inert without
|
||||
-- a paired code revert.
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS awareness_namespace TEXT;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Issue #1735 — drop the workspaces.awareness_namespace column.
|
||||
--
|
||||
-- "Awareness namespaces" were a memory-routing surface (env vars
|
||||
-- AWARENESS_URL / AWARENESS_NAMESPACE) that was plumbed across the
|
||||
-- platform but never wired in any production or staging environment
|
||||
-- (verified 2026-05-23 via Railway GraphQL on the controlplane service:
|
||||
-- AWARENESS_* unset in both env IDs 59227671-… and 639539ec-…).
|
||||
--
|
||||
-- The column added by migration 010_workspace_awareness.sql was only
|
||||
-- ever populated with the canonical "workspace:<id>" string, which is
|
||||
-- also the v2 memory namespace string (see internal/memory/namespace/
|
||||
-- resolver.go:186). Removing the column does not change any agent-
|
||||
-- visible memory namespace — handlers now compute the same
|
||||
-- "workspace:<id>" string inline when inserting into agent_memories.
|
||||
--
|
||||
-- Related: #1733 (memory SSOT consolidation), #1734 (Memory tab bug).
|
||||
|
||||
ALTER TABLE workspaces
|
||||
DROP COLUMN IF EXISTS awareness_namespace;
|
||||
Reference in New Issue
Block a user