forked from molecule-ai/molecule-core
Merge branch 'main' into fix/111-112-clean
This commit is contained in:
commit
5c389efc82
1784
canvas/package-lock.json
generated
1784
canvas/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -145,19 +145,19 @@ export function CommunicationOverlay() {
|
||||
<span className="text-zinc-300 font-medium truncate">
|
||||
{c.sourceName}
|
||||
</span>
|
||||
<span className="text-zinc-600">→</span>
|
||||
<span className="text-zinc-400">→</span>
|
||||
<span className="text-zinc-300 truncate">{c.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={statusColor}>{statusIcon}</span>
|
||||
<span className="text-zinc-600">{age}</span>
|
||||
<span className="text-zinc-400">{age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.summary && (
|
||||
<div className="text-zinc-500 truncate mt-0.5 pl-4">{c.summary}</div>
|
||||
)}
|
||||
{c.durationMs && (
|
||||
<div className="text-zinc-600 pl-4">{c.durationMs}ms</div>
|
||||
<div className="text-zinc-400 pl-4">{c.durationMs}ms</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -42,11 +42,48 @@ export function ConfirmDialog({
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3 / 3.2.2)
|
||||
useEffect(() => {
|
||||
if (!open || !mounted) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open, mounted]);
|
||||
|
||||
// Keyboard: Escape cancels, Enter confirms, Tab is trapped within the dialog
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancelRef.current();
|
||||
if (e.key === "Enter") onConfirmRef.current();
|
||||
if (e.key === "Escape") {
|
||||
onCancelRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
onConfirmRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && dialogRef.current) {
|
||||
const focusable = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((el) => !el.hasAttribute("disabled"));
|
||||
if (focusable.length === 0) { e.preventDefault(); return; }
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
@ -68,13 +105,16 @@ export function ConfirmDialog({
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
{/* Dialog — role="dialog" + aria-modal prevent interaction with background */}
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -162,7 +162,7 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] text-zinc-600 font-mono">
|
||||
<span className="text-[9px] text-zinc-400 font-mono">
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
@ -183,7 +183,7 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
: entry.activity_type.toUpperCase()}
|
||||
</span>
|
||||
{entry.duration_ms != null && entry.duration_ms > 0 && (
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
<span className="text-[9px] text-zinc-400">
|
||||
{entry.duration_ms > 1000
|
||||
? `${Math.round(entry.duration_ms / 1000)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
@ -199,7 +199,7 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName || wsName}
|
||||
</span>
|
||||
<span className="text-zinc-600"> → </span>
|
||||
<span className="text-zinc-400"> → </span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName}
|
||||
</span>
|
||||
@ -211,7 +211,7 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
</span>
|
||||
{sourceName && (
|
||||
<>
|
||||
<span className="text-zinc-600">
|
||||
<span className="text-zinc-400">
|
||||
{" "}← {" "}
|
||||
</span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
@ -248,7 +248,7 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{requestText.slice(0, 2000)}
|
||||
{requestText.length > 2000 && (
|
||||
<span className="text-zinc-600"> ...({requestText.length} chars)</span>
|
||||
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -259,7 +259,7 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{responseText.slice(0, 2000)}
|
||||
{responseText.length > 2000 && (
|
||||
<span className="text-zinc-600"> ...({responseText.length} chars)</span>
|
||||
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -112,9 +112,9 @@ export function SearchDialog() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search workspaces..."
|
||||
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none"
|
||||
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus:outline-none"
|
||||
/>
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
@ -125,7 +125,7 @@ export function SearchDialog() {
|
||||
className="max-h-[300px] overflow-y-auto py-1"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-400">
|
||||
{query ? "No workspaces match" : "No workspaces yet"}
|
||||
</div>
|
||||
) : (
|
||||
@ -156,7 +156,7 @@ export function SearchDialog() {
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-mono text-zinc-600"
|
||||
className="text-[9px] font-mono text-zinc-400"
|
||||
aria-label={`Tier ${node.data.tier}`}
|
||||
>
|
||||
T{node.data.tier}
|
||||
@ -168,10 +168,10 @@ export function SearchDialog() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-zinc-600">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<span className="text-[9px] text-zinc-400">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="flex gap-2">
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-zinc-600 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↵ select</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↵ select</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -319,7 +319,7 @@ export function TemplatePalette() {
|
||||
: "bg-zinc-900/90 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600"
|
||||
}`}
|
||||
title="Template Palette"
|
||||
aria-label="Template Palette"
|
||||
aria-label={open ? "Close template palette" : "Open template palette"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.5" />
|
||||
|
||||
@ -18,35 +18,87 @@ export function showToast(message: string, type: Toast["type"] = "info") {
|
||||
export function Toaster() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const dismiss = (id: string) =>
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
|
||||
useEffect(() => {
|
||||
addToastFn = (message, type = "info") => {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
setToasts((prev) => [...prev.slice(-4), { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
// Errors persist until the user explicitly dismisses them.
|
||||
// Success / info auto-expire after 4 s.
|
||||
if (type !== "error") {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
addToastFn = null;
|
||||
};
|
||||
return () => { addToastFn = null; };
|
||||
}, []);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
const toastCls = (type: Toast["type"]) =>
|
||||
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
type === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
|
||||
: type === "error"
|
||||
? "bg-red-950/90 border border-red-700/40 text-red-200"
|
||||
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
|
||||
}`;
|
||||
|
||||
const pos =
|
||||
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-4 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
toast.type === "success"
|
||||
? "bg-emerald-950/90 border border-emerald-700/40 text-emerald-200"
|
||||
: toast.type === "error"
|
||||
? "bg-red-950/90 border border-red-700/40 text-red-200"
|
||||
: "bg-zinc-900/90 border border-zinc-700/40 text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/*
|
||||
* Polite live region — success & info notifications.
|
||||
* Always rendered so screen readers register it before any toast fires.
|
||||
*/}
|
||||
<div role="status" aria-live="polite" aria-atomic="false" className={pos}>
|
||||
{toasts
|
||||
.filter((t) => t.type !== "error")
|
||||
.map((toast) => (
|
||||
<div key={toast.id} className={toastCls(toast.type)}>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Assertive live region — errors only.
|
||||
* aria-live="assertive" interrupts the screen reader immediately.
|
||||
* Errors never auto-expire; user must dismiss via the × button.
|
||||
*/}
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="false"
|
||||
className={pos}
|
||||
>
|
||||
{toasts
|
||||
.filter((t) => t.type === "error")
|
||||
.map((toast) => (
|
||||
<div key={toast.id} className={toastCls(toast.type)}>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-zinc-700/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -77,6 +77,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${data.name} workspace — ${data.status}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectNode(isSelected ? null : id);
|
||||
@ -92,6 +96,21 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
e.stopPropagation();
|
||||
openContextMenu({ x: e.clientX, y: e.clientY, nodeId: id, nodeData: data });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectNode(isSelected ? null : id);
|
||||
} else if (e.key === "ContextMenu") {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
openContextMenu({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
nodeId: id,
|
||||
nodeData: data,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
group relative rounded-xl
|
||||
${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"}
|
||||
|
||||
@ -97,9 +97,12 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
|
||||
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
|
||||
<div role="tablist" className="flex border-b border-zinc-800/40 bg-zinc-900/30 px-2 shrink-0">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={subTab === "my-chat"}
|
||||
aria-controls="chat-panel-my-chat"
|
||||
onClick={() => setSubTab("my-chat")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
subTab === "my-chat"
|
||||
@ -110,6 +113,9 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
My Chat
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={subTab === "agent-comms"}
|
||||
aria-controls="chat-panel-agent-comms"
|
||||
onClick={() => setSubTab("agent-comms")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
subTab === "agent-comms"
|
||||
@ -123,9 +129,13 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{subTab === "my-chat" ? (
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
<div id="chat-panel-my-chat" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
|
||||
<MyChatPanel workspaceId={workspaceId} data={data} />
|
||||
</div>
|
||||
) : (
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
<div id="chat-panel-agent-comms" role="tabpanel" className="flex-1 overflow-hidden flex flex-col">
|
||||
<AgentCommsPanel workspaceId={workspaceId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -408,6 +418,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div className="p-3 border-t border-zinc-800">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
aria-label="Message to agent"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@ -192,7 +192,7 @@ describe("handleCanvasEvent – WORKSPACE_PROVISIONING", () => {
|
||||
const n = newNodes[0];
|
||||
expect(n.id).toBe("ws-new");
|
||||
expect(n.type).toBe("workspaceNode");
|
||||
expect(n.position).toEqual({ x: 0, y: 0 });
|
||||
expect(n.position).toEqual({ x: 100, y: 100 });
|
||||
expect(n.data.name).toBe("Brand New");
|
||||
expect(n.data.tier).toBe(3);
|
||||
expect(n.data.status).toBe("provisioning");
|
||||
|
||||
@ -88,13 +88,15 @@ export function handleCanvasEvent(
|
||||
),
|
||||
});
|
||||
} else {
|
||||
// Spread new nodes in a grid so they don't stack
|
||||
const GRID_COL_WIDTH = 320;
|
||||
const GRID_ROW_HEIGHT = 160;
|
||||
// Spread new nodes in a grid so they don't stack at the viewport origin
|
||||
const GRID_COLS = 4;
|
||||
const rootIndex = nodes.filter((n) => !n.data.parentId).length;
|
||||
const x = (rootIndex % GRID_COLS) * GRID_COL_WIDTH;
|
||||
const y = Math.floor(rootIndex / GRID_COLS) * GRID_ROW_HEIGHT;
|
||||
const COL_SPACING = 320;
|
||||
const ROW_SPACING = 160;
|
||||
const GRID_ORIGIN_X = 100;
|
||||
const GRID_ORIGIN_Y = 100;
|
||||
const idx = nodes.length;
|
||||
const x = GRID_ORIGIN_X + (idx % GRID_COLS) * COL_SPACING;
|
||||
const y = GRID_ORIGIN_Y + Math.floor(idx / GRID_COLS) * ROW_SPACING;
|
||||
|
||||
set({
|
||||
nodes: [
|
||||
|
||||
@ -324,7 +324,7 @@
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #18181b;
|
||||
color: #f4f4f5;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
.add-key-form__select:focus,
|
||||
@ -399,7 +399,7 @@
|
||||
font-size: 14px;
|
||||
font-family: monospace;
|
||||
background: #18181b;
|
||||
color: #f4f4f5;
|
||||
color: #d4d4d8;
|
||||
}
|
||||
|
||||
.key-value-field input:focus {
|
||||
@ -683,7 +683,7 @@
|
||||
}
|
||||
|
||||
.settings-button:hover { background: #27272a; color: #f4f4f5; }
|
||||
.settings-button--active { color: #3b82f6; background: #1e3a5f; }
|
||||
.settings-button--active { color: #3b82f6; background: #1e3a8a; }
|
||||
.settings-button:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); }
|
||||
|
||||
.settings-button__tooltip {
|
||||
|
||||
@ -736,6 +736,165 @@ builders; Molecule AI users are developers building agent companies.
|
||||
|
||||
---
|
||||
|
||||
### Scion — `GoogleCloudPlatform/scion`
|
||||
|
||||
**Pitch:** "An experimental agent hypervisor — each agent runs in its own isolated container with dedicated credentials, config, and git worktree; orchestrates Claude Code, Gemini CLI, Codex, and OpenCode concurrently."
|
||||
|
||||
**Shape:** Go + YAML (Apache-2.0). Container-per-agent isolation via Docker, Podman, Apple Containers, or Kubernetes. Named runtime profiles. Introduces an `agents.md` capability-declaration convention. Not a framework — a harness supervisor.
|
||||
|
||||
**Overlap with us:** Container-per-agent mirrors our Docker workspace model. Multi-harness concurrency maps to multi-workspace A2A topology. Explicitly manages Claude Code — direct contact with our user base.
|
||||
|
||||
**Differentiation:** No persistent agent memory, no visual canvas, no A2A between agents, no channels. It is the container orchestration layer beneath agents; we are the agent identity and collaboration layer above.
|
||||
|
||||
**Worth borrowing:** `agents.md` capability spec — a standard file per workspace declaring what the agent can do. Adopt in `workspace-template/` for Scion interoperability.
|
||||
|
||||
**Terminology collisions:** "profile" — Scion: named runtime config; ours: undefined. "harness" — both mean "the process managing agent execution."
|
||||
|
||||
**Signals to react to:** If Scion adds A2A or a memory layer → direct overlap. If `agents.md` gains wide adoption → align `workspace-template/` to the spec.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** GCP repo, 230 HN pts at launch, April 8, 2026
|
||||
|
||||
---
|
||||
|
||||
### claude-mem — `thedotmack/claude-mem`
|
||||
|
||||
**Pitch:** "Automatically captures everything Claude does during coding sessions — persistent cross-session memory with search, timeline, and observation retrieval as MCP tools."
|
||||
|
||||
**Shape:** TypeScript (AGPL-3.0), ~56k ⭐, +2,997 stars in one day. Five lifecycle hooks (`SessionStart`, `UserPromptSubmit`, `PostToolUse`, `Stop`, `SessionEnd`) intercept agent actions, compress observations via Claude SDK, store in SQLite FTS5 + Chroma hybrid. Three MCP tools exposed: `search`, `timeline`, `get_observations`. Web viewer at localhost:37777. ⚠️ `ragtime/` retrieval subdirectory is PolyForm Noncommercial — reimplementation required for commercial SaaS use.
|
||||
|
||||
**Overlap with us:** Directly addresses our known cross-session memory gap. Lifecycle hooks are structurally compatible with our harness entry points.
|
||||
|
||||
**Differentiation:** A memory add-on for a single Claude Code session; no A2A, no org hierarchy, no scheduling, no channels.
|
||||
|
||||
**Worth borrowing:** `PostToolUse` + `SessionEnd` → compressed observation pipeline, compatible with our harness lifecycle. Progressive-disclosure retrieval (summaries first, full content on demand) caps token overhead at `SessionStart`.
|
||||
|
||||
**Terminology collisions:** "observations" — their captured agent actions; not a first-class term in our platform.
|
||||
|
||||
**Signals to react to:** If PolyForm NC removed from `ragtime/` → evaluate direct integration. If hook schema is formalized → adopt as standard workspace lifecycle spec.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** ~56k ⭐, +2,997 today
|
||||
|
||||
---
|
||||
|
||||
### Multica — `multica-ai/multica`
|
||||
|
||||
**Pitch:** "Turn coding agents into real teammates — assign tasks, track progress, compound skills."
|
||||
|
||||
**Shape:** TypeScript + Go (Next.js 16 / Chi router / PostgreSQL 17 + pgvector), ~12.8k ⭐, +1,503 today. Local agent daemons execute Claude Code / Codex / OpenCode in isolation; state syncs to a central backend. Solved tasks are semantically indexed via pgvector and surfaced to future agents team-wide — the "skill-compounding" model. 36 releases, 1.6k forks, actively shipped.
|
||||
|
||||
**Overlap with us:** Skill-compounding maps to our plugin/skills registry but adds automatic semantic indexing. Local-daemon + central-backend mirrors Docker workspaces + Canvas backend. Cross-agent task assignment and scheduling are first-class features.
|
||||
|
||||
**Differentiation:** No visual org-chart canvas, no A2A protocol, no persistent agent identity across restarts, no channel integrations. Central backend is a coordination hub, not peer-to-peer. Closer to a task manager for agents than an agent company platform.
|
||||
|
||||
**Worth borrowing:** pgvector semantic indexing of solved tasks — each completed workspace run contributes to a searchable skill pool, evolving our plugin registry from file-based discovery to semantic retrieval.
|
||||
|
||||
**Terminology collisions:** "skills" — their skills are solved-task embeddings; ours are installed behaviour bundles.
|
||||
|
||||
**Signals to react to:** If Multica adds A2A or persistent agent identity → direct competitor. Star velocity (+1,503/day) warrants weekly tracking.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** ~12.8k ⭐, +1,503 today, 36 releases
|
||||
|
||||
---
|
||||
|
||||
### Skills CLI — `vercel-labs/skills`
|
||||
|
||||
**Pitch:** "The CLI for the open agent skills ecosystem — discover, install, and share reusable skills across 45+ coding agents."
|
||||
|
||||
**Shape:** TypeScript (MIT), ~14.2k ⭐, +153 today. `npx skills` package manager backed by Vercel. Skills are `SKILL.md` directories following the [agentskills.io](https://agentskills.io) open spec. Targets Claude Code, Codex, Gemini CLI, Cursor, Cline, OpenCode, Hermes, Holaboss, and 37+ others from a single repository.
|
||||
|
||||
**Overlap with us:** Three existing entries (Hermes, gstack, Holaboss) flag "if agentskills.io picks up mass adoption → align our plugin manifest." This is that moment: Vercel ships the canonical install CLI with 14k stars and 45-agent coverage.
|
||||
|
||||
**Differentiation:** Skills CLI is a package manager, not an agent runtime. No canvas, A2A, or scheduling. It installs behavior bundles into whatever agent the developer uses; Molecule AI is the runtime those bundles run inside.
|
||||
|
||||
**Worth borrowing:** Align our `plugins/` manifest to the agentskills.io `SKILL.md` spec so any `npx skills`-installable skill also installs cleanly into a Molecule AI workspace. Dual compatibility = free distribution channel.
|
||||
|
||||
**Terminology collisions:** "skills" — same word, same filesystem convention; full spec alignment is the goal, not a collision to manage.
|
||||
|
||||
**Signals to react to:** If `npx skills` becomes the de facto install path industry-wide → our `plugins/install` should natively consume the same manifest format. If agentskills.io publishes a versioned schema → adopt it immediately in `plugins/`.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** ~14.2k ⭐, +153 today, Vercel-backed
|
||||
|
||||
---
|
||||
|
||||
### Archon — `coleam00/Archon`
|
||||
|
||||
**Pitch:** "The first open-source harness builder for AI coding — make AI coding deterministic and repeatable."
|
||||
|
||||
**Shape:** TypeScript (MIT), ~18.1k ⭐, +396 today. Defines AI coding workflows as YAML DAGs: planning → implementation → validation → review → PR. Each run is git-worktree-isolated. Nodes are either AI-powered (Claude Code generation) or deterministic (bash, test runners). Human approval gates at any phase. Delivery to Slack, Telegram, Discord, GitHub, or web UI. "What Dockerfiles did for infra, Archon does for AI coding."
|
||||
|
||||
**Overlap with us:** Wraps Claude Code in a structured pipeline — the same pattern as our Dev Lead delegating to a Claude Code workspace. Approval gates map to our `approvals` table. Git-worktree isolation mirrors our `workspace-template/` worktree pattern.
|
||||
|
||||
**Differentiation:** No persistent agent identity, no org hierarchy, no A2A, no canvas, no multi-session scheduling. Archon defines a single delivery run; Molecule AI is the persistent company those runs operate inside.
|
||||
|
||||
**Worth borrowing:** YAML-DAG workflow definition (planning → implementation → validation → PR) with mixed AI/deterministic nodes — natural extension of `workspace-template/` for repeatable, auditable delivery pipelines.
|
||||
|
||||
**Terminology collisions:** "workflow" — their YAML DAG vs our informal usage. "harness" — Archon, Scion, and our Claude Code runner all claim the word; Molecule AI docs should clarify its own use.
|
||||
|
||||
**Signals to react to:** If Archon adds multi-workspace coordination → direct competitor to our orchestration layer. If their YAML workflow schema gains wide adoption → add an Archon import adapter to `workspace-template/`.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** ~18.1k ⭐, +396 today, v0.3.6
|
||||
|
||||
---
|
||||
|
||||
### Claude Code Routines — `anthropic.com` *(commercial, no public repo)*
|
||||
|
||||
**Pitch:** "Schedule Claude Code agents to run automatically on timers and GitHub events — agentic workflows in the cloud without manual intervention."
|
||||
|
||||
**Shape:** Anthropic-hosted cloud feature. Users define routines that fire a Claude Code session on cron timers or GitHub events (push, PR, issue). Runs serverlessly inside Anthropic infrastructure. No self-hosting, no public API. HN item 47768133: 611 pts, 355 comments at launch today — significant community concern about vendor lock-in.
|
||||
|
||||
**Overlap with us:** Direct overlap with `workspace_schedules` + cron-triggered workspace execution. Anthropic now competes in the scheduled agentic execution space with a first-party hosted offering.
|
||||
|
||||
**Differentiation:** No persistent agent memory, no org hierarchy, no A2A between agents, no visual canvas, no multi-model support, Anthropic-only lock-in. HN consensus: "trivially reproducible with cron + API." Our differentiators: multi-agent coordination, persistent identity, model-agnosticism, self-hostability.
|
||||
|
||||
**Worth borrowing:** GitHub event triggers (push/PR/issue → fire agent) as first-class schedule trigger types. Our `workspace_schedules` is cron-only; this gap is now competitively visible.
|
||||
|
||||
**Terminology collisions:** "routine" — Anthropic: a scheduled agent session; near-synonym with our `workspace_schedule` rows.
|
||||
|
||||
**Signals to react to:** If Routines adds A2A between routines → direct platform competition from Anthropic with massive distribution advantage. If lock-in backlash grows → double down on "self-hostable, model-agnostic" narrative as the open alternative.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** Anthropic cloud feature, 611 HN pts today (item 47768133)
|
||||
|
||||
---
|
||||
|
||||
### Microsoft Agent Framework — `microsoft/agent-framework`
|
||||
|
||||
**Pitch:** "A framework for building, orchestrating and deploying AI agents and multi-agent workflows with support for Python and .NET."
|
||||
|
||||
**Shape:** Python + C#/.NET (MIT), ~9.5k ⭐, April 2026 active releases. Graph-based workflow engine with streaming, checkpointing, and human-in-the-loop approval gates. Supports Azure OpenAI, Microsoft Foundry, and OpenAI. Ships a DevUI for interactive debugging, OpenTelemetry observability, and "AF Labs" (experimental RL-based features). Ships a migration guide from AutoGen — this is the official Microsoft successor to `microsoft/autogen`.
|
||||
|
||||
**Overlap with us:** Our workspace-template adapters target AutoGen/AG2; this is the official Microsoft path forward, making our adapter coverage incomplete. HITL approval gates and graph-based multi-agent routing mirror our `approvals` table + delegation chain.
|
||||
|
||||
**Differentiation:** Orchestration SDK only — no persistent agent memory, no org-chart canvas, no A2A between independently deployed agents, no scheduling, no channel integrations.
|
||||
|
||||
**Worth borrowing:** DevUI interactive debugging panel (inspect agent state mid-run without a full canvas). AF Labs RL routing — agents improve delegation decisions from past run outcomes; worth evaluating for our PM workspace's `delegate_task` routing.
|
||||
|
||||
**Terminology collisions:** "middleware" — their processing pipeline hook; undefined in our platform. "graph" — their workflow DAG vs our live org chart (same word, different semantics).
|
||||
|
||||
**Signals to react to:** If AF 1.0 achieves enterprise adoption → update our autogen adapter to target `microsoft/agent-framework`. If AF Labs RL ships stable → evaluate for dynamic PM routing based on workspace performance history.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** ~9.5k ⭐, April 2026 .NET release, official AutoGen successor
|
||||
|
||||
---
|
||||
|
||||
### Open Agents — `vercel-labs/open-agents`
|
||||
|
||||
**Pitch:** "An open-source reference app for building and running background coding agents on Vercel — fork it, adapt it, ship your own cloud coding agent."
|
||||
|
||||
**Shape:** TypeScript (MIT), ~2.2k ⭐, +1,020 today. Three-layer architecture: web UI → agent workflow (Vercel Workflow SDK for durable execution) → isolated sandbox VM. Key design principle: **agent runs *outside* the sandbox VM** and interacts with it through tools — not co-located. Snapshot-based VM resumption, auto-commit/push/PR, session sharing via read-only links, voice input. From Vercel Labs — same team as the Skills CLI entry above.
|
||||
|
||||
**Overlap with us:** Vercel Workflow SDK gives checkpoint-and-resume durability — the same gap our workspace restart-context solves ad hoc. Agent-outside-sandbox mirrors our Docker workspace + adapter separation. Auto-PR creation is a first-class feature we implement manually.
|
||||
|
||||
**Differentiation:** Single coding agent, no org hierarchy, no A2A, no scheduling, no persistent memory across sessions, no channels. A reference template, not an operational platform.
|
||||
|
||||
**Worth borrowing:** Snapshot-based sandbox resumption — preserves VM state across agent restarts without re-cloning the repo. More efficient than our current Docker restart + `git clone` approach for long-running workspace tasks.
|
||||
|
||||
**Terminology collisions:** "workflow" — Vercel's durable execution primitive; our informal delegation chain term.
|
||||
|
||||
**Signals to react to:** If Vercel Workflow SDK becomes a standard durable-execution backend → evaluate as a drop-in for `workspace_schedules` on Vercel-hosted deployments. If open-agents adds multi-agent coordination → direct competitor reference app with Vercel distribution.
|
||||
|
||||
**Last reviewed:** 2026-04-15 · **Stars / activity:** ~2.2k ⭐, +1,020 today, Vercel Labs
|
||||
|
||||
---
|
||||
## Candidates to add (backlog)
|
||||
|
||||
Short-list of projects to write up next time someone has an hour:
|
||||
|
||||
@ -53,6 +53,16 @@ defaults:
|
||||
performance: [Backend Engineer]
|
||||
docs: [Documentation Specialist]
|
||||
mixed: [Dev Lead]
|
||||
# Evolution-cron categories (#93): these four are fired by hourly
|
||||
# self-review schedules (Research Lead, Technical Researcher, Dev Lead,
|
||||
# DevOps Engineer). Routing them to the same role that generated them
|
||||
# is a safe default — it converts the summary into a delegation back
|
||||
# to the author so they act on their own findings. Override per-org
|
||||
# if you want a different fan-out.
|
||||
research: [Research Lead]
|
||||
plugins: [Technical Researcher]
|
||||
template: [Dev Lead]
|
||||
channels: [DevOps Engineer]
|
||||
|
||||
# workspace_dir: not set by default — each agent gets an isolated Docker volume
|
||||
# Set per-workspace to bind-mount a host directory as /workspace
|
||||
@ -201,6 +211,12 @@ workspaces:
|
||||
tier: 3
|
||||
model: opus
|
||||
files_dir: dev-lead
|
||||
# Dev Lead enforces PR quality gates (see gate 2a in
|
||||
# .claude/skills/triage/SKILL.md) and reviews engineering output
|
||||
# before handoff to PM. The code-review skill surfaces the
|
||||
# 16-criteria rubric — without it Dev Lead falls back to ad-hoc
|
||||
# review prompts. Issue #133.
|
||||
plugins: [molecule-skill-code-review, molecule-skill-llm-judge]
|
||||
canvas: { x: 650, y: 250 }
|
||||
initial_prompt: |
|
||||
You just started as Dev Lead. Set up silently — do NOT contact other agents.
|
||||
@ -487,6 +503,9 @@ workspaces:
|
||||
tier: 3
|
||||
model: opus
|
||||
files_dir: qa-engineer
|
||||
# QA reviews test coverage + runs llm-judge on whether test
|
||||
# deliverables actually match acceptance criteria. Issue #133.
|
||||
plugins: [molecule-skill-code-review, molecule-skill-llm-judge]
|
||||
initial_prompt: |
|
||||
You just started as QA Engineer. Set up silently — do NOT contact other agents.
|
||||
1. Clone the repo: git clone https://github.com/${GITHUB_REPO}.git /workspace/repo 2>/dev/null || (cd /workspace/repo && git pull)
|
||||
|
||||
@ -115,6 +115,11 @@ func TestWorkspaceUpdate_ParentID(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// #125 guard: handler now verifies the workspace exists before applying
|
||||
// the UPDATE. Each PATCH test must mock the EXISTS probe first.
|
||||
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
|
||||
WithArgs("ws-child").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET parent_id").
|
||||
WithArgs("ws-child", "ws-parent").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
@ -144,6 +149,9 @@ func TestWorkspaceUpdate_NameOnly(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
|
||||
WithArgs("ws-rename").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET name").
|
||||
WithArgs("ws-rename", "New Name").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@ -73,6 +73,11 @@ func TestExtended_WorkspaceUpdate(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
// #120 fix: existence check runs first — workspace must be found before updates proceed.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("ws-upd").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
|
||||
// Expect name update
|
||||
mock.ExpectExec("UPDATE workspaces SET name").
|
||||
WithArgs("ws-upd", "New Name").
|
||||
@ -110,6 +115,48 @@ func TestExtended_WorkspaceUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtended_WorkspaceUpdate_NotFound verifies the #120 fix: PATCH /workspaces/:id
|
||||
// returns 404 (not 200) when the workspace does not exist in the DB.
|
||||
//
|
||||
// Before PR #125, the handler ran blind UPDATEs that matched zero rows and still
|
||||
// returned {"status":"updated"} HTTP 200 — allowing an attacker to probe and
|
||||
// speculatively modify workspace attributes (name, tier, parent_id, runtime,
|
||||
// workspace_dir) without any observable error. The existence guard must fire
|
||||
// and return 404 before any UPDATE is attempted.
|
||||
func TestExtended_WorkspaceUpdate_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
// Existence check returns false — workspace does not exist.
|
||||
mock.ExpectQuery("SELECT EXISTS").
|
||||
WithArgs("00000000-0000-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
// No UPDATE or INSERT should follow — the handler must short-circuit at 404.
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000000"}}
|
||||
|
||||
body := `{"name":"probe"}`
|
||||
c.Request = httptest.NewRequest("PATCH",
|
||||
"/workspaces/00000000-0000-0000-0000-000000000000",
|
||||
bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("#120 regression: expected 404 for nonexistent workspace, got %d: %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- TestWorkspaceRestart (Extended) ----------
|
||||
|
||||
func TestExtended_WorkspaceRestart_NoProvisioner(t *testing.T) {
|
||||
|
||||
@ -151,6 +151,7 @@ type updateScheduleRequest struct {
|
||||
// provided fields are changed — no dynamic SQL construction.
|
||||
func (h *ScheduleHandler) Update(c *gin.Context) {
|
||||
scheduleID := c.Param("scheduleId")
|
||||
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var body updateScheduleRequest
|
||||
@ -164,7 +165,8 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
|
||||
if body.CronExpr != nil || body.Timezone != nil {
|
||||
var currentCron, currentTZ string
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1`, scheduleID,
|
||||
`SELECT cron_expr, timezone FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`,
|
||||
scheduleID, workspaceID,
|
||||
).Scan(¤tCron, ¤tTZ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "schedule not found"})
|
||||
@ -199,8 +201,8 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
|
||||
enabled = COALESCE($6, enabled),
|
||||
next_run_at = COALESCE($7, next_run_at),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
`, scheduleID, body.Name, body.CronExpr, body.Timezone, body.Prompt, body.Enabled, nextRunAt)
|
||||
WHERE id = $1 AND workspace_id = $8
|
||||
`, scheduleID, body.Name, body.CronExpr, body.Timezone, body.Prompt, body.Enabled, nextRunAt, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("Schedules.Update: error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update schedule"})
|
||||
@ -218,10 +220,12 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
|
||||
// Delete removes a schedule.
|
||||
func (h *ScheduleHandler) Delete(c *gin.Context) {
|
||||
scheduleID := c.Param("scheduleId")
|
||||
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
|
||||
ctx := c.Request.Context()
|
||||
|
||||
result, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_schedules WHERE id = $1`, scheduleID)
|
||||
`DELETE FROM workspace_schedules WHERE id = $1 AND workspace_id = $2`,
|
||||
scheduleID, workspaceID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete schedule"})
|
||||
return
|
||||
|
||||
@ -155,6 +155,29 @@ type githubPRReviewCommentEvent struct {
|
||||
Comment githubComment `json:"comment"`
|
||||
}
|
||||
|
||||
// githubWorkflowRun captures the subset of GitHub's `workflow_run` event we
|
||||
// route to workspaces (#101). Full schema is ~50 fields; we only need the
|
||||
// handful that tell DevOps "which CI job failed, where, and how to get there."
|
||||
type githubWorkflowRun struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"` // workflow name, e.g. "CI"
|
||||
Event string `json:"event"` // push / pull_request / etc.
|
||||
Status string `json:"status"` // queued / in_progress / completed
|
||||
Conclusion string `json:"conclusion"` // success / failure / cancelled / timed_out
|
||||
HeadBranch string `json:"head_branch"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
RunNumber int `json:"run_number"`
|
||||
}
|
||||
|
||||
type githubWorkflowRunEvent struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Action string `json:"action"` // requested / in_progress / completed
|
||||
Repository githubRepository `json:"repository"`
|
||||
Sender githubSender `json:"sender"`
|
||||
WorkflowRun githubWorkflowRun `json:"workflow_run"`
|
||||
}
|
||||
|
||||
func buildGitHubA2APayload(eventType, deliveryID string, rawBody []byte) (string, map[string]interface{}, error) {
|
||||
switch eventType {
|
||||
case "issue_comment":
|
||||
@ -209,6 +232,50 @@ func buildGitHubA2APayload(eventType, deliveryID string, rawBody []byte) (string
|
||||
"pull_request_num": payload.PullRequest.Number,
|
||||
"comment_url": payload.Comment.HTMLURL,
|
||||
}), nil
|
||||
case "workflow_run":
|
||||
// #101 — CI-break notifications for DevOps Engineer. Only surface
|
||||
// *completed* runs with a non-success conclusion; queued / in_progress
|
||||
// are noise. A success completion is dropped too (explicit filter
|
||||
// rather than `errIgnoredGitHubAction` so the behaviour is visible
|
||||
// in the switch).
|
||||
var payload githubWorkflowRunEvent
|
||||
if err := json.Unmarshal(rawBody, &payload); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid workflow_run payload: %w", err)
|
||||
}
|
||||
if payload.Action != "completed" {
|
||||
return payload.WorkspaceID, nil, errIgnoredGitHubAction
|
||||
}
|
||||
if payload.WorkflowRun.Conclusion == "success" || payload.WorkflowRun.Conclusion == "skipped" || payload.WorkflowRun.Conclusion == "neutral" {
|
||||
return payload.WorkspaceID, nil, errIgnoredGitHubAction
|
||||
}
|
||||
text := fmt.Sprintf(
|
||||
"GitHub CI break — workflow '%s' run #%d %s on %s@%s\nTriggered by: %s (%s)\nRepo: %s\nRun URL: %s",
|
||||
payload.WorkflowRun.Name,
|
||||
payload.WorkflowRun.RunNumber,
|
||||
payload.WorkflowRun.Conclusion,
|
||||
payload.WorkflowRun.HeadBranch,
|
||||
payload.WorkflowRun.HeadSHA[:min(7, len(payload.WorkflowRun.HeadSHA))],
|
||||
payload.Sender.Login,
|
||||
payload.WorkflowRun.Event,
|
||||
payload.Repository.FullName,
|
||||
payload.WorkflowRun.HTMLURL,
|
||||
)
|
||||
return payload.WorkspaceID, newGitHubMessagePayload(text, map[string]interface{}{
|
||||
"source": "github",
|
||||
"event": eventType,
|
||||
"action": payload.Action,
|
||||
"delivery_id": deliveryID,
|
||||
"repository": payload.Repository.FullName,
|
||||
"sender": payload.Sender.Login,
|
||||
"workflow_name": payload.WorkflowRun.Name,
|
||||
"run_id": payload.WorkflowRun.ID,
|
||||
"run_number": payload.WorkflowRun.RunNumber,
|
||||
"conclusion": payload.WorkflowRun.Conclusion,
|
||||
"head_branch": payload.WorkflowRun.HeadBranch,
|
||||
"head_sha": payload.WorkflowRun.HeadSHA,
|
||||
"run_url": payload.WorkflowRun.HTMLURL,
|
||||
"trigger_event": payload.WorkflowRun.Event,
|
||||
}), nil
|
||||
default:
|
||||
return "", nil, errUnsupportedGitHubEvent
|
||||
}
|
||||
|
||||
89
platform/internal/handlers/webhooks_workflow_test.go
Normal file
89
platform/internal/handlers/webhooks_workflow_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Tests the workflow_run → DevOps A2A routing added for #101.
|
||||
|
||||
func TestBuildGitHubA2APayload_WorkflowRunFailure(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"workspace_id": "ws-devops",
|
||||
"action": "completed",
|
||||
"repository": {"full_name": "Molecule-AI/molecule-monorepo"},
|
||||
"sender": {"login": "hongming"},
|
||||
"workflow_run": {
|
||||
"id": 123456,
|
||||
"name": "CI",
|
||||
"event": "pull_request",
|
||||
"status": "completed",
|
||||
"conclusion": "failure",
|
||||
"head_branch": "fix/thing",
|
||||
"head_sha": "deadbeef1234567",
|
||||
"html_url": "https://github.com/Molecule-AI/molecule-monorepo/actions/runs/123456",
|
||||
"run_number": 42
|
||||
}
|
||||
}`)
|
||||
|
||||
wsID, payload, err := buildGitHubA2APayload("workflow_run", "delivery-abc", raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if wsID != "ws-devops" {
|
||||
t.Errorf("workspace id: got %q want ws-devops", wsID)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
text := string(body)
|
||||
for _, needle := range []string{"failure", "CI", "run #42", "fix/thing", "deadbee", "Molecule-AI/molecule-monorepo"} {
|
||||
if !strings.Contains(text, needle) {
|
||||
t.Errorf("missing %q in payload: %s", needle, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGitHubA2APayload_WorkflowRunSuccessIgnored(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"workspace_id": "ws-devops",
|
||||
"action": "completed",
|
||||
"repository": {"full_name": "x/y"},
|
||||
"sender": {"login": "u"},
|
||||
"workflow_run": {"name": "CI", "status": "completed", "conclusion": "success", "head_sha": "abcdef1"}
|
||||
}`)
|
||||
_, _, err := buildGitHubA2APayload("workflow_run", "d1", raw)
|
||||
if err != errIgnoredGitHubAction {
|
||||
t.Errorf("success run should be ignored; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGitHubA2APayload_WorkflowRunNonCompletedIgnored(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"workspace_id": "ws-devops",
|
||||
"action": "requested",
|
||||
"repository": {"full_name": "x/y"},
|
||||
"sender": {"login": "u"},
|
||||
"workflow_run": {"name": "CI", "status": "in_progress", "conclusion": "", "head_sha": "abc"}
|
||||
}`)
|
||||
_, _, err := buildGitHubA2APayload("workflow_run", "d2", raw)
|
||||
if err != errIgnoredGitHubAction {
|
||||
t.Errorf("non-completed action should be ignored; got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Short-SHA truncation used to crash when head_sha was < 7 chars — the
|
||||
// `min(7, len)` guard covers that edge case.
|
||||
func TestBuildGitHubA2APayload_WorkflowRunShortSHA(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"workspace_id": "ws-devops",
|
||||
"action": "completed",
|
||||
"repository": {"full_name": "x/y"},
|
||||
"sender": {"login": "u"},
|
||||
"workflow_run": {"name": "CI", "status": "completed", "conclusion": "failure", "head_sha": "abc", "run_number": 1}
|
||||
}`)
|
||||
_, _, err := buildGitHubA2APayload("workflow_run", "d3", raw)
|
||||
if err != nil {
|
||||
t.Errorf("short-sha path: %v", err)
|
||||
}
|
||||
}
|
||||
@ -402,6 +402,16 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// #120: guard — return 404 for nonexistent workspace IDs instead of
|
||||
// silently applying zero-row UPDATEs and returning 200.
|
||||
var exists bool
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM workspaces WHERE id = $1)`, id,
|
||||
).Scan(&exists); err != nil || !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if name, ok := body["name"]; ok {
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET name = $2, updated_at = now() WHERE id = $1`, id, name); err != nil {
|
||||
log.Printf("Update name error for %s: %v", id, err)
|
||||
|
||||
@ -304,6 +304,10 @@ func TestWorkspaceUpdate_MultipleFields(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// #125: existence probe fires once before any field update.
|
||||
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
|
||||
WithArgs("ws-multi").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
// Expect name, role, and tier updates
|
||||
mock.ExpectExec("UPDATE workspaces SET name").
|
||||
WithArgs("ws-multi", "Updated Agent").
|
||||
@ -348,6 +352,9 @@ func TestWorkspaceUpdate_RuntimeField(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery("SELECT EXISTS.*workspaces WHERE id").
|
||||
WithArgs("ws-rt").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE workspaces SET runtime").
|
||||
WithArgs("ws-rt", "claude-code").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@ -4,6 +4,7 @@ package middleware
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -71,11 +72,33 @@ func (rl *RateLimiter) Middleware() gin.HandlerFunc {
|
||||
b.lastReset = time.Now()
|
||||
}
|
||||
|
||||
// Issue #105 — advertise the current bucket state so clients and
|
||||
// monitoring tools can back off proactively. Headers are set on every
|
||||
// response (both allowed and throttled) so they're observable against
|
||||
// any endpoint — /health, /metrics, and every /workspaces/* route.
|
||||
//
|
||||
// The `reset` value is seconds until the current bucket refills,
|
||||
// matching the RFC 6585 Retry-After spec for 429 responses and the
|
||||
// de-facto X-RateLimit-Reset convention (GitHub, Stripe, etc.).
|
||||
remaining := b.tokens - 1
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
resetSeconds := int(time.Until(b.lastReset.Add(rl.interval)).Seconds())
|
||||
if resetSeconds < 0 {
|
||||
resetSeconds = 0
|
||||
}
|
||||
c.Header("X-RateLimit-Limit", strconv.Itoa(rl.rate))
|
||||
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||
c.Header("X-RateLimit-Reset", strconv.Itoa(resetSeconds))
|
||||
|
||||
if b.tokens <= 0 {
|
||||
rl.mu.Unlock()
|
||||
// Retry-After is the canonical 429 signal per RFC 6585.
|
||||
c.Header("Retry-After", strconv.Itoa(resetSeconds))
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "rate limit exceeded",
|
||||
"retry_after": rl.interval.Seconds(),
|
||||
"retry_after": resetSeconds,
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
72
platform/internal/middleware/ratelimit_test.go
Normal file
72
platform/internal/middleware/ratelimit_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newTestLimiter spins up a tiny limiter with a 2-token/5s budget so tests can
|
||||
// exhaust + recover without real-time delays.
|
||||
func newTestLimiter(t *testing.T) (*RateLimiter, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
rl := NewRateLimiter(2, 5*time.Second, ctx)
|
||||
r := gin.New()
|
||||
r.Use(rl.Middleware())
|
||||
r.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
||||
return rl, r
|
||||
}
|
||||
|
||||
// TestRateLimit_HeadersPresentOnAllowedRequest covers issue #105 — every
|
||||
// response (not just 429s) must carry the X-RateLimit-* triplet so clients
|
||||
// can back off proactively.
|
||||
func TestRateLimit_HeadersPresentOnAllowedRequest(t *testing.T) {
|
||||
_, r := newTestLimiter(t)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||
|
||||
if got := w.Header().Get("X-RateLimit-Limit"); got != "2" {
|
||||
t.Errorf("X-RateLimit-Limit = %q, want 2", got)
|
||||
}
|
||||
if got := w.Header().Get("X-RateLimit-Remaining"); got != "1" {
|
||||
t.Errorf("X-RateLimit-Remaining = %q, want 1", got)
|
||||
}
|
||||
reset, err := strconv.Atoi(w.Header().Get("X-RateLimit-Reset"))
|
||||
if err != nil || reset < 0 || reset > 5 {
|
||||
t.Errorf("X-RateLimit-Reset = %q, want 0-5", w.Header().Get("X-RateLimit-Reset"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimit_RetryAfterOn429 — throttled responses must carry Retry-After
|
||||
// per RFC 6585, so curl/fetch clients back off the exact required window.
|
||||
func TestRateLimit_RetryAfterOn429(t *testing.T) {
|
||||
_, r := newTestLimiter(t)
|
||||
// Burn through both tokens.
|
||||
for i := 0; i < 2; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: want 200, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
// Third should 429.
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("3rd request: want 429, got %d", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got == "" {
|
||||
t.Error("missing Retry-After header on 429")
|
||||
}
|
||||
if got := w.Header().Get("X-RateLimit-Remaining"); got != "0" {
|
||||
t.Errorf("X-RateLimit-Remaining = %q on 429, want 0", got)
|
||||
}
|
||||
}
|
||||
@ -472,3 +472,50 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #120 regression — unauthenticated PATCH /workspaces/:id
|
||||
//
|
||||
// Before PR #125, PATCH /workspaces/:id was registered outside the wsAdmin
|
||||
// group and did NOT enforce AdminAuth. An attacker could change workspace
|
||||
// name, tier, parent_id, runtime, or workspace_dir without any token.
|
||||
// Security Auditor confirmed the live exploit:
|
||||
// curl -X PATCH .../workspaces/00000000-.../ -d '{"name":"probe"}' → 200
|
||||
//
|
||||
// This test asserts AdminAuth applied to the PATCH route blocks unauthenticated
|
||||
// requests — the route-level fix in router.go is the enforcement point.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestAdminAuth_Issue120_PatchWorkspace_NoBearer_Returns401 documents the #120
|
||||
// attack vector and verifies that AdminAuth returns 401 for PATCH without a token.
|
||||
func TestAdminAuth_Issue120_PatchWorkspace_NoBearer_Returns401(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
|
||||
// HasAnyLiveTokenGlobal returns 1 — at least one workspace is token-enrolled.
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
r := gin.New()
|
||||
// Mirror the PR #125 router change: PATCH is inside the wsAdmin AdminAuth group.
|
||||
r.PATCH("/workspaces/:id", AdminAuth(mockDB), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
// #120 attack: no Authorization header on PATCH.
|
||||
req, _ := http.NewRequest(http.MethodPatch,
|
||||
"/workspaces/00000000-0000-0000-0000-000000000000",
|
||||
nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("#120 PATCH no-bearer: expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,6 +59,14 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// rejected requests still land on the 4xx counter.
|
||||
r.Use(middleware.TenantGuard())
|
||||
|
||||
// Security headers (#151) — sets X-Content-Type-Options, X-Frame-Options,
|
||||
// Referrer-Policy, Content-Security-Policy, Permissions-Policy, HSTS on
|
||||
// every response. Tests in securityheaders_test.go assert each header is
|
||||
// present and that handler-set headers are not overridden. Registered
|
||||
// last so a handler can still opt out by setting its own header before
|
||||
// c.Next() returns.
|
||||
r.Use(middleware.SecurityHeaders())
|
||||
|
||||
// Health
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
@ -87,23 +95,26 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// Scrape with: curl http://localhost:8080/metrics
|
||||
r.GET("/metrics", metrics.Handler())
|
||||
|
||||
// Workspace read-only endpoints accessible without an explicit workspace ID.
|
||||
// /workspaces/:id and PATCH (position-persist) remain open for the canvas
|
||||
// browser frontend which does not carry a bearer token in those calls.
|
||||
// Single-workspace read — open so canvas nodes can fetch their own state
|
||||
// without a token (used by WorkspaceNode polling and health checks).
|
||||
r.GET("/workspaces/:id", wh.Get)
|
||||
r.PATCH("/workspaces/:id", wh.Update)
|
||||
|
||||
// C1 + C20 + C18-adjacent: workspace list and mutating operations all gated
|
||||
// behind AdminAuth — any valid workspace bearer token grants access.
|
||||
// C1 + C20 + C18-adjacent + #120: workspace list and ALL mutating operations
|
||||
// gated behind AdminAuth — any valid workspace bearer token grants access.
|
||||
// Fail-open when no tokens exist anywhere (fresh install / pre-Phase-30).
|
||||
// This blocks:
|
||||
// C1 — unauthenticated GET /workspaces (workspace topology exposure)
|
||||
// C20 — unauthenticated DELETE /workspaces/:id (mass-deletion attack)
|
||||
// unauthenticated POST /workspaces (workspace creation)
|
||||
// C1 — unauthenticated GET /workspaces (workspace topology exposure)
|
||||
// C20 — unauthenticated DELETE /workspaces/:id (mass-deletion attack)
|
||||
// unauthenticated POST /workspaces (workspace creation)
|
||||
// #120 — unauthenticated PATCH /workspaces/:id (tier escalation, parent_id
|
||||
// hierarchy manipulation, runtime swap, workspace_dir path hijack)
|
||||
// NOTE: canvas position-persist (PATCH with {x,y}) uses the same AdminAuth
|
||||
// token already required for GET /workspaces list on initial load.
|
||||
{
|
||||
wsAdmin := r.Group("", middleware.AdminAuth(db.DB))
|
||||
wsAdmin.GET("/workspaces", wh.List)
|
||||
wsAdmin.POST("/workspaces", wh.Create)
|
||||
wsAdmin.PATCH("/workspaces/:id", wh.Update)
|
||||
wsAdmin.DELETE("/workspaces/:id", wh.Delete)
|
||||
}
|
||||
|
||||
|
||||
@ -119,6 +119,25 @@ func (s *Scheduler) Start(ctx context.Context) {
|
||||
s.lastTickAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
|
||||
// Independent heartbeat pulse (#140). Decoupled from tick completion so
|
||||
// a single long fire (UIUX audits routinely take 60-120s; max fireTimeout
|
||||
// is 5min) can't make /admin/liveness look stale for the whole fire window.
|
||||
// tick() also calls Heartbeat at its top + each fire goroutine calls it
|
||||
// entry/exit — those are kept as redundant signals but this pulse is the
|
||||
// one that guarantees liveness freshness regardless of tick state.
|
||||
go func() {
|
||||
pulseTicker := time.NewTicker(10 * time.Second)
|
||||
defer pulseTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-pulseTicker.C:
|
||||
supervised.Heartbeat("scheduler")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user