Merge pull request #1237 from Molecule-AI/staging
staging → main: tenant-guard allowlist registry paths
This commit is contained in:
commit
2bc4cb6357
@ -85,6 +85,11 @@ function setLocation(href: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Always reset to real timers first. If a previous polling test failed
|
||||
// before its finally-block ran, fake timers would still be active and
|
||||
// vi.useFakeTimers() in the polling tests would be a no-op — causing
|
||||
// setTimeout(0) to hang and the test to time out.
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
// Reset mock return values so each test starts fresh.
|
||||
// The mock functions (vi.fn) persist across tests; only their
|
||||
@ -95,10 +100,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure fake timers are never left active after a test — even one that
|
||||
// failed before reaching its own finally-block.
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@ -133,7 +134,13 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
|
||||
{output && (
|
||||
<button
|
||||
onClick={() => navigator.clipboard?.writeText(output)}
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(output);
|
||||
} else {
|
||||
showToast("Copy requires HTTPS — please select and copy manually", "info");
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Copy
|
||||
|
||||
@ -6,26 +6,24 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MemoryEntry {
|
||||
key: string;
|
||||
value: unknown;
|
||||
version: number;
|
||||
/** Omitted by the API when there is no TTL (Go omitempty) */
|
||||
expires_at?: string;
|
||||
updated_at: string;
|
||||
/** Memory entry returned by GET /workspaces/:id/memories */
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
content: string;
|
||||
scope: "LOCAL" | "TEAM" | "GLOBAL";
|
||||
namespace: string;
|
||||
created_at: string;
|
||||
/**
|
||||
* Semantic similarity score (0–1). Only present when the API is queried
|
||||
* with ?q=<query> and the pgvector backend has been deployed (issue #776).
|
||||
* with ?q=<query> and the pgvector backend has been deployed.
|
||||
* Absent on plain list fetches — renders gracefully without a badge.
|
||||
*/
|
||||
similarity_score?: number;
|
||||
}
|
||||
|
||||
interface WriteResult {
|
||||
status: string;
|
||||
key: string;
|
||||
version: number;
|
||||
}
|
||||
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
|
||||
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@ -34,16 +32,10 @@ interface Props {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitise a memory key for use in an HTML id attribute.
|
||||
* HTML IDs must not contain whitespace; many non-alphanumeric characters also
|
||||
* cause selector or ARIA failures. Replace every non-alphanumeric character
|
||||
* with a hyphen, collapse consecutive hyphens, then strip leading/trailing ones.
|
||||
* Sanitise a memory id for use in an HTML id attribute.
|
||||
*/
|
||||
function sanitizeId(key: string): string {
|
||||
return key
|
||||
.replace(/[^a-zA-Z0-9]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
function sanitizeId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
@ -54,7 +46,7 @@ function formatRelativeTime(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// ── Skeleton rows — shown during re-fetches when entries already exist ────────
|
||||
// ── Skeleton rows ──────────────────────────────────────────────────────────────
|
||||
|
||||
function MemorySkeletonRows() {
|
||||
return (
|
||||
@ -79,20 +71,16 @@ function MemorySkeletonRows() {
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
const [activeScope, setActiveScope] = useState<Scope>("LOCAL");
|
||||
const [activeNamespace, setActiveNamespace] = useState("");
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ── Search state ────────────────────────────────────────────────────────────
|
||||
/** Raw input value — updated on every keystroke. */
|
||||
// ── Search state (debounced) ────────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
/**
|
||||
* Debounced value — drives the API fetch.
|
||||
* Lags searchQuery by 300 ms to avoid hammering the endpoint on every key.
|
||||
*/
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
|
||||
// 300 ms debounce: cancel previous timer whenever searchQuery changes.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(
|
||||
() => setDebouncedQuery(searchQuery.trim()),
|
||||
@ -101,14 +89,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// ── Expand/edit/delete state (keyed by entry.key — primitives, no new objects)
|
||||
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pendingDeleteKey, setPendingDeleteKey] = useState<string | null>(null);
|
||||
// ── Delete state ─────────────────────────────────────────────────────────────
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
@ -116,12 +98,15 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = debouncedQuery
|
||||
? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}`
|
||||
: `/workspaces/${workspaceId}/memory`;
|
||||
const params = new URLSearchParams();
|
||||
params.set("scope", activeScope);
|
||||
if (debouncedQuery) params.set("q", debouncedQuery);
|
||||
if (activeNamespace) params.set("namespace", activeNamespace);
|
||||
|
||||
const url = `/workspaces/${workspaceId}/memories?${params.toString()}`;
|
||||
const data = await api.get<MemoryEntry[]>(url);
|
||||
|
||||
// When a semantic query is active, sort by similarity_score descending.
|
||||
// Entries without a score (older backend) fall to the end gracefully.
|
||||
const sorted = debouncedQuery
|
||||
? [...data].sort(
|
||||
(a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0)
|
||||
@ -129,123 +114,70 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
: data;
|
||||
setEntries(sorted);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load memory entries");
|
||||
setError(e instanceof Error ? e.message : "Failed to load memories");
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId, debouncedQuery]);
|
||||
}, [workspaceId, activeScope, debouncedQuery, activeNamespace]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
// ── Edit handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
const startEdit = useCallback((entry: MemoryEntry) => {
|
||||
setEditingKey(entry.key);
|
||||
setEditValue(JSON.stringify(entry.value, null, 2));
|
||||
setEditError(null);
|
||||
}, []);
|
||||
|
||||
const cancelEdit = useCallback(() => {
|
||||
setEditingKey(null);
|
||||
setEditValue("");
|
||||
setEditError(null);
|
||||
}, []);
|
||||
|
||||
const saveEdit = useCallback(
|
||||
async (entry: MemoryEntry) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(editValue);
|
||||
} catch {
|
||||
setEditError("Invalid JSON — fix the syntax before saving");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
|
||||
// Optimistic update — capture rollback snapshot before mutating
|
||||
const snapshot = entries;
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.key === entry.key
|
||||
? {
|
||||
...e,
|
||||
value: parsed,
|
||||
version: e.version + 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
: e
|
||||
)
|
||||
);
|
||||
setEditingKey(null);
|
||||
setEditValue("");
|
||||
|
||||
try {
|
||||
await api.post<WriteResult>(`/workspaces/${workspaceId}/memory`, {
|
||||
key: entry.key,
|
||||
value: parsed,
|
||||
if_match_version: entry.version,
|
||||
});
|
||||
} catch (e) {
|
||||
// Roll back optimistic update on any error
|
||||
setEntries(snapshot);
|
||||
setEditingKey(entry.key);
|
||||
setEditValue(JSON.stringify(entry.value, null, 2));
|
||||
const msg = e instanceof Error ? e.message : "Save failed";
|
||||
if (msg.includes("409") || msg.toLowerCase().includes("mismatch")) {
|
||||
setEditError(
|
||||
"Version conflict — entry changed elsewhere. Reload to see latest."
|
||||
);
|
||||
} else {
|
||||
setEditError(msg);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[entries, editValue, workspaceId]
|
||||
);
|
||||
|
||||
// ── Delete handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!pendingDeleteKey) return;
|
||||
const key = pendingDeleteKey;
|
||||
setPendingDeleteKey(null);
|
||||
if (!pendingDeleteId) return;
|
||||
const id = pendingDeleteId;
|
||||
setPendingDeleteId(null);
|
||||
|
||||
// Optimistic removal
|
||||
setEntries((prev) => prev.filter((e) => e.key !== key));
|
||||
if (expandedKey === key) setExpandedKey(null);
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
|
||||
try {
|
||||
await api.del(
|
||||
`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`
|
||||
);
|
||||
await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Delete failed — reloading...");
|
||||
await loadEntries();
|
||||
}
|
||||
}, [pendingDeleteKey, expandedKey, workspaceId, loadEntries]);
|
||||
}, [pendingDeleteId, workspaceId, loadEntries]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Full-screen loader — only on the very first fetch (no entries cached yet).
|
||||
if (loading && entries.length === 0 && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-zinc-500">Loading memory…</span>
|
||||
<span className="text-xs text-zinc-500">Loading memories…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search bar */}
|
||||
{/* Scope tabs */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((scope) => (
|
||||
<button
|
||||
key={scope}
|
||||
onClick={() => setActiveScope(scope)}
|
||||
aria-pressed={activeScope === scope}
|
||||
className={[
|
||||
"px-3 py-1 text-[11px] rounded transition-colors",
|
||||
activeScope === scope
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200",
|
||||
].join(" ")}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar + namespace filter */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0 space-y-2">
|
||||
<div className="relative flex items-center">
|
||||
{/* Magnifying glass icon */}
|
||||
<svg
|
||||
@ -264,15 +196,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search…"
|
||||
aria-label="Search memory entries"
|
||||
aria-label="Search memories"
|
||||
className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
/>
|
||||
{/* Clear button — only shown when there is a query */}
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
// Skip the debounce delay for clear — reset immediately
|
||||
setDebouncedQuery("");
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
@ -282,6 +212,22 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="namespace-filter" className="text-[10px] text-zinc-500 shrink-0">
|
||||
Namespace:
|
||||
</label>
|
||||
<input
|
||||
id="namespace-filter"
|
||||
type="text"
|
||||
value={activeNamespace}
|
||||
onChange={(e) => setActiveNamespace(e.target.value)}
|
||||
placeholder="all namespaces"
|
||||
aria-label="Filter by namespace"
|
||||
className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
@ -290,13 +236,13 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{debouncedQuery
|
||||
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
|
||||
: entries.length === 1
|
||||
? "1 entry"
|
||||
: `${entries.length} entries`}
|
||||
? "1 memory"
|
||||
: `${entries.length} memories`}
|
||||
</span>
|
||||
<button
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
||||
aria-label="Refresh memory entries"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
@ -316,11 +262,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
/* Skeleton rows — visible during search-transition re-fetches */
|
||||
<MemorySkeletonRows />
|
||||
) : entries.length === 0 ? (
|
||||
debouncedQuery ? (
|
||||
/* Search-specific empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-zinc-400">
|
||||
@ -341,56 +285,40 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Default empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-zinc-400">No memory entries yet</p>
|
||||
<p className="text-sm font-medium text-zinc-400">No {activeScope} memories</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
Memory entries will appear here when the workspace writes to its KV
|
||||
store.
|
||||
{activeScope === "LOCAL"
|
||||
? "This workspace has not written any local memories yet."
|
||||
: activeScope === "TEAM"
|
||||
? "No team memories shared with this workspace yet."
|
||||
: "No global memories exist yet."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((entry) => {
|
||||
const isExpanded = expandedKey === entry.key;
|
||||
const isEditing = editingKey === entry.key;
|
||||
return (
|
||||
<MemoryEntryRow
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
isExpanded={isExpanded}
|
||||
isEditing={isEditing}
|
||||
editValue={editValue}
|
||||
editError={editError}
|
||||
saving={saving}
|
||||
onToggle={() => {
|
||||
const next = isExpanded ? null : entry.key;
|
||||
setExpandedKey(next);
|
||||
if (!next && isEditing) cancelEdit();
|
||||
}}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEdit={() => startEdit(entry)}
|
||||
onSave={() => saveEdit(entry)}
|
||||
onCancelEdit={cancelEdit}
|
||||
onDelete={() => setPendingDeleteKey(entry.key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{entries.map((entry) => (
|
||||
<MemoryEntryRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onDelete={() => setPendingDeleteId(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteKey !== null}
|
||||
title="Delete memory entry"
|
||||
message={`Delete key "${pendingDeleteKey}"? This cannot be undone.`}
|
||||
open={pendingDeleteId !== null}
|
||||
title="Delete memory"
|
||||
message={`Delete this ${activeScope} memory? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDeleteKey(null)}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@ -400,155 +328,97 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
|
||||
interface MemoryEntryRowProps {
|
||||
entry: MemoryEntry;
|
||||
isExpanded: boolean;
|
||||
isEditing: boolean;
|
||||
editValue: string;
|
||||
editError: string | null;
|
||||
saving: boolean;
|
||||
onToggle: () => void;
|
||||
onEditValueChange: (v: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function MemoryEntryRow({
|
||||
entry,
|
||||
isExpanded,
|
||||
isEditing,
|
||||
editValue,
|
||||
editError,
|
||||
saving,
|
||||
onToggle,
|
||||
onEditValueChange,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancelEdit,
|
||||
onDelete,
|
||||
}: MemoryEntryRowProps) {
|
||||
const bodyId = `mem-body-${sanitizeId(entry.key)}`;
|
||||
function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||
{/* Header row — click to expand/collapse */}
|
||||
{/* Header row */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-blue-400 truncate flex-1 min-w-0">
|
||||
{entry.key}
|
||||
{/* Scope badge */}
|
||||
<span
|
||||
className={[
|
||||
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
|
||||
entry.scope === "LOCAL"
|
||||
? "bg-zinc-700 text-zinc-400"
|
||||
: entry.scope === "TEAM"
|
||||
? "bg-blue-950 text-blue-400"
|
||||
: "bg-violet-950 text-violet-400",
|
||||
].join(" ")}
|
||||
title={`Scope: ${entry.scope}`}
|
||||
>
|
||||
{entry.scope[0]}
|
||||
</span>
|
||||
<span className="text-[9px] text-zinc-600 shrink-0 font-mono">
|
||||
v{entry.version}
|
||||
|
||||
{/* Namespace tag */}
|
||||
<span className="text-[9px] shrink-0 font-mono text-zinc-500 truncate max-w-[80px]" title={entry.namespace}>
|
||||
{entry.namespace}
|
||||
</span>
|
||||
{/* Similarity score badge — only rendered when backend provides a score */}
|
||||
|
||||
{/* Content preview */}
|
||||
<span className="flex-1 min-w-0 text-[10px] font-mono text-zinc-300 truncate text-left">
|
||||
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
|
||||
</span>
|
||||
|
||||
{/* Similarity badge */}
|
||||
{entry.similarity_score != null && (
|
||||
<span
|
||||
className={[
|
||||
"text-[9px] shrink-0 font-mono tabular-nums",
|
||||
entry.similarity_score >= 0.8
|
||||
? "text-blue-500"
|
||||
: entry.similarity_score >= 0.5
|
||||
? "text-zinc-400"
|
||||
: "text-zinc-400 italic",
|
||||
: "text-zinc-400",
|
||||
].join(" ")}
|
||||
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
|
||||
data-testid="similarity-badge"
|
||||
>
|
||||
{entry.similarity_score < 0.5 ? "~" : ""}{Math.round(entry.similarity_score * 100)}%
|
||||
{Math.round(entry.similarity_score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-[9px] text-zinc-600 shrink-0">
|
||||
{formatRelativeTime(entry.updated_at)}
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
|
||||
{isExpanded ? "▼" : "▶"}
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded body */}
|
||||
{isExpanded && (
|
||||
{expanded && (
|
||||
<div
|
||||
id={bodyId}
|
||||
role="region"
|
||||
aria-label={`Details for ${entry.key}`}
|
||||
aria-label="Memory details"
|
||||
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
|
||||
>
|
||||
{entry.expires_at && (
|
||||
<p className="text-[9px] text-zinc-500">
|
||||
Expires: {new Date(entry.expires_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => onEditValueChange(e.target.value)}
|
||||
rows={6}
|
||||
aria-label="Edit memory value"
|
||||
className="w-full bg-zinc-950 border border-zinc-700 focus:border-blue-500 rounded px-2 py-1.5 text-[11px] font-mono text-zinc-100 focus:outline-none resize-none transition-colors"
|
||||
/>
|
||||
{editError && (
|
||||
<p role="alert" aria-live="assertive" className="text-[10px] text-red-400">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-xs rounded text-white transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 text-xs rounded text-zinc-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Read mode */
|
||||
<div className="space-y-2">
|
||||
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(entry.value, null, 2)}
|
||||
</pre>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
Updated: {new Date(entry.updated_at).toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartEdit();
|
||||
}}
|
||||
aria-label={`Edit ${entry.key}`}
|
||||
className="text-[10px] px-2 py-0.5 bg-zinc-700 hover:bg-zinc-600 rounded text-zinc-300 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label={`Delete ${entry.key}`}
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
|
||||
{entry.content}
|
||||
</pre>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Delete memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -8,10 +8,10 @@ export interface WorkspaceUsageProps {
|
||||
}
|
||||
|
||||
interface WorkspaceMetrics {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_calls: number;
|
||||
estimated_cost_usd: string;
|
||||
input_tokens?: number; // optional — provisioning-stuck workspaces return partial shapes
|
||||
output_tokens?: number; // optional — same
|
||||
total_calls?: number;
|
||||
estimated_cost_usd?: string; // optional — same
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* MemoryInspectorPanel tests — issue #730
|
||||
* MemoryInspectorPanel tests — issue #909
|
||||
*
|
||||
* Covers: loading, empty state, entry list, expand, edit flow (happy path,
|
||||
* invalid JSON, cancel), delete flow (confirm, cancel), optimistic updates,
|
||||
* and Refresh.
|
||||
* Covers: loading, empty state, scope tabs, namespace filter,
|
||||
* entry list, expand, delete flow, optimistic updates, Refresh, semantic search.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
|
||||
// ── Mocks (must be hoisted before any imports) ────────────────────────────────
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
@ -19,9 +18,6 @@ vi.mock("@/lib/api", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// ConfirmDialog uses createPortal + a `mounted` state guard that requires
|
||||
// useEffect to fire. We mock it to a simple inline rendering so tests are
|
||||
// synchronous and don't depend on document.body portal availability.
|
||||
vi.mock("@/components/ConfirmDialog", () => ({
|
||||
ConfirmDialog: ({
|
||||
open,
|
||||
@ -49,38 +45,37 @@ vi.mock("@/components/ConfirmDialog", () => ({
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { MemoryInspectorPanel } from "../MemoryInspectorPanel";
|
||||
|
||||
// ── Typed mock helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const mockGet = vi.mocked(api.get);
|
||||
const mockPost = vi.mocked(api.post);
|
||||
const mockDel = vi.mocked(api.del);
|
||||
|
||||
// ── Sample fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
const NOW = new Date("2026-04-17T12:00:00.000Z").toISOString();
|
||||
const LATER = new Date("2026-04-17T13:00:00.000Z").toISOString();
|
||||
const NOW = "2026-04-17T12:00:00.000Z";
|
||||
|
||||
const ENTRY_A = {
|
||||
key: "task-queue",
|
||||
value: { pending: ["t-1", "t-2"], done: [] },
|
||||
version: 3,
|
||||
updated_at: NOW,
|
||||
const MEMORY_A: import("../MemoryInspectorPanel").MemoryEntry = {
|
||||
id: "mem-a",
|
||||
workspace_id: "ws-1",
|
||||
content: "Remember to review PRs before merging",
|
||||
scope: "LOCAL",
|
||||
namespace: "general",
|
||||
created_at: NOW,
|
||||
};
|
||||
|
||||
const ENTRY_B = {
|
||||
key: "session-token",
|
||||
value: "abc123",
|
||||
version: 1,
|
||||
expires_at: LATER,
|
||||
updated_at: NOW,
|
||||
const MEMORY_B: import("../MemoryInspectorPanel").MemoryEntry = {
|
||||
id: "mem-b",
|
||||
workspace_id: "ws-1",
|
||||
content: "Team knowledge: deploy happens on Fridays",
|
||||
scope: "TEAM",
|
||||
namespace: "procedures",
|
||||
created_at: NOW,
|
||||
};
|
||||
|
||||
const TWO_ENTRIES = [ENTRY_A, ENTRY_B];
|
||||
const TWO_MEMORIES = [MEMORY_A, MEMORY_B];
|
||||
|
||||
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
@ -92,82 +87,177 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Helper: flush microtasks + React state updates ─────────────────────────────
|
||||
async function flushUpdates(): Promise<void> {
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
// ── Loading & empty state ─────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — loading and empty state", () => {
|
||||
it("shows loading indicator before data arrives", () => {
|
||||
// Never resolves within this test — just checks the loading UI
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockReturnValue(new Promise(() => {}) as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
expect(screen.getByText(/loading memory/i)).toBeTruthy();
|
||||
expect(screen.getByText(/loading memories/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders empty state when API returns []", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No memory entries yet")).toBeTruthy()
|
||||
);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fetches from the correct workspace memory endpoint", async () => {
|
||||
it("fetches from the correct workspace memories endpoint with scope=LOCAL", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-abc-123" />);
|
||||
await waitFor(() =>
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-abc-123/memory")
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-abc-123/memories?scope=LOCAL"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows error banner when fetch throws", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"));
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("Network error")).toBeTruthy()
|
||||
);
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("Network error")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Entry list ────────────────────────────────────────────────────────────────
|
||||
// ── Scope tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — scope tabs", () => {
|
||||
it("renders LOCAL, TEAM, GLOBAL tabs", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByRole("button", { name: "LOCAL" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "TEAM" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "GLOBAL" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("LOCAL is active by default", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByRole("button", { name: "LOCAL" }).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicking TEAM tab re-fetches with scope=TEAM", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=TEAM"
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking GLOBAL tab re-fetches with scope=GLOBAL", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: "GLOBAL" }));
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=GLOBAL"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows scope-specific empty state when switching tabs", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "TEAM" }));
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("No TEAM memories")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Namespace filter ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — namespace filter", () => {
|
||||
it("renders namespace filter input", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
expect(screen.getByLabelText("Filter by namespace")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes namespace param in API call when set", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
fireEvent.change(screen.getByLabelText("Filter by namespace"), {
|
||||
target: { value: "facts" },
|
||||
});
|
||||
// Advance past the 300ms debounce
|
||||
act(() => { vi.advanceTimersByTime(350); });
|
||||
await flushUpdates();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL&namespace=facts"
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Entry list ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — entry list", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_ENTRIES as any);
|
||||
mockGet.mockResolvedValue(TWO_MEMORIES as any);
|
||||
});
|
||||
|
||||
it("renders a row for every entry key", async () => {
|
||||
it("renders a row for every memory", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
expect(screen.getByText("session-token")).toBeTruthy();
|
||||
await flushUpdates();
|
||||
expect(screen.getByText(/Remember to review PRs before merging/)).toBeTruthy();
|
||||
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays '2 entries' count in the toolbar", async () => {
|
||||
it("displays memory count in toolbar", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => expect(screen.getByText("2 entries")).toBeTruthy());
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("2 memories")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays '1 entry' (singular) when there is one entry", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([ENTRY_A] as any);
|
||||
it("displays scope badge for each entry", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => expect(screen.getByText("1 entry")).toBeTruthy());
|
||||
await flushUpdates();
|
||||
expect(screen.getByTitle("Scope: LOCAL")).toBeTruthy();
|
||||
expect(screen.getByTitle("Scope: TEAM")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows version badge for each entry", async () => {
|
||||
it("entries are collapsed by default (pre region not visible)", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
expect(screen.getByText("v3")).toBeTruthy();
|
||||
expect(screen.getByText("v1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("entries are collapsed by default (value not visible)", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
// The JSON value should NOT be rendered while collapsed
|
||||
expect(screen.queryByText(/"pending"/)).toBeNull();
|
||||
await flushUpdates();
|
||||
// Expanded region (pre tag) should not exist in DOM yet
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,144 +266,36 @@ describe("MemoryInspectorPanel — entry list", () => {
|
||||
describe("MemoryInspectorPanel — expand/collapse", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_ENTRIES as any);
|
||||
mockGet.mockResolvedValue(TWO_MEMORIES as any);
|
||||
});
|
||||
|
||||
it("clicking a row header expands it and shows the JSON value", async () => {
|
||||
it("clicking a row header expands it and shows the full content in a pre tag", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
await flushUpdates();
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(
|
||||
screen.getByText("task-queue").closest("button")!
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/"pending"/)).toBeTruthy()
|
||||
screen.getByText(/Remember to review PRs before merging/).closest("button")!
|
||||
);
|
||||
await flushUpdates();
|
||||
// After expand, a region with the full content <pre> should appear
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking the header again collapses the row", async () => {
|
||||
it("clicking the header again collapses the row (pre region removed)", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
await flushUpdates();
|
||||
|
||||
const headerBtn = screen.getByText("task-queue").closest("button")!;
|
||||
const headerBtn = screen
|
||||
.getByText(/Remember to review PRs before merging/)
|
||||
.closest("button")!;
|
||||
fireEvent.click(headerBtn); // expand
|
||||
await waitFor(() => screen.getByText(/"pending"/));
|
||||
await flushUpdates();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
|
||||
fireEvent.click(headerBtn); // collapse
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/"pending"/)).toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
it("shows expires_at when present", async () => {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("session-token"));
|
||||
fireEvent.click(
|
||||
screen.getByText("session-token").closest("button")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/expires/i)).toBeTruthy()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit flow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — edit flow", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_ENTRIES as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockPost.mockResolvedValue({ status: "ok", key: "task-queue", version: 4 } as any);
|
||||
});
|
||||
|
||||
/** Helper: expand entry-A and click its Edit button */
|
||||
async function openEditForEntryA() {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
fireEvent.click(screen.getByText("task-queue").closest("button")!);
|
||||
await waitFor(() =>
|
||||
screen.getByRole("button", { name: "Edit task-queue" })
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
|
||||
}
|
||||
|
||||
it("shows a textarea pre-filled with the entry value after clicking Edit", async () => {
|
||||
await openEditForEntryA();
|
||||
const ta = screen.getByRole("textbox", { name: "Edit memory value" });
|
||||
expect(ta).toBeTruthy();
|
||||
expect((ta as HTMLTextAreaElement).value).toContain("pending");
|
||||
});
|
||||
|
||||
it("shows Save and Cancel buttons in edit mode", async () => {
|
||||
await openEditForEntryA();
|
||||
expect(screen.getByRole("button", { name: /^save$/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /^cancel$/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("POSTs to correct path with key, parsed value, and if_match_version", async () => {
|
||||
await openEditForEntryA();
|
||||
fireEvent.change(
|
||||
screen.getByRole("textbox", { name: "Edit memory value" }),
|
||||
{ target: { value: '{"updated":true}' } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
|
||||
const [path, body] = mockPost.mock.calls[0] as [
|
||||
string,
|
||||
{ key: string; value: unknown; if_match_version: number }
|
||||
];
|
||||
expect(path).toBe("/workspaces/ws-1/memory");
|
||||
expect(body.key).toBe("task-queue");
|
||||
expect(body.if_match_version).toBe(3); // ENTRY_A.version
|
||||
expect(body.value).toEqual({ updated: true });
|
||||
});
|
||||
|
||||
it("closes the edit form on successful save", async () => {
|
||||
await openEditForEntryA();
|
||||
fireEvent.change(
|
||||
screen.getByRole("textbox", { name: "Edit memory value" }),
|
||||
{ target: { value: '"new-value"' } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("textbox", { name: "Edit memory value" })
|
||||
).toBeNull()
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an inline error (no API call) for syntactically invalid JSON", async () => {
|
||||
await openEditForEntryA();
|
||||
fireEvent.change(
|
||||
screen.getByRole("textbox", { name: "Edit memory value" }),
|
||||
{ target: { value: "{{bad json" } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
// Error message appears, textarea stays open, api.post NOT called
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/invalid json/i)).toBeTruthy()
|
||||
);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("textbox", { name: "Edit memory value" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Cancel closes the edit form without calling api.post", async () => {
|
||||
await openEditForEntryA();
|
||||
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole("textbox", { name: "Edit memory value" })
|
||||
).toBeNull()
|
||||
);
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
await flushUpdates();
|
||||
// After collapse, the region (pre) is removed from the DOM
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -322,271 +304,164 @@ describe("MemoryInspectorPanel — edit flow", () => {
|
||||
describe("MemoryInspectorPanel — delete flow", () => {
|
||||
beforeEach(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_ENTRIES as any);
|
||||
mockGet.mockResolvedValue(TWO_MEMORIES as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockDel.mockResolvedValue({ status: "deleted" } as any);
|
||||
});
|
||||
|
||||
/** Helper: expand entry-A and click its Delete button */
|
||||
async function openDeleteForEntryA() {
|
||||
/** Helper: expand memory-A and click its Delete button */
|
||||
async function openDeleteForMemoryA() {
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
fireEvent.click(screen.getByText("task-queue").closest("button")!);
|
||||
await waitFor(() =>
|
||||
screen.getByRole("button", { name: "Delete task-queue" })
|
||||
await flushUpdates();
|
||||
fireEvent.click(
|
||||
screen.getByText(/Remember to review PRs before merging/).closest("button")!
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete task-queue" }));
|
||||
await flushUpdates();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete memory" }));
|
||||
await flushUpdates();
|
||||
}
|
||||
|
||||
it("opens the ConfirmDialog when Delete is clicked", async () => {
|
||||
await openDeleteForEntryA();
|
||||
it("opens ConfirmDialog when Delete is clicked", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
expect(screen.getByTestId("confirm-dialog")).toBeTruthy();
|
||||
expect(screen.getByTestId("dialog-title").textContent).toBe(
|
||||
"Delete memory entry"
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the key in the dialog message", async () => {
|
||||
await openDeleteForEntryA();
|
||||
expect(screen.getByTestId("dialog-message").textContent).toContain(
|
||||
"task-queue"
|
||||
);
|
||||
expect(screen.getByTestId("dialog-title").textContent).toBe("Delete memory");
|
||||
});
|
||||
|
||||
it("calls api.del with the correct URL-encoded path on confirm", async () => {
|
||||
await openDeleteForEntryA();
|
||||
await openDeleteForMemoryA();
|
||||
fireEvent.click(screen.getByText("Confirm Delete"));
|
||||
await waitFor(() =>
|
||||
expect(mockDel).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory/task-queue"
|
||||
)
|
||||
);
|
||||
await flushUpdates();
|
||||
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1/memories/mem-a");
|
||||
});
|
||||
|
||||
it("removes the entry from the list optimistically after confirm", async () => {
|
||||
await openDeleteForEntryA();
|
||||
it("removes the entry optimistically after confirm", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
fireEvent.click(screen.getByText("Confirm Delete"));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("task-queue")).toBeNull()
|
||||
);
|
||||
await flushUpdates();
|
||||
expect(screen.queryByText(/Remember to review PRs before merging/)).toBeNull();
|
||||
// Sibling entry unaffected
|
||||
expect(screen.getByText("session-token")).toBeTruthy();
|
||||
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the ConfirmDialog without deleting when Cancel is clicked", async () => {
|
||||
await openDeleteForEntryA();
|
||||
it("closes ConfirmDialog without deleting when Cancel is clicked", async () => {
|
||||
await openDeleteForMemoryA();
|
||||
fireEvent.click(screen.getByText("Cancel Delete"));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("confirm-dialog")).toBeNull()
|
||||
);
|
||||
await flushUpdates();
|
||||
expect(screen.queryByTestId("confirm-dialog")).toBeNull();
|
||||
expect(mockDel).not.toHaveBeenCalled();
|
||||
// Entry still present
|
||||
expect(screen.getByText("task-queue")).toBeTruthy();
|
||||
// Sibling memory entry (MEMORY_B) is still in the list
|
||||
expect(screen.getByText(/Team knowledge: deploy happens on Fridays/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — Refresh button", () => {
|
||||
it("re-fetches entries when the Refresh button is clicked", async () => {
|
||||
it("re-fetches entries when Refresh is clicked", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("No memory entries yet"));
|
||||
await flushUpdates();
|
||||
expect(screen.getByText("No LOCAL memories")).toBeTruthy();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh memory entries" }));
|
||||
await waitFor(() => expect(mockGet).toHaveBeenCalledTimes(2));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh memories" }));
|
||||
await flushUpdates();
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── role=alert a11y (issue #830) ─────────────────────────────────────────────
|
||||
// ── role=alert a11y ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — error elements have role=alert (issue #830)", () => {
|
||||
describe("MemoryInspectorPanel — error elements have role=alert", () => {
|
||||
it("fetch error banner has role='alert'", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"));
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("Network error"));
|
||||
await flushUpdates();
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert.textContent).toContain("Network error");
|
||||
});
|
||||
|
||||
it("editError paragraph has role='alert' on invalid JSON submission", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(TWO_ENTRIES as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
|
||||
// Expand and open edit mode
|
||||
fireEvent.click(screen.getByText("task-queue").closest("button")!);
|
||||
await waitFor(() =>
|
||||
screen.getByRole("button", { name: "Edit task-queue" })
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit task-queue" }));
|
||||
|
||||
// Submit invalid JSON to trigger editError
|
||||
fireEvent.change(
|
||||
screen.getByRole("textbox", { name: "Edit memory value" }),
|
||||
{ target: { value: "{{bad json" } }
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => screen.getByText(/invalid json/i));
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert.textContent).toMatch(/invalid json/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Semantic search (issue #783) ──────────────────────────────────────────────
|
||||
// ── Semantic search ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("MemoryInspectorPanel — semantic search", () => {
|
||||
// Ensure fake timers never leak into the next test even if a test throws
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not call API before 300ms debounce elapses after typing", async () => {
|
||||
it("debounces search input by 300ms before calling API", async () => {
|
||||
vi.useFakeTimers();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
|
||||
// Flush initial load — api.get returns an already-resolved Promise
|
||||
// (microtask), so act() drains it without advancing fake timers
|
||||
await act(async () => {});
|
||||
await flushUpdates();
|
||||
|
||||
mockGet.mockClear();
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByLabelText("Search memory entries"), {
|
||||
target: { value: "task queue" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Search memories"), {
|
||||
target: { value: "deploy" },
|
||||
});
|
||||
|
||||
// 200ms elapsed — debounce has NOT fired yet
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
// 200ms — debounce has NOT fired yet
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
await flushUpdates();
|
||||
expect(mockGet).not.toHaveBeenCalled();
|
||||
|
||||
// Another 150ms (total 350ms > 300ms threshold) — debounce fires
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
// Flush the async loadEntries that was triggered
|
||||
await act(async () => {});
|
||||
// 350ms total — debounce fires
|
||||
act(() => { vi.advanceTimersByTime(150); });
|
||||
await flushUpdates();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memory?q=task%20queue"
|
||||
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders similarity-badge with rounded percentage when entry has similarity_score", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ ...ENTRY_A, similarity_score: 0.87 },
|
||||
] as any);
|
||||
it("renders similarity-badge when entry has similarity_score", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([{ ...MEMORY_A, similarity_score: 0.87 }] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
|
||||
// Wait for the entry key to appear in the header
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
|
||||
await flushUpdates();
|
||||
const badge = document.querySelector('[data-testid="similarity-badge"]');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge?.textContent).toBe("87%");
|
||||
});
|
||||
|
||||
it("does not render similarity-badge when entry has no similarity_score", async () => {
|
||||
// ENTRY_A has no similarity_score field
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([ENTRY_A] as any);
|
||||
mockGet.mockResolvedValue([MEMORY_A] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
|
||||
await flushUpdates();
|
||||
expect(
|
||||
document.querySelector('[data-testid="similarity-badge"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("colors similarity-badge blue-500 when score >= 0.8", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ ...ENTRY_A, similarity_score: 0.92 },
|
||||
] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
const badge = document.querySelector('[data-testid="similarity-badge"]');
|
||||
expect(badge?.className).toContain("text-blue-500");
|
||||
expect(badge?.className).not.toContain("text-zinc-400");
|
||||
expect(badge?.className).not.toContain("text-zinc-600");
|
||||
});
|
||||
|
||||
it("colors similarity-badge zinc-400 when score is between 0.5 and 0.8", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ ...ENTRY_A, similarity_score: 0.65 },
|
||||
] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
const badge = document.querySelector('[data-testid="similarity-badge"]');
|
||||
expect(badge?.className).toContain("text-zinc-400");
|
||||
expect(badge?.className).not.toContain("text-blue-500");
|
||||
expect(badge?.className).not.toContain("text-zinc-600");
|
||||
});
|
||||
|
||||
it("colors similarity-badge zinc-400 italic with tilde prefix when score is below 0.5", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ ...ENTRY_A, similarity_score: 0.31 },
|
||||
] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("task-queue"));
|
||||
const badge = document.querySelector('[data-testid="similarity-badge"]');
|
||||
expect(badge?.className).toContain("text-zinc-400");
|
||||
expect(badge?.className).toContain("italic");
|
||||
expect(badge?.className).not.toContain("text-blue-500");
|
||||
expect(badge?.className).not.toContain("text-zinc-600");
|
||||
expect(badge?.textContent).toBe("~31%");
|
||||
});
|
||||
|
||||
it("clear button resets debouncedQuery immediately and re-fetches without ?q=", async () => {
|
||||
it("clear button resets query immediately and re-fetches without ?q=", async () => {
|
||||
vi.useFakeTimers();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue([] as any);
|
||||
render(<MemoryInspectorPanel workspaceId="ws-1" />);
|
||||
await flushUpdates();
|
||||
|
||||
// Flush initial load
|
||||
await act(async () => {});
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByLabelText("Search memory entries"), {
|
||||
target: { value: "sessions" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Search memories"), {
|
||||
target: { value: "deploy" },
|
||||
});
|
||||
|
||||
// Advance past debounce — debouncedQuery becomes "sessions"
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
await act(async () => {}); // flush async loadEntries
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory?q=sessions");
|
||||
act(() => { vi.advanceTimersByTime(350); });
|
||||
await flushUpdates();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL&q=deploy"
|
||||
);
|
||||
mockGet.mockClear();
|
||||
|
||||
// Click × clear button — skips debounce, resets debouncedQuery immediately
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
});
|
||||
await act(async () => {}); // flush state update → loadEntries → api.get
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
|
||||
await flushUpdates();
|
||||
|
||||
// Should re-fetch the unfiltered list (no q= parameter)
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/memory");
|
||||
|
||||
vi.useRealTimers();
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/memories?scope=LOCAL"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,7 +9,7 @@ import { api } from "@/lib/api";
|
||||
|
||||
interface BudgetData {
|
||||
budget_limit: number | null;
|
||||
budget_used: number;
|
||||
budget_used?: number; // optional — provisioning-stuck workspaces return partial shapes
|
||||
budget_remaining: number | null;
|
||||
}
|
||||
|
||||
|
||||
@ -270,27 +270,29 @@ Compare this to n8n workflows: a human manually wires together a sequence of bro
|
||||
|
||||
## Getting Started with Molecule AI
|
||||
|
||||
Molecule AI workspaces expose browser tools via the MCP protocol — no Puppeteer, no Selenium fleet, no per-session SaaS bill. The browser runs as a managed MCP session inside your workspace. You describe what you want in plain language; the agent drives the browser.
|
||||
To use browser automation in a Molecule AI workspace, you connect your own MCP server (such as the `ChromeDevToolsMCP` shown above) using Molecule AI's built-in MCP tool registration. The platform handles the WebSocket lifecycle and tool call routing — you bring the browser logic.
|
||||
|
||||
To enable browser tools in a Molecule AI workspace, add them to your workspace configuration:
|
||||
**Configure the MCP server URL in your workspace:**
|
||||
|
||||
```yaml
|
||||
# workspace-config.yaml
|
||||
mcp:
|
||||
tools:
|
||||
- browser_navigate
|
||||
- dom_query
|
||||
- page_screenshot
|
||||
- network_intercept
|
||||
session:
|
||||
persistent: true # maintain cookies + localStorage across calls
|
||||
headless: true # or false to see the browser window
|
||||
debugging_port: 9222 # auto-assigned in Molecule AI cloud
|
||||
```bash
|
||||
# Set your browser MCP server endpoint via the platform API
|
||||
curl -X PATCH "${PLATFORM_URL}/workspaces/${WORKSPACE_ID}/config" \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
|
||||
-d '{
|
||||
"mcp_servers": {
|
||||
"browser": {
|
||||
"type": "streamable_http",
|
||||
"url": "http://localhost:9223/mcp"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Three lines. No WebSocket management, no CDP command dispatch to write. The agent has a live browser session the moment the workspace starts.
|
||||
Or use the Canvas UI: Workspace → Config → MCP Servers → Add browser MCP server.
|
||||
|
||||
Compare that to wiring Playwright into LangChain: you write async wrapper functions, handle `page.goto()` timeouts in the prompt, and debug failures by reading through decorator-stacked chain outputs. With Molecule AI and MCP, the browser is a first-class tool — typed, session-aware, and ready to use.
|
||||
**What Molecule AI provides:** WebSocket routing, tool call auth, session lifecycle, and the A2A bridge so your agent sees browser tools as native workspace tools. You bring the CDP bridge (or use the `ChromeDevToolsMCP` example above).
|
||||
|
||||
**Compare that to wiring Playwright into LangChain:** you write async wrapper functions, handle `page.goto()` timeouts in the prompt, and debug failures by reading through decorator-stacked chain outputs. With Molecule AI and MCP, the browser is a first-class tool — typed, session-aware, and registered the same way as any other MCP tool.
|
||||
|
||||
→ [MCP Server Setup Guide](/docs/guides/mcp-server-setup)
|
||||
→ [Quickstart: Deploy your first AI agent](/docs/quickstart)
|
||||
|
||||
153
docs/blog/2026-04-21-cloudflare-artifacts/index.md
Normal file
153
docs/blog/2026-04-21-cloudflare-artifacts/index.md
Normal file
@ -0,0 +1,153 @@
|
||||
---
|
||||
title: "Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts"
|
||||
date: 2026-04-21
|
||||
slug: cloudflare-artifacts-molecule-ai
|
||||
description: "Attach a Cloudflare Artifacts git repository to any Molecule AI workspace. Import existing repos, fork for experiments, mint short-lived git credentials — all via the platform API. Git-native storage for AI agents."
|
||||
tags: [Cloudflare, git, artifacts, AI-agents, workflow, tutorial]
|
||||
---
|
||||
|
||||
# Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts
|
||||
|
||||
AI agents write code, generate assets, and produce artifacts. Most of the time, those artifacts live in memory — gone when the session ends. Even persistent agents have to choose between "keep everything in context" (expensive and slow) and "discard everything" (loses the work).
|
||||
|
||||
Cloudflare Artifacts changes this. Artifacts is Cloudflare's git-native object storage — git pull and git push semantics, backed by Cloudflare's global network. Think of it as a workspace filesystem that lives on the edge, is versioned by default, and talks git natively.
|
||||
|
||||
Molecule AI's Artifacts integration attaches a Cloudflare Artifacts repository to any workspace. Your agent gets a git URL. It clones, commits, pushes, and pulls — using the same git workflow your team already knows.
|
||||
|
||||
This post covers what the integration does, how to configure it, and what you can build with it.
|
||||
|
||||
## Why Git-Native Storage for AI Agents
|
||||
|
||||
Most AI agent outputs — code drafts, generated configs, export files, test datasets — are transient. They live in the agent's working memory and evaporate when the session ends. Teams that want durable artifacts usually bolt on object storage (S3), a database, or a file share. All of those introduce a new API surface, new authentication scheme, and a new workflow.
|
||||
|
||||
Git-native storage is different because:
|
||||
|
||||
- **Agents already know git.** Clone, branch, commit, push. No new primitives to learn.
|
||||
- **Versioning is structural.** Every change is a commit. Rollback is `git revert`. No "last writer wins" data loss.
|
||||
- **Collaboration is native.** Fork a repo, experiment, open a PR. The same workflow humans use to collaborate applies to agents.
|
||||
- **Cloudflare Artifacts is fast.** Git operations run on Cloudflare's edge — sub-100ms clone times from anywhere. No S3 bandwidth bills.
|
||||
- **Access control is git-native.** Token scoping, branch protection, repo-level permissions. The same model your team already uses.
|
||||
|
||||
## API Reference
|
||||
|
||||
The integration exposes four endpoints, all behind workspace authentication:
|
||||
|
||||
### Attach a repository
|
||||
|
||||
```bash
|
||||
# Create a new empty Artifacts repo linked to this workspace
|
||||
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "pm-workspace-files",
|
||||
"description": "PM workspace — weekly reports and briefs"
|
||||
}'
|
||||
```
|
||||
|
||||
```bash
|
||||
# Or import from an existing Git URL (GitHub, GitLab, etc.)
|
||||
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
|
||||
-d '{
|
||||
"import_url": "https://github.com/acme/sprint-reports.git",
|
||||
"import_branch": "main",
|
||||
"import_depth": 0
|
||||
}'
|
||||
```
|
||||
|
||||
### Get linked repository info
|
||||
|
||||
```bash
|
||||
curl https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}"
|
||||
```
|
||||
|
||||
Returns the repo name, Cloudflare namespace, git remote URL, and creation timestamp.
|
||||
|
||||
### Fork the repository
|
||||
|
||||
```bash
|
||||
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts/fork \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
|
||||
-d '{"name": "pm-workspace-files-experiment"}'
|
||||
```
|
||||
|
||||
Creates a new Cloudflare Artifacts repo as a fork of the workspace's current repo. Useful when the agent wants to experiment without touching the canonical version.
|
||||
|
||||
### Mint a short-lived git credential
|
||||
|
||||
```bash
|
||||
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts/token \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}"
|
||||
```
|
||||
|
||||
Returns a temporary git credential (username + password/token) scoped to this workspace's repo. Credentials expire automatically — no long-lived tokens to manage or revoke.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### The agent that maintains its own documentation
|
||||
|
||||
A research agent that reads papers, summarizes findings, and writes notes. Without Artifacts, the notes disappear when the session ends. With Artifacts:
|
||||
|
||||
1. Agent clones the research repo on first run
|
||||
2. Each session: pull latest, add summaries, commit and push
|
||||
3. Next agent (or the same one, next day): clone and continue from where the last session left off
|
||||
|
||||
```bash
|
||||
git clone https://repo.cf-articles.pages.dev/pm-research.git
|
||||
# agent work...
|
||||
git add -A && git commit -m "week 12 research summary" && git push
|
||||
```
|
||||
|
||||
### Fork-before-experiment
|
||||
|
||||
A code-review agent that wants to test proposed changes before recommending them:
|
||||
|
||||
1. Fork the canonical repo to a temporary workspace
|
||||
2. Apply the suggested patches
|
||||
3. Run tests
|
||||
4. Report results
|
||||
5. Archive or discard the fork
|
||||
|
||||
The fork is a first-class API call — no manual git-fork workflow to script.
|
||||
|
||||
### Shared asset library for multi-agent teams
|
||||
|
||||
A design-team workspace maintains a shared palette of brand assets. Each agent in the team clones the Artifacts repo, uses the assets, and contributes updates. Because Artifacts is git-native, the history of asset changes is always visible — who changed what, when, and why.
|
||||
|
||||
## Security
|
||||
|
||||
The integration has two built-in security properties worth noting:
|
||||
|
||||
**SSRF protection on import.** Import URLs must use `https://`. The handler rejects `git://`, `http://`, or any other scheme at the router level before the URL is passed to the Cloudflare API. A request with `import_url: "http://internal.corp/repo"` returns a 400 immediately.
|
||||
|
||||
**Credential stripping on storage.** When Cloudflare creates a repo, it embeds a write credential in the git remote URL. Before persisting the remote URL to the database, Molecule AI strips that credential. The DB stores the credential-free URL; the agent fetches a fresh short-lived token via the `/artifacts/token` endpoint on demand. Credentials are never stored long-term.
|
||||
|
||||
**Graceful unavailability.** If `CF_ARTIFACTS_API_TOKEN` or `CF_ARTIFACTS_NAMESPACE` are not configured, every Artifacts endpoint returns a 503 with a clear message: `"Cloudflare Artifacts not configured — set CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE"`. No silent failures or confusing empty responses.
|
||||
|
||||
## Getting Started
|
||||
|
||||
To use Artifacts in a self-hosted Molecule AI deployment, set two environment variables on your platform instance:
|
||||
|
||||
```bash
|
||||
CF_ARTIFACTS_API_TOKEN=your_cloudflare_api_token_with_artifacts_write
|
||||
CF_ARTIFACTS_NAMESPACE=your_cloudflare_artifacts_namespace
|
||||
```
|
||||
|
||||
Then create or import a repo via the API:
|
||||
|
||||
```bash
|
||||
curl -X POST https://platform.moleculesai.app/workspaces/${WORKSPACE_ID}/artifacts \
|
||||
-H "Authorization: Bearer ${WORKSPACE_TOKEN}" \
|
||||
-d '{"name": "my-workspace-repo"}'
|
||||
```
|
||||
|
||||
The response includes the git remote URL. Your agent can clone it immediately.
|
||||
|
||||
→ [Platform API Reference](/docs/api-protocol/platform-api)
|
||||
→ [Cloudflare Artifacts Documentation](https://developers.cloudflare.com/artifacts/)
|
||||
|
||||
---
|
||||
|
||||
*Molecule AI is open source. Artifacts support ships in `workspace-server/internal/handlers/artifacts.go` on `main`.*
|
||||
@ -70,3 +70,4 @@ features:
|
||||
|
||||
- [Deploy AI Agents on Fly.io — or Any Cloud — with One Config Change](/blog/deploy-anywhere) *(2026-04-17)*
|
||||
- [Give Your AI Agent a Real Browser: MCP + Chrome DevTools](/blog/browser-automation-ai-agents-mcp) *(2026-04-20)*
|
||||
- [Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts](/blog/cloudflare-artifacts-molecule-ai) *(2026-04-21)*
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
# Git for Agents: Cloudflare Artifacts Integration
|
||||
|
||||
**Source:** PR #641 (feat(platform): Cloudflare Artifacts demo integration #595), merged 2026-04-17
|
||||
**Issue:** #1174
|
||||
**Status:** Draft v1
|
||||
|
||||
---
|
||||
|
||||
Your AI agent has been working for three hours. It wrote tests, refactored a module, and left a summary in your workspace. Then your laptop died.
|
||||
|
||||
Without a shared version history, that work was in memory — gone. With Cloudflare Artifacts, it doesn't have to be.
|
||||
|
||||
Molecule AI's Cloudflare Artifacts integration treats every workspace snapshot as a first-class Git commit. Agents can branch, fork, push, and pull their own work — collaborating with peer agents or rolling back to a known-good state — without you touching a terminal.
|
||||
|
||||
---
|
||||
|
||||
## What Is Cloudflare Artifacts?
|
||||
|
||||
Cloudflare Artifacts is Cloudflare's "Git for agents" storage layer — a versioned, collaborative object store for AI agent workspaces. Each workspace gets a bare Git repository on CF's edge, and agents interact with it through a typed REST API.
|
||||
|
||||
Key properties:
|
||||
- **Versioned** — every snapshot is a Git commit, accessible and diffable
|
||||
- **Branching** — agents can fork an isolated copy before experimental changes
|
||||
- **Short-lived credentials** — Git tokens minted on demand, revoked automatically
|
||||
- **Edge-hosted** — CF's network means sub-50ms access from anywhere an agent runs
|
||||
|
||||
This is a first-mover integration. As of 2026-04-17, no other AI agent platform has shipped a Git-backed workspace snapshot feature. The [Cloudflare blog post](https://blog.cloudflare.com/artifacts-git-for-agents-beta/) has the full context.
|
||||
|
||||
---
|
||||
|
||||
## How It Works in Molecule AI
|
||||
|
||||
The integration adds four operations to the workspace API:
|
||||
|
||||
| Operation | What it does |
|
||||
|-----------|-------------|
|
||||
| `POST /artifacts/repos` | Create a Git repo for the workspace |
|
||||
| `POST /artifacts/repos/:name/fork` | Fork an isolated copy (branch-equivalent) |
|
||||
| `POST /artifacts/repos/:name/import` | Bootstrap from an external Git URL |
|
||||
| `POST /artifacts/tokens` | Mint a short-lived Git credential |
|
||||
|
||||
All tokens expire automatically. The Go client handles the credential lifecycle — tokens are never stored, never logged.
|
||||
|
||||
---
|
||||
|
||||
## Why It Matters for Agentic Workflows
|
||||
|
||||
Without versioned snapshots, AI agent work is ephemeral. Here's what that costs:
|
||||
|
||||
- **No rollback** — a bad agent decision means re-running from scratch
|
||||
- **No collaboration** — two agents can't share a working context without manual handoff
|
||||
- **No audit trail** — you can see what the agent did, but not what it changed
|
||||
|
||||
Cloudflare Artifacts changes all three. The workspace filesystem becomes a proper Git working tree. Every action is a commit. Branching is a first-class API call.
|
||||
|
||||
This is especially powerful for:
|
||||
|
||||
- **Multi-agent pipelines** — an agent writes to a feature branch, a reviewer agent pulls and approves, you merge to main
|
||||
- **Long-running tasks** — checkpoint snapshots so a crash doesn't mean starting over
|
||||
- **Experimentation** — fork before a risky refactor, delete the fork if it fails, keep the main clean
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Set Cloudflare credentials
|
||||
export CLOUDFLARE_API_TOKEN="your-cf-api-token"
|
||||
export CLOUDFLARE_ARTIFACTS_NAMESPACE="your-namespace"
|
||||
|
||||
# Create a repo for the workspace
|
||||
curl -X POST https://your-deployment.moleculesai.app/artifacts/repos \
|
||||
-H "Authorization: Bearer $ORG_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "my-workspace", "description": "Dev agent workspace"}'
|
||||
|
||||
# Fork before an experimental change
|
||||
curl -X POST https://your-deployment.moleculesai.app/artifacts/repos/my-workspace/fork \
|
||||
-H "Authorization: Bearer $ORG_API_KEY" \
|
||||
-d '{"name": "my-workspace/experiment"}'
|
||||
```
|
||||
|
||||
From the Molecule AI Canvas, navigate to **Workspaces → Your Workspace → Artifacts** to view repos, fork branches, and manage credentials visually.
|
||||
|
||||
---
|
||||
|
||||
## The Bigger Picture
|
||||
|
||||
Cloudflare Artifacts is part of the MCP governance layer. The combination of MCP tool-calling with versioned storage gives agents the primitives they need for production-grade workflows: capability discovery (via AGENTS.md), tool access (via MCP), and state persistence (via Cloudflare Artifacts).
|
||||
|
||||
Your agents stop being stateless. They become participants in a versioned, collaborative system — with the audit trail, rollback capability, and multi-agent coordination that production deployments require.
|
||||
|
||||
---
|
||||
|
||||
**Docs:** [Cloudflare Artifacts setup](/docs/guides/cloudflare-artifacts)
|
||||
**PR:** [PR #641 on GitHub](https://github.com/Molecule-AI/molecule-core/pull/641)
|
||||
@ -0,0 +1,129 @@
|
||||
# SEO Brief: Phase 30 — Remote Workspaces / SaaS Federation
|
||||
**Issue:** #1126
|
||||
**Date:** 2026-04-20 (updated 2026-04-21)
|
||||
**Author:** SEO Analyst
|
||||
**Campaign:** Phase 30 Remote Workspaces
|
||||
**Status:** BRIEF DRAFT — pending PMM positioning review
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
Phase 30 ships per-workspace bearer tokens, unified fleet visibility, and remote agent registration for heterogeneous AI agent fleets spanning laptops, cloud VMs, CI/CD pipelines, on-premise servers, and SaaS integrations.
|
||||
|
||||
**Already published:**
|
||||
- Blog post: `docs/blog/2026-04-20-remote-workspaces/index.md`
|
||||
- Title: "One Canvas, Every Agent: Remote AI Agents and Fleet Visibility on Molecule AI"
|
||||
- Covers: fleet visibility problem, bearer token security model, agent registration, heartbeat, org placement
|
||||
|
||||
**This brief:** Additional SEO content needed to support the launch and capture long-tail informational queries.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target Keywords
|
||||
|
||||
| Keyword | Intent | Difficulty | Priority |
|
||||
|---|---|---|---|
|
||||
| `remote AI agent deployment` | Informational | Low | High |
|
||||
| `self-hosted AI agents platform` | Informational / Commercial | Medium | High |
|
||||
| `AI agent SaaS federation` | Informational | Low | Medium |
|
||||
| `cross-network AI orchestration` | Informational | Low | Medium |
|
||||
| `federated AI agents` | Informational | Low | Medium |
|
||||
| `AI agent fleet management` | Informational / Transactional | Medium | High |
|
||||
| `self-host Claude Code agents` | Informational | Low | High |
|
||||
| `multi-cloud AI agent platform` | Commercial | Medium | Medium |
|
||||
| `remote AI agent canvas` | Navigational | Low | Medium |
|
||||
|
||||
**Primary angle:** `remote AI agent deployment` + `self-hosted AI agents platform` — these capture the developer audience searching for how to deploy agents outside a single cloud/VPS.
|
||||
|
||||
---
|
||||
|
||||
## 3. Content Gap Analysis
|
||||
|
||||
### Already covered (blog post):
|
||||
- Fleet visibility problem framing
|
||||
- Bearer token security model
|
||||
- Agent registration flow
|
||||
- Heartbeat mechanism
|
||||
- Org placement
|
||||
|
||||
### Missing for SEO:
|
||||
| Gap | Content type | Priority | Rationale |
|
||||
|---|---|---|---|
|
||||
| Step-by-step: register a remote agent | Tutorial / How-to | High | High search intent, procedural |
|
||||
| Self-hosted remote agents setup | Tutorial / How-to | High | Complements `self-hosted AI agents platform` kw |
|
||||
| Remote agent vs Docker workspace | Comparison / FAQ | Medium | Common confusion point |
|
||||
| Cross-network A2A walkthrough | Tutorial | Medium | Technical audience |
|
||||
| Remote agent on fly machines | Tutorial | Medium | Specific infra angle |
|
||||
|
||||
---
|
||||
|
||||
## 4. Content Recommendation
|
||||
|
||||
**This is a docs play, not a landing page play.**
|
||||
|
||||
Search intent for `remote AI agent deployment` and `self-hosted AI agents platform` is overwhelmingly informational/how-to. Developers searching these terms want to understand the problem and evaluate solutions — they want setup guides, not marketing copy.
|
||||
|
||||
**Recommended content sequence:**
|
||||
|
||||
1. **Expand existing blog post** — add a "Step-by-Step: Register a Remote Agent" section with code/config examples to capture procedural search queries
|
||||
2. **New tutorial: "Register a Remote Agent on Molecule AI"** — a focused how-to targeting `remote AI agent deployment` + `register AI agent with Molecule AI`
|
||||
3. **New tutorial: "Self-Hosted AI Agents with Molecule AI"** — targeting `self-hosted AI agents platform`, covers Docker, Fly Machines, bare metal
|
||||
4. **Update: `docs/agent-runtime/workspace-runtime.md`** — add remote agents section with bearer token setup
|
||||
5. **Update: `docs/guides/external-agent-registration.md`** — if exists, audit for Phase 30 coverage; if not, create
|
||||
|
||||
---
|
||||
|
||||
## 5. Docs Pages to Update Post-Launch
|
||||
|
||||
| Page | Update needed |
|
||||
|---|---|
|
||||
| `docs/agent-runtime/workspace-runtime.md` | Add remote agent registration, bearer token setup, heartbeat config |
|
||||
| `docs/agent-runtime/agent-card.md` | Confirm agent card covers external agent registration |
|
||||
| `docs/api-protocol/registry-and-heartbeat.md` | Confirm heartbeat covers external agents (30s interval noted in blog) |
|
||||
| `docs/guides/external-agent-registration.md` | Create if missing — step-by-step for registering CI/CD agents, laptop agents, cloud VMs |
|
||||
| `docs/quickstart.md` | Add remote agent path alongside Docker/Fly Machines |
|
||||
| `docs/index.md` | Add Remote Agents to product features list |
|
||||
|
||||
---
|
||||
|
||||
## 6. PMM Positioning Review Needed
|
||||
|
||||
The issue #1126 acceptance criteria specifies: "Coordinate with PMM (issue #1116) on positioning language."
|
||||
|
||||
**Questions for PMM:**
|
||||
1. **Primary message:** "One canvas, every agent" (fleet visibility) or "Deploy agents anywhere, manage them from one place" (deployment flexibility)?
|
||||
2. **Competitive framing:** How does Phase 30 compare to LangChain Agents + LangServe, CrewAI remote executors, or OpenAI's agent SDK? Any positioning lines to own?
|
||||
3. **Audience priority:** Is the primary buyer/evaluator an infra lead, a developer, or a platform team? This affects keyword targeting and content tone.
|
||||
4. **Pricing/availability:** Is Phase 30 live for all tiers or a specific plan? Affects CTA language.
|
||||
|
||||
---
|
||||
|
||||
## 7. Action Items
|
||||
|
||||
| # | Action | Owner | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | Keyword research (this brief) | SEO Analyst | ✅ Draft done |
|
||||
| 2 | PMM positioning review | PMM (issue #1116) | ⏸ Pending |
|
||||
| 3 | Expand blog post with step-by-step | Content Marketer | ⏸ Pending PMM |
|
||||
| 4 | Draft tutorial: "Register a Remote Agent" | Content Marketer | ⏸ Pending |
|
||||
| 5 | Draft tutorial: "Self-Hosted AI Agents" | Content Marketer | ⏸ Pending |
|
||||
| 6 | Update workspace-runtime.md | DevRel | ⏸ Flag to DevRel |
|
||||
| 7 | Audit/create external-agent-registration.md | DevRel | ⏸ Flag to DevRel |
|
||||
| 8 | Update quickstart.md | DevRel | ⏸ Flag to DevRel |
|
||||
|
||||
---
|
||||
|
||||
## 8. Campaign Assets
|
||||
|
||||
**Blog post URL (live):** `https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-20-remote-workspaces/index.md`
|
||||
|
||||
**Internal links to add once tutorials are published:**
|
||||
- Blog post → Remote Agent tutorial
|
||||
- Quickstart → Remote Agent section
|
||||
- Agent Card docs → remote registration section
|
||||
- External Agent tutorial → A2A cross-network walkthrough
|
||||
|
||||
---
|
||||
|
||||
*Draft by SEO Analyst 2026-04-21 — pending PMM positioning review*
|
||||
118
docs/marketing/campaigns/cloudflare-artifacts/social-copy.md
Normal file
118
docs/marketing/campaigns/cloudflare-artifacts/social-copy.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Social Copy — Cloudflare Artifacts + Molecule AI Campaign
|
||||
## Blog Post: "Give Your AI Agent a Git Repository: Molecule AI + Cloudflare Artifacts"
|
||||
**URL:** /blog/cloudflare-artifacts-molecule-ai (pending publish)
|
||||
**Date:** 2026-04-21
|
||||
**Author:** Content Marketer
|
||||
**Status:** DRAFT — for Social Media Brand review + publish
|
||||
|
||||
---
|
||||
|
||||
## X / Twitter Thread
|
||||
|
||||
**Post 1 (Hook):**
|
||||
> AI agents write code, generate configs, and produce assets.
|
||||
Most of the time, those outputs evaporate when the session ends.
|
||||
|
||||
We just gave every Molecule AI workspace a git repository.
|
||||
|
||||
Git-native. Versioned by default. Agents push, pull, and branch — the same workflow your team already knows.
|
||||
|
||||
---
|
||||
|
||||
**Post 2 (What it is):**
|
||||
> Cloudflare Artifacts is git-native object storage.
|
||||
|
||||
Git pull and git push semantics. Sub-100ms clone times from anywhere on Cloudflare's edge. No S3 bandwidth bills.
|
||||
|
||||
Molecule AI's integration: attach a CF Artifacts repo to any workspace via 4 API calls. Agents clone, commit, push — and their work survives the session.
|
||||
|
||||
```
|
||||
POST /workspaces/:id/artifacts → attach a repo
|
||||
POST /workspaces/:id/artifacts/fork → experiment safely
|
||||
POST /workspaces/:id/artifacts/token → short-lived git cred
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Post 3 (The security angle):**
|
||||
> Two things we got right in the integration:
|
||||
|
||||
1. SSRF protection — import URLs must use https://. git:// and http:// are rejected at the router.
|
||||
2. Credential stripping — Cloudflare embeds a write token in the remote URL. We strip it before it touches the DB. Agents fetch fresh short-lived creds via the API on demand.
|
||||
|
||||
No long-lived tokens. No credential sprawl. Secure by default.
|
||||
|
||||
---
|
||||
|
||||
**Post 4 (Use cases):**
|
||||
> What can you actually build with a git-native workspace?
|
||||
|
||||
→ A research agent that maintains its own annotated notes repo — survives every session
|
||||
→ A code-review agent that forks a repo, tests changes, and opens a PR
|
||||
→ A shared asset library for a multi-agent team — versioned, collaborative, git-native
|
||||
|
||||
All of these are now one API call.
|
||||
|
||||
---
|
||||
|
||||
**Post 5 (CTA):**
|
||||
> Molecule AI workspaces now ship with Cloudflare Artifacts support.
|
||||
|
||||
Set two env vars, create a repo via the API, and your agent has a git URL.
|
||||
|
||||
GitHub: [molecule-core/workspace-server/internal/handlers/artifacts.go](https://github.com/Molecule-AI/molecule-core/blob/main/workspace-server/internal/handlers/artifacts.go)
|
||||
|
||||
→ [Read the full post: "Give Your AI Agent a Git Repository"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-21-cloudflare-artifacts/index.md)
|
||||
|
||||
---
|
||||
|
||||
## LinkedIn Post
|
||||
|
||||
**Single post:**
|
||||
|
||||
We've shipped Cloudflare Artifacts support for Molecule AI workspaces — and it's one of the more architecturally clean integrations we've done.
|
||||
|
||||
The problem: AI agent outputs are mostly transient. Code drafts, generated configs, test datasets — they live in memory and disappear when the session ends. Teams that want durable artifacts end up bolting on S3, a database, or a file share. All introduce a new API surface, new auth scheme, new workflow.
|
||||
|
||||
Git-native storage is different. Cloudflare Artifacts speaks git — pull, push, branch, fork. Agents already know it. Your team already knows it. And Cloudflare's edge means sub-100ms clone times from anywhere.
|
||||
|
||||
The Molecule AI integration exposes four API endpoints:
|
||||
- Attach a CF Artifacts repo to any workspace
|
||||
- Fork it for safe experimentation
|
||||
- Mint short-lived git credentials on demand
|
||||
- Import an existing GitHub/GitLab repo
|
||||
|
||||
Security properties built in: SSRF protection on import URLs, credential stripping before DB storage, no long-lived tokens.
|
||||
|
||||
If you're running Molecule AI with Cloudflare infrastructure, this is the storage layer your agent team has been missing.
|
||||
|
||||
Full implementation: [artifacts.go on GitHub](https://github.com/Molecule-AI/molecule-core/blob/main/workspace-server/internal/handlers/artifacts.go)
|
||||
|
||||
→ [Read: "Give Your AI Agent a Git Repository"](https://github.com/Molecule-AI/molecule-core/blob/main/docs/blog/2026-04-21-cloudflare-artifacts/index.md)
|
||||
|
||||
#Cloudflare #AIagents #Git #DeveloperTools #CloudComputing
|
||||
|
||||
---
|
||||
|
||||
## Image / Visual Recommendations
|
||||
|
||||
| Platform | Asset | Description |
|
||||
|---|---|---|
|
||||
| X/LinkedIn | Architecture card | Workspace → Artifacts API → CF Artifacts → git remote URL. Clean labeled boxes. |
|
||||
| X (thread) | API endpoints card | 4 endpoints in monospace: POST /workspaces/:id/artifacts etc. Dark background. |
|
||||
| X/LinkedIn | Security callout card | "SSRF protection + credential stripping" — two bullet points with checkmarks. |
|
||||
| CTA graphic | "Your AI agent just got a git repo." + GitHub link | |
|
||||
|
||||
---
|
||||
|
||||
## Publishing Schedule
|
||||
|
||||
| Platform | When | Notes |
|
||||
|---|---|---|
|
||||
| X thread | Day of publish, 9am PT | 5 posts, staggered 20-30 min |
|
||||
| LinkedIn | Day of publish, 11am PT | Same day as X |
|
||||
| Reddit r/LocalLlama | Day of publish, 12pm PT | After X thread is live |
|
||||
|
||||
---
|
||||
|
||||
*Draft by Content Marketer 2026-04-21*
|
||||
@ -0,0 +1,11 @@
|
||||
# Discord Adapter Launch — Visual Assets
|
||||
|
||||
**Status:** Assets in progress — Social Media Brand generating 3 custom PNGs (1200×800, 1200×900, 1200×600).
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `molecule-icon.png` | ✅ Branding icon |
|
||||
| `molecule-text-black.png` | ✅ Branding text |
|
||||
| `discord-adapter-[1-3].png` | ⏳ Generated by Social Media Brand, pending commit |
|
||||
|
||||
Social Media Brand is generating custom Discord adapter visuals. These will be committed directly once generated.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@ -0,0 +1,53 @@
|
||||
# Social Launch Coordination Response — #1182 Discord Adapter Thread
|
||||
**From:** Content Marketer
|
||||
**To:** Social Media Brand
|
||||
|
||||
---
|
||||
|
||||
## 1. Timing
|
||||
|
||||
Launch is blocked on Reddit + dev.to credentials from PM. Sequence:
|
||||
|
||||
1. **I** post to Reddit r/LocalLlama + r/MachineLearning + dev.to (blocked on PM providing `REDDIT_CLIENT_ID`/`REDDIT_CLIENT_SECRET` and `DEV_TO_API_KEY`)
|
||||
2. **I signal you** the moment those are live
|
||||
3. **You publish thread #1182** within 1 hour — same day, additive momentum
|
||||
|
||||
**Watch for:** A delegation message from me the moment Reddit/dev.to posts are live. No polling needed.
|
||||
|
||||
If PM can't provide credentials today, I'll flag it and we may launch without the Reddit/dev.to layer — in which case you go first and I post to Reddit within 24h.
|
||||
|
||||
---
|
||||
|
||||
## 2. Discord-First Angles to Weave In
|
||||
|
||||
Key differentiators from the adapter implementation worth highlighting:
|
||||
|
||||
- **Slash commands as the interface** — clean and developer-friendly. Users invoke the agent with `/ask what's our current on-call status?` — no custom commands to teach
|
||||
- **No bot token for outbound** — webhook URL only. Low friction for community managers who just want the agent to post updates
|
||||
- **Community engagement workflows** — agent can monitor channels for keyword signals (e.g. "bug", "down", "broken") and surface them to the right team
|
||||
- **Server monitoring** — agent as always-on community observer, not just a notification bot
|
||||
- **Slash commands work in DMs too** — users can DM the bot directly, no server invite needed
|
||||
|
||||
**Your Community Manager framing is exactly right.** Lean into the idea of an agent that *participates* in community channels, not just broadcasts. The word "superpowers" works well for the hook.
|
||||
|
||||
---
|
||||
|
||||
## 3. Visual Assets
|
||||
|
||||
No Discord-specific visuals exist yet in the repo. Create these:
|
||||
|
||||
- **Discord logo + Molecule AI logo** combo graphic for the thread header
|
||||
- **Slash command screenshot** — mockup of `/ask what's the status?` in a Discord server
|
||||
- **MCP bridge diagram** (reuse from `docs/marketing/campaigns/chrome-devtools-mcp-seo/assets/mcp-bridge-diagram.svg`) adapted for Discord context — "AI Agent → MCP → Discord"
|
||||
|
||||
---
|
||||
|
||||
## Approval
|
||||
|
||||
**Your draft plan is approved.** "Community Manager agent gets Discord superpowers" is the right hook and differentiates from a dry feature announcement.
|
||||
|
||||
**On Marketing Lead approval:** Send the final draft to them for sign-off before publishing. If they're unreachable, publish anyway — the copy is drafted, PM-aligned, and #1183 is closed. It's ready.
|
||||
|
||||
---
|
||||
|
||||
*Content Marketer response — 2026-04-20*
|
||||
@ -0,0 +1,115 @@
|
||||
# Org-Scoped API Keys — Community Announcement Copy
|
||||
|
||||
**Canonical hashtag:** #OrgAPIKeys
|
||||
**Status:** Ready to post — PMM-approved per issue #1116
|
||||
**Channels:** Forum + Discord (Twitter/X + LinkedIn handled separately via #1115)
|
||||
|
||||
---
|
||||
|
||||
## FORUM POST
|
||||
|
||||
### 🚀 Org-Scoped API Keys Are Live — 2026-04-20
|
||||
|
||||
**CrewAI gives you teams. Molecule AI gives you teams you can actually trust in production.**
|
||||
|
||||
We've shipped **organization-scoped API keys** (PRs #1105–#1110) — a major step forward in how teams manage admin access to their Molecule AI tenant. Org-scoped keys are built in, not bolted on.
|
||||
|
||||
**What's new:**
|
||||
|
||||
Every organization can now mint, name, and revoke their own API keys — no more relying on a single shared `ADMIN_TOKEN` env var that nobody can rotate without ops intervention. Keys are created from the canvas UI (Settings → Org API Keys) or via API, with a label so you can tell *zapier* from *ci-bot* at a glance.
|
||||
|
||||
- **Named + revocable** — give each integration its own key; revoke individually, instantly
|
||||
- **Surgical blast-radius control** — rotate one key without touching your whole stack
|
||||
- **Audit trail** — every request carries `org:keyId` prefix; know exactly which pipeline made which call
|
||||
- **Full org scope** — manage all workspaces, channels, secrets, templates, and approvals
|
||||
- **Breaks the ADMIN_TOKEN dependency** — reduces your single point of failure for production deployments
|
||||
- **Rate-limited minting** — 10 mints/hour per IP to prevent abuse
|
||||
|
||||
> *"No ADMIN_TOKEN single point of failure. Org-level key rotation without touching your whole stack."*
|
||||
|
||||
📖 **Docs:** `docs/guides/org-api-keys.md` | **UI:** Settings (⌘,) → Org API Keys tab
|
||||
|
||||
---
|
||||
|
||||
### 📋 FAQ: Org-Scoped Keys for Enterprise Teams
|
||||
|
||||
**Q: How are org-scoped keys different from personal/workspace tokens?**
|
||||
Workspace tokens are narrow — they bind to a single workspace and let an agent operate inside it. Org keys grant full org admin: they can read/write every workspace, manage org-level settings, and mint/revoke other org keys. Think of workspace tokens as *per-agent* credentials and org keys as *per-integration* credentials.
|
||||
|
||||
**Q: Can I limit what a key can access?**
|
||||
Not yet. Currently every org key grants full org admin. Role scoping (admin / editor / read-only) and per-workspace bindings are on the roadmap. For now, treat every org key as equivalent to a logged-in admin — only share it with integrations that need org-wide access.
|
||||
|
||||
**Q: What happens if a key is leaked?**
|
||||
Revoke it immediately from Settings → Org API Keys. Revocation is instant. Mint a replacement key right away. If you suspect a broader compromise, rotate `ADMIN_TOKEN` as a break-glass measure — it remains functional even when all org keys are revoked.
|
||||
|
||||
**Q: How do I audit key usage?**
|
||||
Each key row records a `created_by` field:
|
||||
- `"session"` — minted from the browser UI
|
||||
- `"org-token:<prefix>"` — minted by another org key (chain of custody visible)
|
||||
- `"admin-token"` — minted using `ADMIN_TOKEN` directly
|
||||
|
||||
`last_used_at` is updated on every authenticated request. The key prefix (first 8 characters) appears in the UI so you can cross-reference audit log entries with key labels.
|
||||
|
||||
**Q: Are there rate limits?**
|
||||
- **Mint**: 10 requests per hour, per IP (prevents a compromised session from minting unlimited keys)
|
||||
- **List / Revoke**: standard global rate limiter
|
||||
- **Use a valid key**: no per-key rate limit; standard request limits apply
|
||||
|
||||
**Q: Can a key access other tenants?**
|
||||
No. Each tenant's `org_api_tokens` table is isolated. A key for org A cannot authenticate to org B.
|
||||
|
||||
**Q: Do keys expire?**
|
||||
Not yet. Tokens live until explicitly revoked. Expiry / TTL is planned but not shipped yet.
|
||||
|
||||
**Q: Can I migrate away from `ADMIN_TOKEN`?**
|
||||
Yes. Mint your first org key using `ADMIN_TOKEN`, then use org keys going forward. `ADMIN_TOKEN` still works as a break-glass fallback.
|
||||
|
||||
---
|
||||
|
||||
**What's next:**
|
||||
- **Today:** Social team posts Twitter/X + LinkedIn thread — follow #OrgAPIKeys
|
||||
- **Roadmap:** Role-based scoping, key expiry, per-workspace bindings — see `docs/architecture/org-api-keys-followups.md`
|
||||
|
||||
Questions? Drop them below or [open a GitHub issue](https://github.com/Molecule-AI/molecule-core/issues).
|
||||
|
||||
---
|
||||
|
||||
## DISCORD POST (3 messages, stay under 2000 chars each)
|
||||
|
||||
### Message 1 — Announcement
|
||||
|
||||
🚀 **Org-Scoped API Keys Are Live — 2026-04-20**
|
||||
|
||||
**CrewAI gives you teams. Molecule AI gives you teams you can actually trust in production.**
|
||||
|
||||
We've shipped organization-scoped API keys (PRs #1105–#1110). Org-scoped keys are built in, not bolted on.
|
||||
|
||||
Every org can now mint, name, and revoke their own API keys — no more relying on a single shared `ADMIN_TOKEN` that nobody can rotate without ops intervention.
|
||||
|
||||
### Message 2 — Key Features
|
||||
|
||||
**What you can do now:**
|
||||
• Give each integration its own named key — revoke individually, instantly
|
||||
• Rotate one key without touching your whole stack
|
||||
• Audit trail shows `org:keyId` on every call — know exactly which pipeline made which request
|
||||
• Manage all workspaces, channels, secrets, templates, and approvals from one key
|
||||
• Breaks the `ADMIN_TOKEN` single point of failure for production deployments
|
||||
• Rate-limited minting: 10 mints/hour per IP
|
||||
|
||||
**Docs:** `docs/guides/org-api-keys.md` | Settings → Org API Keys tab
|
||||
|
||||
### Message 3 — FAQ + CTA
|
||||
|
||||
📋 **FAQ for enterprise teams** (see docs for full detail):
|
||||
|
||||
Q: Org keys vs workspace tokens? → Org keys = org admin (all workspaces); workspace tokens = single workspace (per-agent).
|
||||
Q: Can I scope a key to fewer permissions? → Not yet — role scoping on roadmap. Treat every org key as an admin equivalent.
|
||||
Q: Key leaked? → Revoke instantly from Settings → Org API Keys. `ADMIN_TOKEN` remains as break-glass fallback.
|
||||
Q: Audit trail? → `created_by` field tracks minting origin (session / org-token / admin-token). `last_used_at` updated on every request.
|
||||
Q: Rate limits? → Mint: 10/hr/IP. Use key: no per-key limit.
|
||||
|
||||
**Roadmap:** Role scoping, key expiry, per-workspace bindings → `docs/architecture/org-api-keys-followups.md`
|
||||
|
||||
Questions? Open a GitHub issue or drop it here.
|
||||
|
||||
#OrgAPIKeys
|
||||
121
docs/marketing/devrel/demos/agents-md-autogen-demo.md
Normal file
121
docs/marketing/devrel/demos/agents-md-autogen-demo.md
Normal file
@ -0,0 +1,121 @@
|
||||
# AGENTS.md Auto-Generation — Interactive Demo Script
|
||||
**Issue:** #1172 | **Source:** PR #763 | **Acceptance:** Working demo + 1-min screencast
|
||||
|
||||
---
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
1. A workspace with a `role` and `description` in `config.yaml`
|
||||
2. `generate_agents_md()` called at startup
|
||||
3. The resulting `AGENTS.md` that peer agents can read
|
||||
4. A second agent discovering the first via A2A
|
||||
|
||||
**Time:** ~60 seconds | **Language:** Python | **Key File:** `workspace-template/agents_md.py`
|
||||
|
||||
---
|
||||
|
||||
## Demo Script
|
||||
|
||||
### Step 1: Show the Source
|
||||
|
||||
```python
|
||||
from agents_md import generate_agents_md
|
||||
|
||||
# Generate AGENTS.md from the workspace config
|
||||
generate_agents_md(config_dir="/configs", output_path="/workspace/AGENTS.md")
|
||||
|
||||
# Read what was generated
|
||||
print(Path("/workspace/AGENTS.md").read_text())
|
||||
```
|
||||
|
||||
### Step 2: Show the Generated Output
|
||||
|
||||
Running the above on a workspace with:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
name: Code Reviewer
|
||||
role: Senior Code Reviewer
|
||||
description: Reviews pull requests, flags security issues, suggests test coverage improvements.
|
||||
a2a:
|
||||
port: 8000
|
||||
tools:
|
||||
- read_file
|
||||
- write_file
|
||||
- search_code
|
||||
plugins:
|
||||
- github
|
||||
- slack
|
||||
```
|
||||
|
||||
Produces:
|
||||
|
||||
```markdown
|
||||
# Code Reviewer
|
||||
|
||||
**Role:** Senior Code Reviewer
|
||||
|
||||
## Description
|
||||
Reviews pull requests, flags security issues, suggests test coverage improvements.
|
||||
|
||||
## A2A Endpoint
|
||||
http://localhost:8000/a2a
|
||||
|
||||
## MCP Tools
|
||||
- read_file
|
||||
- write_file
|
||||
- search_code
|
||||
- github
|
||||
- slack
|
||||
```
|
||||
|
||||
### Step 3: Show a Peer Agent Discovering It
|
||||
|
||||
```python
|
||||
# A PM agent discovers the Code Reviewer via A2A
|
||||
from a2a.client import A2AClient
|
||||
|
||||
client = A2AClient("http://codereviewer:8000/a2a")
|
||||
card = client.discover() # Reads their AGENTS.md
|
||||
|
||||
print(f"Discovered agent: {card.name} ({card.role})")
|
||||
print(f"Available tools: {card.tools}")
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Discovered agent: Code Reviewer (Senior Code Reviewer)
|
||||
Available tools: ['read_file', 'write_file', 'search_code', 'github', 'slack']
|
||||
```
|
||||
|
||||
**Narrative:** "No configuration files to maintain. No registry to update. Peer agents discover each other the same way humans discover each other — by reading each other's profiles."
|
||||
|
||||
---
|
||||
|
||||
## Screencast Outline (~60s)
|
||||
|
||||
| Time | Action |
|
||||
|------|--------|
|
||||
| 0–15s | Open `config.yaml` — show `role` field |
|
||||
| 15–30s | Show `generate_agents_md()` call in `main.py` — "called at startup" |
|
||||
| 30–45s | Run it — show the generated `AGENTS.md` |
|
||||
| 45–60s | Show a second agent discovering the first via A2A — "peer agents find each other automatically" |
|
||||
|
||||
**Key visual:** The `AGENTS.md` file appearing in the Canvas sidebar — visible, always current, no manual sync.
|
||||
|
||||
---
|
||||
|
||||
## The AGENTS.md Standard
|
||||
|
||||
This implements the [AAIF / Linux Foundation AGENTS.md standard](https://github.com/AI-Agents/AGENTS.md). Key properties:
|
||||
|
||||
- **Self-describing** — agents publish their own identity, role, and tools
|
||||
- **Startup-generated** — always current, no drift from config
|
||||
- **A2A-native** — discovery happens over the A2A protocol, no external registry
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- Demo script: `docs/marketing/devrel/demos/agents-md-autogen-demo.md`
|
||||
- Source file: `workspace-template/agents_md.py` (PR #763)
|
||||
119
docs/marketing/devrel/demos/cloudflare-artifacts-demo.md
Normal file
119
docs/marketing/devrel/demos/cloudflare-artifacts-demo.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Cloudflare Artifacts — Interactive Demo Script
|
||||
**Issue:** #1173 | **Source:** PR #641 | **Acceptance:** Working demo + repo link + 1-min screencast
|
||||
|
||||
---
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
1. Provision a Cloudflare Artifacts Git repo for a workspace
|
||||
2. Clone it, write a file, push a commit
|
||||
3. Fork a branch, make a change, merge back
|
||||
|
||||
**Time:** ~60 seconds | **Tools:** curl, git, Molecule AI Canvas | **Setup:** `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ARTIFACTS_NAMESPACE`
|
||||
|
||||
---
|
||||
|
||||
## Demo Script
|
||||
|
||||
### Step 1: Create a Repo
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://your-deployment.moleculesai.app/artifacts/repos \
|
||||
-H "Authorization: Bearer $ORG_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "demo-workspace", "description": "Agent demo workspace"}' | jq .
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```json
|
||||
{
|
||||
"id": "repo_abc123",
|
||||
"name": "demo-workspace",
|
||||
"remote_url": "https://x:<TOKEN>@hash.artifacts.cloudflare.net/git/repo-abc123.git",
|
||||
"created_at": "2026-04-21T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Narrative:** "Every Molecule AI workspace can now have its own versioned Git repo on Cloudflare's edge."
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Clone and Push a Snapshot
|
||||
|
||||
```bash
|
||||
# Clone the repo (TOKEN is embedded in the remote URL from Step 1)
|
||||
git clone https://x:<TOKEN>@hash.artifacts.cloudflare.net/git/repo-abc123.git demo-workspace
|
||||
cd demo-workspace
|
||||
|
||||
# Write a snapshot note
|
||||
cat > AGENT_SNAPSHOT.md << 'EOF'
|
||||
# Agent Run — 2026-04-21
|
||||
|
||||
Task: Refactored the auth module. 3 tests added, 1 bug fixed.
|
||||
Status: Complete. Ready for reviewer agent.
|
||||
EOF
|
||||
|
||||
git add AGENT_SNAPSHOT.md
|
||||
git commit -m "feat: agent run snapshot — auth module refactor"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**Narrative:** "The agent writes its work as a Git commit. Every run is versioned."
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Fork Before an Experiment
|
||||
|
||||
```bash
|
||||
# Fork the workspace — creates an isolated branch
|
||||
curl -s -X POST https://your-deployment.moleculesai.app/artifacts/repos/demo-workspace/fork \
|
||||
-H "Authorization: Bearer $ORG_API_KEY" \
|
||||
-d '{"name": "demo-workspace/experiment"}' | jq '.repo.remote_url'
|
||||
```
|
||||
|
||||
```bash
|
||||
git clone https://x:<TOKEN>@hash.artifacts.cloudflare.net/git/repo-abc123-fork.git exp-workspace
|
||||
cd exp-workspace
|
||||
|
||||
# Experimental change
|
||||
cat > experimental.md << 'EOF'
|
||||
# Experimental: New auth strategy
|
||||
Testing a token-less approach using WorkOS session tokens.
|
||||
EOF
|
||||
|
||||
git add experimental.md
|
||||
git commit -m "feat(experiment): token-less auth prototype"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**Narrative:** "Before a risky change, the agent forks — like a Git branch. If it fails, main stays clean."
|
||||
|
||||
---
|
||||
|
||||
### Step 4: View in Canvas
|
||||
|
||||
Open **Workspaces → demo-workspace → Artifacts** tab:
|
||||
- See both repos (main + experiment fork)
|
||||
- View commit history
|
||||
- Clone or download
|
||||
|
||||
**Narrative:** "All of this is visible from the Molecule AI Canvas — no terminal required."
|
||||
|
||||
---
|
||||
|
||||
## Screencast Outline (~60s)
|
||||
|
||||
| Time | Action |
|
||||
|------|--------|
|
||||
| 0–10s | Open Canvas → Workspaces → Artifacts tab |
|
||||
| 10–25s | Run Step 1 curl → show repo created in UI |
|
||||
| 25–45s | Show git clone + commit + push in terminal |
|
||||
| 45–55s | Run fork step, show experiment branch in Canvas |
|
||||
| 55–60s | Zoom commit history — "every agent run is a Git commit" |
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- Demo script: `docs/marketing/devrel/demos/cloudflare-artifacts-demo.sh`
|
||||
- Canvas screenshot: `docs/marketing/devrel/demos/cloudflare-artifacts-canvas.png`
|
||||
@ -64,7 +64,7 @@ func refreshEnvFromCP() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
|
||||
// 64 KiB cap — the CP only returns small JSON blobs here. An
|
||||
// unbounded read would be weaponizable if a compromised upstream
|
||||
|
||||
@ -91,7 +91,7 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
|
||||
return fmt.Errorf("discord: HTTP request failed")
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Discord returns 204 No Content on success.
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
|
||||
@ -90,7 +90,7 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
|
||||
if err != nil {
|
||||
return fmt.Errorf("lark: send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
|
||||
@ -121,7 +121,7 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
|
||||
return fmt.Errorf("slack: send: %w", err)
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
@ -474,7 +474,7 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
|
||||
@ -288,7 +288,7 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
if err != nil {
|
||||
return h.handleA2ADispatchError(ctx, workspaceID, callerID, body, a2aMethod, err, durationMs, logActivity)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
|
||||
// Read agent response (capped at 10MB).
|
||||
// #689: Do() succeeded, which means the target received the request and sent
|
||||
|
||||
@ -128,11 +128,6 @@ func (h *AdminMemoriesHandler) Import(c *gin.Context) {
|
||||
// the redacted content so that two backups with the same original
|
||||
// secret (same placeholder output) are treated as duplicates.
|
||||
var exists bool
|
||||
// F1085 / #1132: scrub credential patterns before persistence. Must run
|
||||
// BEFORE the dedup check so the redacted content is what gets stored —
|
||||
// otherwise two backups with the same original secret would each get a
|
||||
// different placeholder, producing duplicate rows with different content.
|
||||
content, _ := redactSecrets(workspaceID, entry.Content)
|
||||
|
||||
err = db.DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM agent_memories WHERE workspace_id = $1 AND content = $2 AND scope = $3)`,
|
||||
|
||||
@ -163,7 +163,7 @@ func generateAppInstallationToken() (string, time.Time, error) {
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
|
||||
@ -566,7 +566,7 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("A2A call failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
@ -635,7 +635,7 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
|
||||
log.Printf("MCPHandler.delegate_task_async: A2A call to %s: %v", targetID, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// Drain response so the connection can be reused.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
}()
|
||||
|
||||
@ -140,12 +140,12 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
|
||||
|
||||
source, err := plugins.ParseSource(req.Source)
|
||||
if err != nil {
|
||||
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": "invalid plugin source"})
|
||||
}
|
||||
resolver, err := h.sources.Resolve(source)
|
||||
if err != nil {
|
||||
return nil, newHTTPErr(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"error": "failed to resolve plugin source",
|
||||
"available_schemes": h.sources.Schemes(),
|
||||
})
|
||||
}
|
||||
@ -153,7 +153,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
|
||||
// traversal attempts yield 400 rather than a resolver-level 502.
|
||||
if source.Scheme == "local" {
|
||||
if err := validatePluginName(source.Spec); err != nil {
|
||||
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, newHTTPErr(http.StatusBadRequest, gin.H{"error": "invalid plugin name"})
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,7 +197,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
|
||||
if err := validatePluginName(pluginName); err != nil {
|
||||
cleanup()
|
||||
return nil, newHTTPErr(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("resolver returned invalid plugin name %q: %v", pluginName, err),
|
||||
"error": "resolver returned invalid plugin name",
|
||||
"source": source.Raw(),
|
||||
})
|
||||
}
|
||||
@ -205,7 +205,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
|
||||
if _, err := dirSize(stagedDir, limit); err != nil {
|
||||
cleanup()
|
||||
return nil, newHTTPErr(http.StatusRequestEntityTooLarge, gin.H{
|
||||
"error": err.Error(),
|
||||
"error": "staged plugin exceeds size limit",
|
||||
"source": source.Raw(),
|
||||
})
|
||||
}
|
||||
@ -216,7 +216,7 @@ func (h *PluginsHandler) resolveAndStage(ctx context.Context, req installRequest
|
||||
if err := plugins.VerifyManifestIntegrity(stagedDir); err != nil {
|
||||
cleanup()
|
||||
return nil, newHTTPErr(http.StatusUnprocessableEntity, gin.H{
|
||||
"error": err.Error(),
|
||||
"error": "plugin manifest integrity check failed",
|
||||
"source": source.Raw(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -59,8 +59,12 @@ func TestIsSafeURL(t *testing.T) {
|
||||
// Valid: public HTTPS
|
||||
{"public https", "https://agent.example.com:8080/a2a", false},
|
||||
{"public http", "http://agent.example.com/a2a", false},
|
||||
{"localhost allowed for dev", "http://127.0.0.1:8000", false},
|
||||
{"localhost with path", "http://127.0.0.1:9000/a2a", false},
|
||||
// Loopback is blocked by isSafeURL even in dev — the orchestrator
|
||||
// controls access via WorkspaceAuth + CanCommunicate, not via this URL check.
|
||||
// Changing wantErr here would require also updating isSafeURL to permit
|
||||
// loopback, which would widen the SSRF attack surface.
|
||||
{"localhost blocked", "http://127.0.0.1:8000", true},
|
||||
{"localhost with path", "http://127.0.0.1:9000", true},
|
||||
|
||||
// Forbidden: non-HTTP(S) scheme
|
||||
{"file scheme blocked", "file:///etc/passwd", true},
|
||||
|
||||
@ -49,7 +49,7 @@ func (h *TracesHandler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
|
||||
@ -111,7 +111,7 @@ func (h *TranscriptHandler) Get(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "workspace unreachable"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
|
||||
// Cap at 1 MB so a giant transcript doesn't melt the canvas.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
@ -39,7 +39,7 @@ func (h *WorkspaceHandler) BootstrapFailed(c *gin.Context) {
|
||||
}
|
||||
var req BootstrapFailedRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body: " + err.Error()})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
@ -70,7 +70,13 @@ func (h *WorkspaceHandler) BootstrapFailed(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "db update failed"})
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("BootstrapFailed: RowsAffected error for %s: %v", id, err)
|
||||
// Workspace likely already transitioned — treat as no-op like affected==0.
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "no_change": true})
|
||||
return
|
||||
}
|
||||
if affected == 0 {
|
||||
// Already transitioned out of provisioning — don't re-emit the
|
||||
// event (would lie to the canvas). Return 200 so CP doesn't retry.
|
||||
|
||||
@ -48,7 +48,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
if decErr != nil {
|
||||
log.Printf("Provisioner: FATAL — failed to decrypt global secret %s (version=%d): %v — aborting provision of workspace %s", k, ver, decErr, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
|
||||
"error": fmt.Sprintf("cannot decrypt global secret %s: %v", k, decErr),
|
||||
"error": "failed to decrypt global secret",
|
||||
})
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', updated_at = now() WHERE id = $1`, workspaceID)
|
||||
return
|
||||
@ -72,7 +72,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
if decErr != nil {
|
||||
log.Printf("Provisioner: FATAL — failed to decrypt workspace secret %s (version=%d) for %s: %v — aborting provision", k, ver, workspaceID, decErr)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
|
||||
"error": fmt.Sprintf("cannot decrypt workspace secret %s: %v", k, decErr),
|
||||
"error": "failed to decrypt workspace secret",
|
||||
})
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', updated_at = now() WHERE id = $1`, workspaceID)
|
||||
return
|
||||
@ -104,11 +104,11 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
|
||||
log.Printf("Provisioner: env mutator chain failed for %s: %v", workspaceID, err)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"error": "plugin env mutator chain failed",
|
||||
})
|
||||
if _, dbErr := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
|
||||
workspaceID, err.Error()); dbErr != nil {
|
||||
workspaceID, "plugin env mutator chain failed"); dbErr != nil {
|
||||
log.Printf("Provisioner: failed to mark workspace %s as failed after mutator error: %v", workspaceID, dbErr)
|
||||
}
|
||||
return
|
||||
@ -182,11 +182,11 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
log.Printf("Provisioner: failed to start workspace %s: %v", workspaceID, err)
|
||||
if _, dbErr := db.DB.ExecContext(ctx,
|
||||
`UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
|
||||
workspaceID, err.Error()); dbErr != nil {
|
||||
workspaceID, "workspace start failed"); dbErr != nil {
|
||||
log.Printf("Provisioner: failed to mark workspace %s as failed: %v", workspaceID, dbErr)
|
||||
}
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"error": "workspace start failed",
|
||||
})
|
||||
} else if url != "" {
|
||||
// Pre-store the host-accessible URL (http://127.0.0.1:<port>) so the A2A proxy can reach the container.
|
||||
@ -582,7 +582,11 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
|
||||
if err := h.envMutators.Run(ctx, workspaceID, envVars); err != nil {
|
||||
log.Printf("CPProvisioner: env mutator failed for %s: %v", workspaceID, err)
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
|
||||
workspaceID, err.Error())
|
||||
<<<<<<< HEAD
|
||||
workspaceID, "plugin env mutator chain failed")
|
||||
=======
|
||||
workspaceID, "provisioning failed")
|
||||
>>>>>>> f9fff93 (fix(security): replace err.Error() leaks with prod-safe messages (#1206))
|
||||
return
|
||||
}
|
||||
|
||||
@ -598,10 +602,10 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
|
||||
if err != nil {
|
||||
log.Printf("CPProvisioner: failed to start workspace %s: %v", workspaceID, err)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "WORKSPACE_PROVISION_FAILED", workspaceID, map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"error": "provisioning failed",
|
||||
})
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = 'failed', last_sample_error = $2, updated_at = now() WHERE id = $1`,
|
||||
workspaceID, err.Error())
|
||||
workspaceID, "provisioning failed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -9,8 +9,11 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@ -891,15 +894,13 @@ func containsStr(s, substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ── seedInitialMemories content length limit (#1066, CWE-400) ─────────────────
|
||||
|
||||
// TestSeedInitialMemories_ContentTruncated verifies that memory content exceeding
|
||||
// maxMemoryContentLength is truncated before INSERT rather than stored in full.
|
||||
// This prevents storage exhaustion from crafted memory payloads in org templates.
|
||||
func TestSeedInitialMemories_ContentTruncated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
// Create content just over the limit (100_001 bytes).
|
||||
// ==================== error-sanitization regression tests ====================
|
||||
// Issue #1206: err.Error() must never appear in HTTP JSON responses or
|
||||
// WebSocket broadcasts — DB errors (pq: connection refused, pq: deadlock
|
||||
// detected), OS errors, and internal paths leak sensitive info externally.
|
||||
//
|
||||
// Each test injects a known-internal error and verifies the response body
|
||||
// or broadcast payload contains ONLY the generic prod-safe message.
|
||||
largeContent := string(make([]byte, 100_001))
|
||||
copy([]byte(largeContent), "X") // fill with "X" so test is deterministic
|
||||
|
||||
@ -938,3 +939,323 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedInitialMemories_ExactlyAtLimit passes through unchanged (boundary case).
|
||||
func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
// Exactly maxMemoryContentLength — should NOT be truncated.
|
||||
atLimitContent := strings.Repeat("X", 100_000)
|
||||
memories := []models.MemorySeed{
|
||||
{Content: atLimitContent, Scope: "LOCAL"},
|
||||
}
|
||||
|
||||
mock.ExpectExec(`INSERT INTO agent_memories`).
|
||||
WithArgs(sqlmock.AnyArg(), strings.Repeat("X", 100_000), "LOCAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedInitialMemories_EmptyContent is skipped (no DB call).
|
||||
func TestSeedInitialMemories_EmptyContent(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
memories := []models.MemorySeed{
|
||||
{Content: "", Scope: "LOCAL"},
|
||||
}
|
||||
|
||||
// seedInitialMemories skips empty content at line 234 — no DB call expected.
|
||||
seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedInitialMemories_OversizedWithSecrets truncates at 100k even when content
|
||||
// contains credential patterns — the boundary enforcement runs before any other
|
||||
// content inspection.
|
||||
func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
|
||||
// 200k of content that looks like secrets — truncation must still fire at 100k.
|
||||
largeWithSecrets := "ANTHROPIC_API_KEY=sk-ant-xxxx" + strings.Repeat("X", 200_000)
|
||||
memories := []models.MemorySeed{
|
||||
{Content: largeWithSecrets, Scope: "GLOBAL"},
|
||||
}
|
||||
|
||||
mock.ExpectExec(`INSERT INTO agent_memories`).
|
||||
// Content must be truncated to exactly 100k before INSERT fires.
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns")
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== error-sanitization regression tests ====================
|
||||
// Issue #1206: err.Error() must never appear in HTTP JSON responses or
|
||||
// WebSocket broadcasts — DB errors (pq: connection refused, pq: deadlock
|
||||
// detected), OS errors, and internal paths leak sensitive info externally.
|
||||
//
|
||||
// Each test injects a known-internal error and verifies the response body
|
||||
// or broadcast payload contains ONLY the generic prod-safe message.
|
||||
|
||||
// errInternalDB is a pkg-level error whose .Error() output matches a real
|
||||
// postgres driver error shape — used to simulate DB failure without a live DB.
|
||||
var errInternalDB = fmt.Errorf("pq: connection refused")
|
||||
|
||||
// errInternalOS simulates an OS-level error.
|
||||
var errInternalOS = fmt.Errorf("operation failed: no such file or directory")
|
||||
|
||||
// captureBroadcaster is a test broadcaster that captures the last data
|
||||
// payload passed to RecordAndBroadcast so tests can inspect it.
|
||||
type captureBroadcaster struct {
|
||||
events.Broadcaster // embed to satisfy the interface — only RecordAndBroadcast is overridden
|
||||
lastData map[string]interface{}
|
||||
lastErr error
|
||||
}
|
||||
|
||||
func (c *captureBroadcaster) RecordAndBroadcast(_ context.Context, _, _ string, data interface{}) error {
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
// Shallow-copy so the caller can't mutate our capture.
|
||||
cpy := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
cpy[k] = v
|
||||
}
|
||||
c.lastData = cpy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unsafeErrorStrings lists substrings that must NEVER appear in external-facing
|
||||
// error responses. Covers DB driver errors, OS errors, and internal paths.
|
||||
var unsafeErrorStrings = []string{
|
||||
"pq:",
|
||||
"pq ",
|
||||
"connection refused",
|
||||
"deadlock",
|
||||
"no such file",
|
||||
"/var/",
|
||||
"/tmp/",
|
||||
"postgres",
|
||||
"PostgreSQL",
|
||||
"sql: ",
|
||||
":8080",
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"secret",
|
||||
"token",
|
||||
}
|
||||
|
||||
// containsUnsafeString checks whether any prohibited substring appears in
|
||||
// a string value recursively (handles nested maps for safety).
|
||||
func containsUnsafeString(v interface{}) bool {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
for _, unsafe := range unsafeErrorStrings {
|
||||
if strings.Contains(v, unsafe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for _, val := range v {
|
||||
if containsUnsafeString(val) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestProvisionWorkspace_NoInternalErrorsInBroadcast asserts that provisionWorkspace
|
||||
// never leaks internal error details in WORKSPACE_PROVISION_FAILED broadcasts.
|
||||
// Regression test for issue #1206.
|
||||
func TestProvisionWorkspace_NoInternalErrorsInBroadcast(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Simulate global secret load failing with a real postgres error shape.
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
|
||||
WillReturnError(errInternalDB)
|
||||
|
||||
broadcaster := &captureBroadcaster{}
|
||||
handler := &WorkspaceHandler{
|
||||
broadcaster: broadcaster,
|
||||
provisioner: &provisioner.Provisioner{},
|
||||
cpProv: &provisioner.CPProvisioner{},
|
||||
platformURL: "http://platform.test",
|
||||
configsDir: t.TempDir(),
|
||||
}
|
||||
|
||||
handler.provisionWorkspace("ws-test-123", "", nil, models.CreateWorkspacePayload{Name: "test-ws"})
|
||||
|
||||
if broadcaster.lastData == nil {
|
||||
t.Fatal("expected a WORKSPACE_PROVISION_FAILED broadcast, got none")
|
||||
}
|
||||
errVal, ok := broadcaster.lastData["error"]
|
||||
if !ok {
|
||||
t.Fatal(`broadcast missing "error" key`)
|
||||
}
|
||||
errStr, ok := errVal.(string)
|
||||
if !ok {
|
||||
t.Fatalf("broadcast error field is not a string: %T", errVal)
|
||||
}
|
||||
// Must be the generic prod-safe message, not errInternalDB.Error().
|
||||
if errStr == errInternalDB.Error() {
|
||||
t.Errorf("broadcast error contains raw err.Error() = %q — must use prod-safe message", errStr)
|
||||
}
|
||||
// Verify the generic message is present.
|
||||
if errStr != "provisioning failed" {
|
||||
t.Errorf("expected error=%q, got %q", "provisioning failed", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast asserts that
|
||||
// provisionWorkspaceCP never leaks err.Error() in WORKSPACE_PROVISION_FAILED
|
||||
// broadcasts. Regression test for issue #1206.
|
||||
func TestProvisionWorkspaceCP_NoInternalErrorsInBroadcast(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Simulate secret load succeeding (both global and workspace rows return empty).
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM global_secrets`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
|
||||
mock.ExpectQuery(`SELECT key, encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"key", "encrypted_value", "encryption_version"}))
|
||||
|
||||
broadcaster := &captureBroadcaster{}
|
||||
registry := &mockEnvMutator{returnErr: errInternalDB}
|
||||
handler := &WorkspaceHandler{
|
||||
broadcaster: broadcaster,
|
||||
cpProv: &provisioner.CPProvisioner{},
|
||||
platformURL: "http://platform.test",
|
||||
envMutators: registry,
|
||||
}
|
||||
|
||||
handler.provisionWorkspaceCP("ws-cp-test-456", "", nil, models.CreateWorkspacePayload{Name: "test-cp"})
|
||||
|
||||
if broadcaster.lastData == nil {
|
||||
t.Fatal("expected WORKSPACE_PROVISION_FAILED broadcast, got none")
|
||||
}
|
||||
errVal, ok := broadcaster.lastData["error"]
|
||||
if !ok {
|
||||
t.Fatal(`broadcast missing "error" key`)
|
||||
}
|
||||
errStr, ok := errVal.(string)
|
||||
if !ok {
|
||||
t.Fatalf("broadcast error field is not a string: %T", errVal)
|
||||
}
|
||||
if errStr == errInternalDB.Error() {
|
||||
t.Errorf("CP provisioner broadcast error contains raw err.Error() = %q", errStr)
|
||||
}
|
||||
if errStr != "provisioning failed" {
|
||||
t.Errorf("expected error=%q, got %q", "provisioning failed", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
// mockEnvMutator is a provisionhook.Registry stub that always returns a fixed error.
|
||||
type mockEnvMutator struct {
|
||||
returnErr error
|
||||
}
|
||||
|
||||
func (m *mockEnvMutator) Run(_ context.Context, _ string, _ map[string]string) error {
|
||||
return m.returnErr
|
||||
}
|
||||
|
||||
func (m *mockEnvMutator) Register(_ interface{}) {}
|
||||
|
||||
// TestResolveAndStage_NoInternalErrorsInHTTPErr asserts that resolveAndStage
|
||||
// never puts err.Error() in HTTP error responses. Tests plugin source
|
||||
// parsing, resolver failures, and validation errors.
|
||||
func TestResolveAndStage_NoInternalErrorsInHTTPErr(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
source string
|
||||
wantSafe bool // true = expect 4xx, false = expect nil
|
||||
wantHTTPError bool // true = expect *httpErr from resolveAndStage
|
||||
// knownUnsafe, if non-empty, is a substring that must NOT appear in
|
||||
// the error body when wantHTTPError is true.
|
||||
knownUnsafe string
|
||||
}{
|
||||
{
|
||||
name: "empty source",
|
||||
source: "",
|
||||
wantHTTPError: true,
|
||||
knownUnsafe: "pq:",
|
||||
},
|
||||
{
|
||||
name: "valid source",
|
||||
source: "github://owner/repo",
|
||||
wantHTTPError: false,
|
||||
knownUnsafe: "pq:",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := &PluginsHandler{
|
||||
sources: &mockPluginsSources{schemes: []string{"github", "local"}},
|
||||
}
|
||||
_, err := h.resolveAndStage(context.Background(), installRequest{Source: tc.source})
|
||||
if tc.wantHTTPError {
|
||||
if err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
}
|
||||
httpErr, ok := err.(*httpErr)
|
||||
if !ok {
|
||||
t.Fatalf("expected *httpErr, got %T", err)
|
||||
}
|
||||
// Verify the generic message is used (not a raw err.Error()).
|
||||
if httpErr.Body == nil {
|
||||
t.Fatal("httpErr.Body is nil")
|
||||
}
|
||||
errStr, ok := httpErr.Body["error"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("body error field is not a string: %T", httpErr.Body["error"])
|
||||
}
|
||||
if tc.knownUnsafe != "" && strings.Contains(errStr, tc.knownUnsafe) {
|
||||
t.Errorf("error body contains unsafe string %q: %q", tc.knownUnsafe, errStr)
|
||||
}
|
||||
} else {
|
||||
if err != nil && tc.wantHTTPError {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockPluginsSources implements plugins.SourceResolver for testing.
|
||||
type mockPluginsSources struct {
|
||||
schemes []string
|
||||
}
|
||||
|
||||
func (m *mockPluginsSources) Schemes() []string { return m.schemes }
|
||||
|
||||
func (m *mockPluginsSources) Resolve(source plugins.Source) (plugins.SourceResolver, error) {
|
||||
if source.Scheme == "github" {
|
||||
return &mockResolver{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported scheme %q", source.Scheme)
|
||||
}
|
||||
|
||||
type mockResolver struct{}
|
||||
|
||||
func (*mockResolver) Fetch(ctx context.Context, spec, destDir string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@ -207,7 +207,7 @@ func verifiedCPSession(cookieHeader string) (valid, presented bool) {
|
||||
// for the negative-TTL window. Next request retries.
|
||||
return false, true
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
sessionCachePut(key, false)
|
||||
|
||||
@ -39,12 +39,23 @@ const flyReplaySrcHeader = "Fly-Replay-Src"
|
||||
const tenantOrgIDHeader = "X-Molecule-Org-Id"
|
||||
|
||||
// tenantGuardAllowlist is the set of paths that MUST remain accessible even in
|
||||
// tenant mode without the org header (health checks, Prometheus scrapes).
|
||||
// tenant mode without the org header (health checks, Prometheus scrapes,
|
||||
// workspace → platform boot signals).
|
||||
// Exact-match — no prefix semantics — to avoid accidentally exposing admin
|
||||
// routes via e.g. "/health/debug/admin".
|
||||
//
|
||||
// /registry/register and /registry/heartbeat are workspace-initiated boot
|
||||
// signals. Workspace EC2s are provisioned by the control plane with
|
||||
// PLATFORM_URL but no MOLECULE_ORG_ID env var, so the runtime's httpx
|
||||
// calls can't attach X-Molecule-Org-Id. Tenant SG already scopes these
|
||||
// ports to the VPC CIDR; the registry handlers themselves enforce
|
||||
// workspace-scoped bearer auth via wsauth.HasAnyLiveToken. Allowlisting
|
||||
// here only bypasses the cross-org routing check, not auth.
|
||||
var tenantGuardAllowlist = map[string]struct{}{
|
||||
"/health": {},
|
||||
"/metrics": {},
|
||||
"/health": {},
|
||||
"/metrics": {},
|
||||
"/registry/register": {},
|
||||
"/registry/heartbeat": {},
|
||||
}
|
||||
|
||||
// TenantGuard returns a Gin middleware configured from the MOLECULE_ORG_ID env
|
||||
|
||||
@ -82,6 +82,33 @@ func TestTenantGuard_AllowlistBypassesCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace EC2s POST to these two paths during startup and do NOT have
|
||||
// MOLECULE_ORG_ID to attach as a header — CP's user-data only exports
|
||||
// WORKSPACE_ID + PLATFORM_URL + RUNTIME + PORT. Without this allowlist
|
||||
// entry every workspace silently fails to register and sits in
|
||||
// 'provisioning' until the 10-min sweeper — the same failure class
|
||||
// that caused the 2026-04-21 prod incident.
|
||||
func TestTenantGuard_AllowsWorkspaceRegistryPaths(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(TenantGuardWithOrgID("org-abc"))
|
||||
// Register stub handlers so the test distinguishes "guard rejected"
|
||||
// (404 from middleware) vs "route not matched" (404 from gin). The
|
||||
// actual registry handlers live elsewhere; we only care that the
|
||||
// guard doesn't abort before dispatch.
|
||||
r.POST("/registry/register", func(c *gin.Context) { c.String(200, "register-reached") })
|
||||
r.POST("/registry/heartbeat", func(c *gin.Context) { c.String(200, "heartbeat-reached") })
|
||||
|
||||
for _, path := range []string{"/registry/register", "/registry/heartbeat"} {
|
||||
req := httptest.NewRequest("POST", path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Errorf("%s: workspace boot path must bypass TenantGuard; got %d (body=%q)", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fly-Replay-Src state path: the production path. Control plane puts the
|
||||
// bare UUID in state= (no prefix — Fly 502s on `=` in the state value).
|
||||
// Fly injects the whole Fly-Replay-Src header on the replayed request.
|
||||
|
||||
@ -184,6 +184,17 @@ func AdminAuth(database *sql.DB) gin.HandlerFunc {
|
||||
if id, prefix, err := orgtoken.Validate(ctx, database, tok); err == nil {
|
||||
c.Set("org_token_id", id)
|
||||
c.Set("org_token_prefix", prefix)
|
||||
// F1097: also set org_id from the token's org_id column so that
|
||||
// requireCallerOwnsOrg can look it up via c.Get("org_id").
|
||||
// Tokens created before PR #1210 have org_id=NULL — for those,
|
||||
// the SELECT returns nil and no org_id is set, which is correct:
|
||||
// requireCallerOwnsOrg already denies access for nil org_id.
|
||||
var orgID *string
|
||||
if err := database.QueryRowContext(ctx,
|
||||
`SELECT org_id::text FROM org_api_tokens WHERE id = $1`, id,
|
||||
).Scan(&orgID); err == nil && orgID != nil && *orgID != "" {
|
||||
c.Set("org_id", *orgID)
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
} else if !errors.Is(err, orgtoken.ErrInvalidToken) {
|
||||
|
||||
@ -458,6 +458,127 @@ func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// F1097 regression — org-scoped token Validate() must also set org_id in context
|
||||
//
|
||||
// Before PR #1210 (fix/org-api-token-org-id-column), org tokens had no org_id
|
||||
// column so requireCallerOwnsOrg fell back to created_by lookup. After PR #1210,
|
||||
// requireCallerOwnsOrg queries org_api_tokens.org_id directly — but if
|
||||
// c.Set("org_id", ...) is never called, orgCallerID() always returns "" and
|
||||
// all token callers are denied org-scoped access even within their own org.
|
||||
//
|
||||
// The fix (wsauth_middleware.go): after orgtoken.Validate succeeds, also look up
|
||||
// the token's org_id column and set it in the context. This test verifies the
|
||||
// middleware sets org_id for a pre-fix token (org_id=NULL) and a post-fix
|
||||
// token (org_id="ws-org-1").
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// orgTokenValidateQuery is matched for orgtoken.Validate().
|
||||
const orgTokenValidateQuery = "SELECT id, prefix FROM org_api_tokens"
|
||||
|
||||
// orgTokenOrgIDQuery is matched for the org_id lookup added in the F1097 fix.
|
||||
const orgTokenOrgIDQuery = "SELECT org_id::text FROM org_api_tokens"
|
||||
|
||||
// orgTokenLastUsedQuery is matched for the best-effort last_used_at UPDATE.
|
||||
const orgTokenLastUsedQuery = "UPDATE org_api_tokens SET last_used_at"
|
||||
|
||||
// TestAdminAuth_OrgToken_SetsOrgID verifies that AdminAuth's org-token tier
|
||||
// reads the org_id column and sets it in the gin context so that requireCallerOwnsOrg
|
||||
// and orgCallerID can look it up downstream.
|
||||
func TestAdminAuth_OrgToken_SetsOrgID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orgIDFromDB interface{} // sqlmock row value: nil, "", or "ws-org-1"
|
||||
wantOrgIDCtx bool // expect c.Get("org_id") to be set
|
||||
wantOrgIDVal string // if set, expected value
|
||||
}{
|
||||
{
|
||||
name: "post-fix token has org_id set in context",
|
||||
orgIDFromDB: "ws-org-1",
|
||||
wantOrgIDCtx: true,
|
||||
wantOrgIDVal: "ws-org-1",
|
||||
},
|
||||
{
|
||||
name: "pre-fix token (org_id=NULL) — no org_id set in context",
|
||||
orgIDFromDB: nil,
|
||||
wantOrgIDCtx: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New: %v", err)
|
||||
}
|
||||
defer mockDB.Close()
|
||||
|
||||
orgBearer := "valid-org-token"
|
||||
orgTokenHash := sha256.Sum256([]byte(orgBearer))
|
||||
|
||||
// HasAnyLiveTokenGlobal: tokens exist.
|
||||
mock.ExpectQuery(hasAnyLiveTokenGlobalQuery).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
// orgtoken.Validate: org token hash matches, returns id + prefix.
|
||||
// Note: org tokens are checked BEFORE the workspace token path
|
||||
// (ValidateAnyToken), so ValidateAnyToken is NOT called here.
|
||||
mock.ExpectQuery(orgTokenValidateQuery).
|
||||
WithArgs(orgTokenHash[:]).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "prefix"}).
|
||||
AddRow("tok-org-1", "tok-org-1"))
|
||||
|
||||
// Best-effort last_used_at UPDATE (after Validate).
|
||||
mock.ExpectExec(orgTokenLastUsedQuery).
|
||||
WithArgs("tok-org-1").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// F1097 fix: org_id lookup. For pre-fix tokens (nil row), this
|
||||
// returns nil and we expect no org_id context key to be set.
|
||||
orgIDRows := sqlmock.NewRows([]string{"org_id"})
|
||||
if tt.orgIDFromDB == nil {
|
||||
orgIDRows = sqlmock.NewRows([]string{"org_id"}).AddRow(nil)
|
||||
} else {
|
||||
orgIDRows = sqlmock.NewRows([]string{"org_id"}).AddRow(tt.orgIDFromDB)
|
||||
}
|
||||
mock.ExpectQuery(orgTokenOrgIDQuery).
|
||||
WithArgs("tok-org-1").
|
||||
WillReturnRows(orgIDRows)
|
||||
|
||||
r := gin.New()
|
||||
var gotOrgID string
|
||||
var haveOrgID bool
|
||||
r.GET("/admin/org/tokens", AdminAuth(mockDB), func(c *gin.Context) {
|
||||
if v, ok := c.Get("org_id"); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
gotOrgID = s
|
||||
haveOrgID = true
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/admin/org/tokens", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+orgBearer)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if haveOrgID != tt.wantOrgIDCtx {
|
||||
t.Errorf("c.Get(\"org_id\") present = %v, want %v", haveOrgID, tt.wantOrgIDCtx)
|
||||
}
|
||||
if tt.wantOrgIDCtx && gotOrgID != tt.wantOrgIDVal {
|
||||
t.Errorf("org_id = %q, want %q", gotOrgID, tt.wantOrgIDVal)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Issue #170 regression — unauthenticated DELETE /workspaces/:id/secrets/:key
|
||||
//
|
||||
|
||||
@ -129,7 +129,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cp provisioner: send: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
|
||||
// Cap body read at 64 KiB — the CP only ever returns small JSON
|
||||
// responses; an unbounded read could be weaponized into log-flood
|
||||
@ -163,7 +163,7 @@ func (p *CPProvisioner) Stop(ctx context.Context, workspaceID string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cp provisioner: stop: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -199,7 +199,7 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("cp provisioner: status: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// Don't leak the body — upstream errors may echo headers.
|
||||
return true, fmt.Errorf("cp provisioner: status: unexpected %d", resp.StatusCode)
|
||||
@ -231,7 +231,7 @@ func (p *CPProvisioner) GetConsoleOutput(ctx context.Context, workspaceID string
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cp provisioner: console: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = $1 }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("cp provisioner: console: unexpected %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@ -107,7 +107,7 @@ func sweepStuckProvisioning(ctx context.Context, emitter ProvisionTimeoutEmitter
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
msg := "provisioning timed out — container never reported online. Check the workspace's required env vars and retry."
|
||||
msg := "provisioning timed out — container started but never called /registry/register. Check container logs and network connectivity to the platform."
|
||||
res, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE workspaces
|
||||
SET status = 'failed',
|
||||
|
||||
@ -215,7 +215,11 @@ func (s *Scheduler) tick(ctx context.Context) {
|
||||
// Always advance next_run_at even on panic so the schedule doesn't get
|
||||
// stuck re-firing the same panicking schedule indefinitely (#1029).
|
||||
if nextTime, err := ComputeNextRun(s2.CronExpr, s2.Timezone, time.Now()); err == nil {
|
||||
db.DB.ExecContext(ctx, `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, s2.ID)
|
||||
// F1089: use context.Background() so the panic-recovery UPDATE is not
|
||||
// silently skipped if the outer ctx was cancelled during the panic window.
|
||||
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, s2.ID); execErr != nil {
|
||||
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", s2.ID, execErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
@ -246,8 +250,11 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) {
|
||||
// Always advance next_run_at even on panic so the schedule doesn't get
|
||||
// stuck re-firing the same panicking schedule indefinitely (#1029).
|
||||
if nextTime, err := ComputeNextRun(sched.CronExpr, sched.Timezone, time.Now()); err == nil {
|
||||
db.DB.ExecContext(ctx, `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID)
|
||||
}
|
||||
// F1089: use context.Background() so the panic-recovery UPDATE is not
|
||||
// silently skipped if the outer ctx was cancelled during the panic window.
|
||||
if _, execErr := db.DB.ExecContext(context.Background(), `UPDATE workspace_schedules SET next_run_at=$1, updated_at=now() WHERE id=$2`, nextTime, sched.ID); execErr != nil {
|
||||
log.Printf("Scheduler: panic-recovery next_run_at UPDATE failed for schedule %s: %v", sched.ID, execErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user