framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
219 lines • 10.3 kB
JavaScript
import { EventStream } from '../event-stream.js';
import { applyEventToMeta } from '../store/index.js';
import { errorMessage } from '../error-message.js';
const START_TIMEOUT_MS = 15_000;
/** How long a status ping waits before calling a device offline (#1072): short, since it polls. */
const PING_TIMEOUT_MS = 3_000;
/**
* Health-check a saved device (#1072): a cookie'd `GET /_relay/ping`, true on any 2xx, false on a
* non-2xx, an unreachable host, or the timeout. The token stays in memory for the check only, never
* persisted, same as {@link startRemoteAgent}. This is how the browser's status dots learn reachable
* from not: it has the tokens, the daemon does the cross-origin request.
*/
export async function pingRemote(target) {
try {
const res = await fetch(`${trimSlashes(target.url)}/_relay/ping`, {
headers: { cookie: `fw_daemon=${target.token}` },
signal: AbortSignal.timeout(PING_TIMEOUT_MS),
});
return res.ok;
}
catch {
return false;
}
}
/** The two headers every relay request carries: JSON, and the shared-token cookie (#1051). No Origin on purpose. */
function relayHeaders(token) {
return { 'content-type': 'application/json', cookie: `fw_daemon=${token}` };
}
/**
* Start an agent on the remote daemon and return its {@link StartAgentResult} (with the remote's own run
* id). A non-2xx or a transport failure surfaces as an `ok: false` result the dashboard shows, the
* same shape a local refusal has, so the caller does not special-case remote errors.
*/
export async function startRemoteAgent(target, body) {
try {
const res = await fetch(`${trimSlashes(target.url)}/_relay/start`, {
method: 'POST',
headers: relayHeaders(target.token),
body: JSON.stringify(body),
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
if (!res.ok)
return { ok: false, error: `the device refused the run (${res.status})` };
return (await res.json());
}
catch (err) {
return { ok: false, error: `could not reach the device: ${errorMessage(err)}` };
}
}
const RPC_TIMEOUT_MS = 60_000; // a relayed git push/PR runs over the network on the device
/**
* Relay one run-scoped RPC to the device that owns a remote agent (#1067 slice 2). The local daemon
* holds the device token, so a read/diff/handoff/push/PR for a relayed agent runs ON the device: POST
* {fn, args} to the remote's /_relay/rpc over the shared-token cookie (#1051) (no Origin), returning the device's
* result. Throws on an unreachable device or a non-2xx so the caller falls back to its own empty/error
* shape, the same way a failed local read does.
*/
export async function relayRpc(target, fn, args) {
const res = await fetch(`${trimSlashes(target.url)}/_relay/rpc`, {
method: 'POST',
headers: relayHeaders(target.token),
body: JSON.stringify({ fn, args }),
signal: AbortSignal.timeout(RPC_TIMEOUT_MS),
});
if (!res.ok)
throw new Error(`the device refused the request (${res.status})`);
const body = (await res.json());
return body.result;
}
/**
* Fetch-stream a remote agent's newline-delimited events into `onEvent` until the remote closes the
* body, the agent ends, or `cancel()` is called. A 401 (the token was rotated) ends the stream
* cleanly rather than as an error, so the dashboard sees a normal `done`, not a lost connection.
* Returns a cancel function; calling it aborts the fetch and releases the reader.
*/
export function streamRemoteEvents(target, agentId, onEvent, onEnd) {
const controller = new AbortController();
let ended = false;
const end = () => {
if (ended)
return;
ended = true;
onEnd?.();
};
void (async () => {
try {
const url = `${trimSlashes(target.url)}/_relay/events?run=${encodeURIComponent(agentId)}`;
const res = await fetch(url, { headers: relayHeaders(target.token), signal: controller.signal });
// 401 = the device rotated its token. Nothing more will stream; end cleanly (a done, not a loss).
if (!res.ok || !res.body)
return end();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done)
break;
buffer += decoder.decode(value, { stream: true });
// Emit every complete line, keeping the trailing partial for the next chunk.
let newline = buffer.indexOf('\n');
while (newline !== -1) {
emitLine(buffer.slice(0, newline), onEvent);
buffer = buffer.slice(newline + 1);
newline = buffer.indexOf('\n');
}
}
emitLine(buffer, onEvent); // a final line with no trailing newline
}
catch {
// Aborted by cancel(), or the transport dropped: either way the stream is over.
}
finally {
end();
}
})();
return () => {
controller.abort();
end();
};
}
/** Parse one NDJSON line as a {@link FrameworkEvent} and forward it; a blank or malformed line is skipped. */
function emitLine(line, onEvent) {
const trimmed = line.trim();
if (!trimmed)
return;
try {
onEvent(JSON.parse(trimmed));
}
catch {
// A partial or malformed line is dropped rather than crashing the pump.
}
}
/**
* The local daemon's live relayed runs (#1067), keyed by the remote agent id. Registering an agent opens
* an {@link EventStream} the dashboard reads through `onEvents`, fed by {@link streamRemoteEvents}
* from the remote. This map is where a saved device's token lives daemon-side: in memory, for the
* run's lifetime.
*
* The `targets` map outlives the event pump (#1067 slice 2): a finished remote agent's post-run reads,
* push and open-PR still have to reach the device after its event stream has ended, so the device
* target is kept until {@link dispose} clears it, not dropped when the stream closes.
*
* The `metas` map (#1077) holds a local {@link AgentMeta} stub per relayed agent so `onAgents` can show a
* remote run in the session list and re-open it after a dashboard reload; {@link list} projects it
* per project. Same lifetime as `targets`: it outlives the event stream and is cleared on dispose.
*/
export class RelayedAgents {
agents = new Map();
targets = new Map();
// The local AgentMeta stub for each relayed agent, so onAgents can show a remote agent in the session list
// and re-open it after a reload; outlives the event stream, cleared on dispose (same lifetime as targets).
metas = new Map();
/** Open a local stream for a remote agent and start pumping the remote's events into it. */
register(agentId, target, meta, projectId) {
this.targets.set(agentId, target); // kept past the stream, for post-run reads/push/PR (slice 2)
this.metas.set(agentId, { meta, projectId }); // the local list row, so a reload re-opens the run (#1077)
this.agents.get(agentId)?.cancel(); // a re-register (same id) replaces the old pump
const stream = new EventStream();
const cancel = streamRemoteEvents(target, agentId, event => {
stream.push(event);
this.apply(agentId, event); // fold the event into the run's list row, mirroring the device
}, () => this.endStream(agentId));
this.agents.set(agentId, { target, stream, cancel });
}
/** The live event stream for a relayed agent, or undefined when this daemon is not relaying it. */
get(agentId) {
return agentId ? this.agents.get(agentId)?.stream : undefined;
}
/** The device a relayed agent runs on, kept past the event stream so post-run push/PR still reach it. */
target(agentId) {
return agentId ? this.targets.get(agentId) : undefined;
}
/** A project's relayed run stubs (#1077), newest-first, so `onAgents` can surface them in the list. */
list(projectId) {
const rows = [];
for (const entry of this.metas.values())
if (entry.projectId === projectId)
rows.push(entry.meta);
// Newest first: startedAt is ISO, so a string compare is the time order (no parse).
return rows.sort((a, b) => (a.startedAt < b.startedAt ? 1 : a.startedAt > b.startedAt ? -1 : 0));
}
/** Fold each relayed event into the agent's list row via the store's own reducer (#1077), so the
* local stub mirrors the device: the terminal status on `end`, the waiting flag while it is parked
* (#785), the driver once its session starts. Events carry no write time, so this stamps its own. */
apply(agentId, event) {
const entry = this.metas.get(agentId);
if (!entry)
return;
entry.meta = applyEventToMeta(entry.meta, event, new Date().toISOString());
}
/** Close a relayed agent's event stream (not its target). Idempotent. */
endStream(agentId) {
const agent = this.agents.get(agentId);
if (!agent)
return;
this.agents.delete(agentId);
agent.stream.close(); // a clean close surfaces as `done` in the browser, not a lost stream
// The stream dropped with no terminal event: the agent is no longer live, so stop showing it as such.
const entry = this.metas.get(agentId);
if (entry && entry.meta.status === 'running')
entry.meta.status = 'stopped';
}
/** Stop every pump, close every stream, and forget every device target + list stub, on daemon shutdown. */
dispose() {
for (const [agentId, agent] of this.agents) {
agent.cancel();
agent.stream.close();
this.agents.delete(agentId);
}
this.targets.clear();
this.metas.clear();
}
}
/** Trim trailing slashes off a base URL so `${base}/_relay/...` never doubles them. */
function trimSlashes(url) {
return url.replace(/\/+$/, '');
}
//# sourceMappingURL=remote-run.js.map