@cordfuse/crosstalk 7.0.0-alpha.9 → 7.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +118 -0
- package/LICENSE +21 -0
- package/README.md +402 -61
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +120 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/status.js +37 -13
- package/commands/token.js +136 -0
- package/commands/transport.js +270 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +173 -0
- package/lib/resolve.js +101 -34
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/resolve.ts +100 -0
- package/src/state.ts +190 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +254 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +262 -0
- package/template/PROTOCOL.md +70 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -243
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -115
- package/commands/up.js +0 -135
package/src/api.ts
ADDED
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
// api.ts — local HTTP API the host-side client (@cordfuse/crosstalk)
|
|
2
|
+
// connects to. Binds 127.0.0.1 only; never exposes to the network.
|
|
3
|
+
//
|
|
4
|
+
// Design choices (see conversation 2026-06-12):
|
|
5
|
+
// - No auth. Localhost dev tool, same model as ollama / postgres-on-
|
|
6
|
+
// localhost / jupyter-without-token. If you're on the host, you're
|
|
7
|
+
// trusted. The actual security boundary is the bind address.
|
|
8
|
+
// - Bind hardcoded to 127.0.0.1. Operators can't accidentally expose
|
|
9
|
+
// to the network even by misconfiguring docker-compose port mapping.
|
|
10
|
+
// - Plain Node `http` module, no Express/Fastify dep. v7 ships with
|
|
11
|
+
// two runtime deps (tsx + yaml); HTTP framework would break that.
|
|
12
|
+
// - JSON in, JSON out. Error responses carry { error: <message> }.
|
|
13
|
+
//
|
|
14
|
+
// Endpoint surface (v7.0.0-alpha.1):
|
|
15
|
+
// GET /healthz — liveness probe (no body needed)
|
|
16
|
+
// GET /version — engine version + alias
|
|
17
|
+
// GET /status — status summary
|
|
18
|
+
// GET /channels — channel listing (name → uuid)
|
|
19
|
+
// POST /channels — create a channel
|
|
20
|
+
// POST /messages — write primitive or workflow marker
|
|
21
|
+
// GET /messages/:relPath/replies — reply status for given relPath(s)
|
|
22
|
+
//
|
|
23
|
+
// Deferred to follow-up: SSE streaming endpoint for `replies --watch`,
|
|
24
|
+
// /transport/template (export template tarball for `crosstalk transport init`),
|
|
25
|
+
// /shutdown (graceful engine stop).
|
|
26
|
+
|
|
27
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http';
|
|
28
|
+
import { randomUUID } from 'crypto';
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
30
|
+
import { hostname, homedir } from 'os';
|
|
31
|
+
import { join } from 'path';
|
|
32
|
+
import { now, messageFilename } from './filenames.js';
|
|
33
|
+
import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
|
|
34
|
+
import {
|
|
35
|
+
discoverChannels,
|
|
36
|
+
gitCommitAndPush,
|
|
37
|
+
listChannelMessages,
|
|
38
|
+
} from './transport.js';
|
|
39
|
+
import { sendWakeSignal, readHeartbeat, readCursor, countErrors } from './state.js';
|
|
40
|
+
import { reList } from './activation.js';
|
|
41
|
+
import { isOnPath, type ModelEntry, type ModelsRegistry } from './models.js';
|
|
42
|
+
import { resolveToField } from './resolve.js';
|
|
43
|
+
import { dashboardPage } from './web/dashboard.js';
|
|
44
|
+
import { channelListPage, channelViewPage, agentsPage, type BusMessage } from './web/channels.js';
|
|
45
|
+
import { workflowsListPage, workflowDetailPage, type WorkflowSummary } from './web/workflows.js';
|
|
46
|
+
import { setupPage as authSetupPage, loginPage as authLoginPage } from './web/auth-pages.js';
|
|
47
|
+
import { chatPage } from './web/chat-page.js';
|
|
48
|
+
import { mountChatPty } from './web/chat-pty.js';
|
|
49
|
+
import { accountPage, usersPage, tokensPage, logsPage, settingsPage, aboutPage } from './web/stubs.js';
|
|
50
|
+
import { logBuffer, type LogEntry } from './log-buffer.js';
|
|
51
|
+
import { findOpenWorkflows, validatePlan, COMPILE_PROMPT, extractPlanFromOutput, type WorkflowPlan } from './workflow.js';
|
|
52
|
+
import { invokeModelCli } from './invoke.js';
|
|
53
|
+
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
54
|
+
import { FileUserStore, type UserStore } from './auth/users.js';
|
|
55
|
+
import { FileTokenStore, type TokenStore } from './auth/tokens.js';
|
|
56
|
+
import { SetupState, handleSetupSubmit, setupGate } from './auth/setup.js';
|
|
57
|
+
import {
|
|
58
|
+
authenticateRequest, sessionCookieFor, clearSessionCookie,
|
|
59
|
+
type AuthSuccess,
|
|
60
|
+
} from './auth/middleware.js';
|
|
61
|
+
import { handleLogin, handleLogout } from './auth/handlers.js';
|
|
62
|
+
|
|
63
|
+
export interface ApiContext {
|
|
64
|
+
transportRoot: string;
|
|
65
|
+
alias: string;
|
|
66
|
+
version: string;
|
|
67
|
+
registry: ModelsRegistry;
|
|
68
|
+
claimed: Map<string, ModelEntry>; // shorthand for registry.claimed
|
|
69
|
+
/** Auth stores; v8-native. Optional — engine still runs without auth
|
|
70
|
+
* if the operator doesn't want it (just the web UI gating turns off). */
|
|
71
|
+
userStore?: UserStore;
|
|
72
|
+
tokenStore?: TokenStore;
|
|
73
|
+
setupState?: SetupState;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface JsonRes {
|
|
77
|
+
status: number;
|
|
78
|
+
body: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
82
|
+
|
|
83
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
84
|
+
return new Promise((resolveBody, reject) => {
|
|
85
|
+
const chunks: Buffer[] = [];
|
|
86
|
+
req.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
87
|
+
req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf-8')));
|
|
88
|
+
req.on('error', reject);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readJson<T = unknown>(req: IncomingMessage): Promise<T> {
|
|
93
|
+
const raw = await readBody(req);
|
|
94
|
+
if (raw.length === 0) return {} as T;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(raw) as T;
|
|
97
|
+
} catch {
|
|
98
|
+
throw new HttpError(400, 'invalid JSON body');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
class HttpError extends Error {
|
|
103
|
+
constructor(public status: number, message: string) {
|
|
104
|
+
super(message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeJson(res: ServerResponse, status: number, body: unknown, engineVersion?: string): void {
|
|
109
|
+
const json = JSON.stringify(body);
|
|
110
|
+
const headers: Record<string, string> = {
|
|
111
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
112
|
+
'Content-Length': Buffer.byteLength(json).toString(),
|
|
113
|
+
};
|
|
114
|
+
// Advertise our version on every response so the client can detect
|
|
115
|
+
// version skew passively — no extra round-trip needed. Client writes
|
|
116
|
+
// a one-shot warning when its own version doesn't match.
|
|
117
|
+
if (engineVersion) headers['X-Crosstalk-Engine-Version'] = engineVersion;
|
|
118
|
+
res.writeHead(status, headers);
|
|
119
|
+
res.end(json);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeHtml(res: ServerResponse, status: number, html: string, engineVersion?: string): void {
|
|
123
|
+
const headers: Record<string, string> = {
|
|
124
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
125
|
+
'Content-Length': Buffer.byteLength(html, 'utf-8').toString(),
|
|
126
|
+
// HTML pages always reflect server-side state (channels, workflows,
|
|
127
|
+
// tokens, etc.) — never serve a stale cached copy. Defeats both
|
|
128
|
+
// intermediary proxy caches and Chrome's bfcache restoring a list
|
|
129
|
+
// page that still shows a just-deleted entity.
|
|
130
|
+
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
131
|
+
'Pragma': 'no-cache',
|
|
132
|
+
};
|
|
133
|
+
if (engineVersion) headers['X-Crosstalk-Engine-Version'] = engineVersion;
|
|
134
|
+
res.writeHead(status, headers);
|
|
135
|
+
res.end(html);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function writeRedirect(res: ServerResponse, to: string): void {
|
|
139
|
+
res.writeHead(302, { 'Location': to });
|
|
140
|
+
res.end();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── channel discovery helpers ────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
interface ChannelMeta {
|
|
146
|
+
uuid: string;
|
|
147
|
+
name: string | null;
|
|
148
|
+
parent: string | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readChannelMeta(transportRoot: string, uuid: string): ChannelMeta {
|
|
152
|
+
const chPath = join(transportRoot, 'data', 'channels', uuid, 'CHANNEL.md');
|
|
153
|
+
if (!existsSync(chPath)) return { uuid, name: null, parent: null };
|
|
154
|
+
const raw = readFileSync(chPath, 'utf-8');
|
|
155
|
+
const { data } = parseFrontmatter<{ name?: unknown; parent?: unknown }>(raw);
|
|
156
|
+
return {
|
|
157
|
+
uuid,
|
|
158
|
+
name: typeof data.name === 'string' ? data.name : null,
|
|
159
|
+
parent: typeof data.parent === 'string' ? data.parent : null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function allChannelMeta(transportRoot: string): ChannelMeta[] {
|
|
164
|
+
return discoverChannels(transportRoot).map((u) => readChannelMeta(transportRoot, u));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveChannelHandle(transportRoot: string, handle: string): ChannelMeta | null {
|
|
168
|
+
if (UUID_RE.test(handle)) {
|
|
169
|
+
const path = join(transportRoot, 'data', 'channels', handle);
|
|
170
|
+
if (!existsSync(path)) return null;
|
|
171
|
+
return readChannelMeta(transportRoot, handle);
|
|
172
|
+
}
|
|
173
|
+
// Prefer CHANNEL.md name match.
|
|
174
|
+
for (const meta of allChannelMeta(transportRoot)) {
|
|
175
|
+
if (meta.name === handle) return meta;
|
|
176
|
+
}
|
|
177
|
+
// Fallback: bare directory name. Handles legacy / orphan channels
|
|
178
|
+
// whose dir name is not a UUID AND whose CHANNEL.md has no name —
|
|
179
|
+
// they'd be otherwise unreachable via the API. Caught 2026-06-22
|
|
180
|
+
// when an old "mctest" dir couldn't be deleted from the web UI.
|
|
181
|
+
const directPath = join(transportRoot, 'data', 'channels', handle);
|
|
182
|
+
if (existsSync(directPath)) return readChannelMeta(transportRoot, handle);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── route handlers ───────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function handleHealthz(ctx: ApiContext): JsonRes {
|
|
189
|
+
return { status: 200, body: { ok: true, alias: ctx.alias, version: ctx.version } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleVersion(ctx: ApiContext): JsonRes {
|
|
193
|
+
return {
|
|
194
|
+
status: 200,
|
|
195
|
+
body: { engine: 'crosstalk', version: ctx.version, alias: ctx.alias },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function handleStatus(ctx: ApiContext): JsonRes {
|
|
200
|
+
const { transportRoot } = ctx;
|
|
201
|
+
const hb = readHeartbeat(transportRoot);
|
|
202
|
+
const cursor = readCursor(transportRoot);
|
|
203
|
+
const channels = allChannelMeta(transportRoot);
|
|
204
|
+
return {
|
|
205
|
+
status: 200,
|
|
206
|
+
body: {
|
|
207
|
+
transport_root: transportRoot,
|
|
208
|
+
alias: ctx.alias,
|
|
209
|
+
version: ctx.version,
|
|
210
|
+
claimed_models: [...ctx.claimed.keys()],
|
|
211
|
+
heartbeat: hb
|
|
212
|
+
? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias }
|
|
213
|
+
: null,
|
|
214
|
+
cursor: cursor ?? null,
|
|
215
|
+
channels: channels.map((c) => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
|
|
216
|
+
errors_logged: countErrors(transportRoot),
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function handleListChannels(ctx: ApiContext): JsonRes {
|
|
222
|
+
return {
|
|
223
|
+
status: 200,
|
|
224
|
+
body: { channels: allChannelMeta(ctx.transportRoot) },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Canonical agent CLI binaries crosstalk supports. The set the host
|
|
229
|
+
// client's `chat` command knows how to PTY-wrap. The engine checks PATH
|
|
230
|
+
// inside the container for each — what's actually installed.
|
|
231
|
+
//
|
|
232
|
+
// This is distinct from `claimed_models` on /status: that lists
|
|
233
|
+
// data/crosstalk.yaml entries whose CLI is on PATH (e.g. "sonnet", "haiku"
|
|
234
|
+
// — model names). /agents/installed lists raw CLI binaries (e.g.
|
|
235
|
+
// "claude" — the interactive entry point operators talk to).
|
|
236
|
+
const KNOWN_AGENT_BINARIES = ['claude', 'codex', 'gemini', 'qwen', 'opencode', 'agy'];
|
|
237
|
+
|
|
238
|
+
function handleAgentsInstalled(ctx: ApiContext): JsonRes {
|
|
239
|
+
const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
|
|
240
|
+
// The set of CLI binaries referenced by entries in data/crosstalk.yaml
|
|
241
|
+
// (whether their CLI is currently claimed or not). Lets the client
|
|
242
|
+
// differentiate "yaml has them but they're not claimed → restart" from
|
|
243
|
+
// "yaml has no entry referencing them → add one". M2 fix (alpha.10):
|
|
244
|
+
// alpha.9 status hint conflated those two cases.
|
|
245
|
+
const yamlReferenced = [...new Set(
|
|
246
|
+
[...ctx.registry.all.values()].map((entry) => entry.cli),
|
|
247
|
+
)];
|
|
248
|
+
// The set of CLI binaries with at least one currently-claimed model.
|
|
249
|
+
// Distinct from yaml_referenced: yaml may reference `claude` but the
|
|
250
|
+
// dispatcher may not yet have claimed it (CLI installed post-startup,
|
|
251
|
+
// PATH change, etc.). N1 (alpha.11): lets the client surface the
|
|
252
|
+
// "needs restart" hint in mixed states where some models ARE claimed.
|
|
253
|
+
const claimedClis = [...new Set(
|
|
254
|
+
[...ctx.claimed.values()].map((entry) => entry.cli),
|
|
255
|
+
)];
|
|
256
|
+
return {
|
|
257
|
+
status: 200,
|
|
258
|
+
body: {
|
|
259
|
+
installed,
|
|
260
|
+
known: KNOWN_AGENT_BINARIES,
|
|
261
|
+
yaml_referenced: yamlReferenced,
|
|
262
|
+
claimed: claimedClis,
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
interface CreateChannelReq { name: string }
|
|
268
|
+
|
|
269
|
+
function handleCreateChannel(ctx: ApiContext, body: CreateChannelReq): JsonRes {
|
|
270
|
+
const { transportRoot } = ctx;
|
|
271
|
+
if (typeof body.name !== 'string' || body.name.length === 0) {
|
|
272
|
+
throw new HttpError(400, 'channel name is required');
|
|
273
|
+
}
|
|
274
|
+
if (UUID_RE.test(body.name)) {
|
|
275
|
+
throw new HttpError(400, 'channel name cannot be UUID-shaped');
|
|
276
|
+
}
|
|
277
|
+
if (allChannelMeta(transportRoot).some((c) => c.name === body.name)) {
|
|
278
|
+
throw new HttpError(409, `a channel named '${body.name}' already exists`);
|
|
279
|
+
}
|
|
280
|
+
const uuid = randomUUID();
|
|
281
|
+
const dir = join(transportRoot, 'data', 'channels', uuid);
|
|
282
|
+
mkdirSync(dir, { recursive: true });
|
|
283
|
+
writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter({ name: body.name }, ''));
|
|
284
|
+
const push = gitCommitAndPush(transportRoot, `channel(create): ${body.name} (${uuid.slice(0, 8)})`);
|
|
285
|
+
if (!push.committed && push.error) {
|
|
286
|
+
// Commit itself failed (e.g. file system / git config issue).
|
|
287
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
288
|
+
}
|
|
289
|
+
// push.committed=true but push.pushed=false → local commit landed,
|
|
290
|
+
// remote push failed (missing ssh key, no internet, conflicting
|
|
291
|
+
// origin). Don't fail the operation — operators on single-machine
|
|
292
|
+
// setups should be able to use crosstalk locally without git
|
|
293
|
+
// credentials. The engine log records the push error for visibility.
|
|
294
|
+
return { status: 201, body: { uuid, name: body.name } };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface RenameChannelReq { name: string }
|
|
298
|
+
|
|
299
|
+
function handleRenameChannel(ctx: ApiContext, handle: string, body: RenameChannelReq): JsonRes {
|
|
300
|
+
const { transportRoot } = ctx;
|
|
301
|
+
if (typeof body.name !== 'string' || body.name.length === 0) {
|
|
302
|
+
throw new HttpError(400, 'new name is required');
|
|
303
|
+
}
|
|
304
|
+
if (UUID_RE.test(body.name)) {
|
|
305
|
+
throw new HttpError(400, 'channel name cannot be UUID-shaped');
|
|
306
|
+
}
|
|
307
|
+
const meta = resolveChannelHandle(transportRoot, handle);
|
|
308
|
+
if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
|
|
309
|
+
if (meta.name === body.name) {
|
|
310
|
+
return { status: 200, body: { uuid: meta.uuid, name: meta.name, noop: true } };
|
|
311
|
+
}
|
|
312
|
+
if (allChannelMeta(transportRoot).some((c) => c.name === body.name)) {
|
|
313
|
+
throw new HttpError(409, `a channel named '${body.name}' already exists`);
|
|
314
|
+
}
|
|
315
|
+
const chPath = join(transportRoot, 'data', 'channels', meta.uuid, 'CHANNEL.md');
|
|
316
|
+
const fm: Record<string, unknown> = { name: body.name };
|
|
317
|
+
if (meta.parent) fm['parent'] = meta.parent;
|
|
318
|
+
writeFileSync(chPath, serializeFrontmatter(fm, ''));
|
|
319
|
+
const push = gitCommitAndPush(
|
|
320
|
+
transportRoot,
|
|
321
|
+
`channel(rename): ${meta.name ?? '(unnamed)'} -> ${body.name} (${meta.uuid.slice(0, 8)})`,
|
|
322
|
+
);
|
|
323
|
+
if (!push.committed && push.error) {
|
|
324
|
+
// Commit itself failed (e.g. file system / git config issue).
|
|
325
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
326
|
+
}
|
|
327
|
+
// push.committed=true but push.pushed=false → local commit landed,
|
|
328
|
+
// remote push failed (missing ssh key, no internet, conflicting
|
|
329
|
+
// origin). Don't fail the operation — operators on single-machine
|
|
330
|
+
// setups should be able to use crosstalk locally without git
|
|
331
|
+
// credentials. The engine log records the push error for visibility.
|
|
332
|
+
return { status: 200, body: { uuid: meta.uuid, name: body.name } };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function handleDeleteChannel(ctx: ApiContext, handle: string, confirm: string | null): JsonRes {
|
|
336
|
+
const { transportRoot } = ctx;
|
|
337
|
+
const meta = resolveChannelHandle(transportRoot, handle);
|
|
338
|
+
if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
|
|
339
|
+
// The confirmation string must match the channel's name (or UUID if
|
|
340
|
+
// unnamed). Prevents accidental deletes — operator + client both
|
|
341
|
+
// typed the same name out, gives a moment for second thoughts.
|
|
342
|
+
const expected = meta.name ?? meta.uuid;
|
|
343
|
+
if (confirm !== expected) {
|
|
344
|
+
throw new HttpError(
|
|
345
|
+
400,
|
|
346
|
+
`confirmation mismatch — pass ?confirm=${expected} to delete this channel`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
const dir = join(transportRoot, 'data', 'channels', meta.uuid);
|
|
350
|
+
rmSync(dir, { recursive: true, force: true });
|
|
351
|
+
const push = gitCommitAndPush(
|
|
352
|
+
transportRoot,
|
|
353
|
+
`channel(delete): ${meta.name ?? '(unnamed)'} (${meta.uuid.slice(0, 8)})`,
|
|
354
|
+
);
|
|
355
|
+
if (!push.committed && push.error) {
|
|
356
|
+
// Commit itself failed (e.g. file system / git config issue).
|
|
357
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
358
|
+
}
|
|
359
|
+
// push.committed=true but push.pushed=false → local commit landed,
|
|
360
|
+
// remote push failed (missing ssh key, no internet, conflicting
|
|
361
|
+
// origin). Don't fail the operation — operators on single-machine
|
|
362
|
+
// setups should be able to use crosstalk locally without git
|
|
363
|
+
// credentials. The engine log records the push error for visibility.
|
|
364
|
+
return { status: 200, body: { uuid: meta.uuid, name: meta.name, deleted: true } };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
interface PostMessageReq {
|
|
368
|
+
type: 'primitive' | 'workflow';
|
|
369
|
+
channel?: string; // name or UUID; defaults to single channel if exactly one exists
|
|
370
|
+
from?: string;
|
|
371
|
+
to?: string; // required for primitive
|
|
372
|
+
as?: string; // optional persona for primitive
|
|
373
|
+
fanout?: number; // optional, default 1
|
|
374
|
+
body: string;
|
|
375
|
+
re?: string[]; // optional explicit re: targets
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function resolveChannelUuid(transportRoot: string, handle?: string): string {
|
|
379
|
+
if (handle) {
|
|
380
|
+
const meta = resolveChannelHandle(transportRoot, handle);
|
|
381
|
+
if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
|
|
382
|
+
return meta.uuid;
|
|
383
|
+
}
|
|
384
|
+
const all = discoverChannels(transportRoot);
|
|
385
|
+
if (all.length === 1) return all[0]!;
|
|
386
|
+
if (all.length === 0) {
|
|
387
|
+
throw new HttpError(409, "no channels exist — create one via POST /channels first");
|
|
388
|
+
}
|
|
389
|
+
throw new HttpError(409, 'multiple channels exist — specify "channel" in request body');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function writeMessageFile(opts: {
|
|
393
|
+
transportRoot: string;
|
|
394
|
+
channelUuid: string;
|
|
395
|
+
from: string;
|
|
396
|
+
to: string;
|
|
397
|
+
as?: string;
|
|
398
|
+
body: string;
|
|
399
|
+
re: string[];
|
|
400
|
+
extraFrontmatter?: Record<string, unknown>;
|
|
401
|
+
workflow?: boolean;
|
|
402
|
+
}): string {
|
|
403
|
+
const ts = now();
|
|
404
|
+
const dir = join(opts.transportRoot, 'data', 'channels', opts.channelUuid, ts.pathDate);
|
|
405
|
+
mkdirSync(dir, { recursive: true });
|
|
406
|
+
const fm: Record<string, unknown> = {
|
|
407
|
+
from: opts.from,
|
|
408
|
+
to: opts.to,
|
|
409
|
+
timestamp: ts.iso,
|
|
410
|
+
};
|
|
411
|
+
if (opts.as) fm['as'] = opts.as;
|
|
412
|
+
if (opts.workflow) fm['type'] = 'workflow';
|
|
413
|
+
if (opts.re.length === 1) fm['re'] = opts.re[0];
|
|
414
|
+
else if (opts.re.length > 1) fm['re'] = opts.re;
|
|
415
|
+
if (opts.extraFrontmatter) Object.assign(fm, opts.extraFrontmatter);
|
|
416
|
+
const filename = messageFilename(ts);
|
|
417
|
+
writeFileSync(join(dir, filename), serializeFrontmatter(fm, opts.body));
|
|
418
|
+
return join(ts.pathDate, filename);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
|
|
422
|
+
const { transportRoot } = ctx;
|
|
423
|
+
if (typeof body.body !== 'string') {
|
|
424
|
+
throw new HttpError(400, 'message body (`body` field) is required and must be a string');
|
|
425
|
+
}
|
|
426
|
+
const re = Array.isArray(body.re) ? body.re.filter((s) => typeof s === 'string') : [];
|
|
427
|
+
const from = typeof body.from === 'string' && body.from.length > 0 ? body.from : 'operator';
|
|
428
|
+
|
|
429
|
+
if (body.type === 'primitive') {
|
|
430
|
+
if (typeof body.to !== 'string' || body.to.length === 0) {
|
|
431
|
+
throw new HttpError(400, 'primitive requires `to` (model name)');
|
|
432
|
+
}
|
|
433
|
+
// Normalize `to:` to qualified `<provider>/<model>[@host]` via the
|
|
434
|
+
// addressing layer. Bare names auto-disambig against the claimed
|
|
435
|
+
// registry; unknown/ambiguous fails fast with a pick-list 400.
|
|
436
|
+
const resolved = resolveToField(body.to, ctx.registry);
|
|
437
|
+
if (resolved.kind !== 'ok' && resolved.kind !== 'reserved') {
|
|
438
|
+
throw new HttpError(400, resolved.error!);
|
|
439
|
+
}
|
|
440
|
+
const toQualified = resolved.qualified!;
|
|
441
|
+
const channelUuid = resolveChannelUuid(transportRoot, body.channel);
|
|
442
|
+
const fanout = Math.max(1, Math.min(50, body.fanout ?? 1));
|
|
443
|
+
const relPaths: string[] = [];
|
|
444
|
+
for (let i = 0; i < fanout; i++) {
|
|
445
|
+
relPaths.push(
|
|
446
|
+
writeMessageFile({
|
|
447
|
+
transportRoot,
|
|
448
|
+
channelUuid,
|
|
449
|
+
from,
|
|
450
|
+
to: toQualified,
|
|
451
|
+
as: body.as,
|
|
452
|
+
body: body.body,
|
|
453
|
+
re,
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const commitMsg =
|
|
458
|
+
fanout === 1
|
|
459
|
+
? `run: ${from} -> ${toQualified} in ${channelUuid.slice(0, 8)}`
|
|
460
|
+
: `run: ${from} -> ${toQualified} x${fanout} in ${channelUuid.slice(0, 8)}`;
|
|
461
|
+
const push = gitCommitAndPush(transportRoot, commitMsg);
|
|
462
|
+
sendWakeSignal(transportRoot);
|
|
463
|
+
if (!push.committed && push.error) {
|
|
464
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
465
|
+
}
|
|
466
|
+
// push.committed=true with push.pushed=false → local-only, OK.
|
|
467
|
+
return { status: 201, body: { relPaths, channel: channelUuid, type: 'primitive' } };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (body.type === 'workflow') {
|
|
471
|
+
const parsed = parseFrontmatter<Record<string, unknown>>(body.body);
|
|
472
|
+
if (parsed.data['type'] !== 'workflow') {
|
|
473
|
+
throw new HttpError(400, "workflow body must declare 'type: workflow' in frontmatter");
|
|
474
|
+
}
|
|
475
|
+
const parentUuid = resolveChannelUuid(transportRoot, body.channel);
|
|
476
|
+
const childUuid = randomUUID();
|
|
477
|
+
const childDir = join(transportRoot, 'data', 'channels', childUuid);
|
|
478
|
+
mkdirSync(childDir, { recursive: true });
|
|
479
|
+
writeFileSync(
|
|
480
|
+
join(childDir, 'CHANNEL.md'),
|
|
481
|
+
serializeFrontmatter({ name: `workflow-${Date.now()}`, parent: parentUuid }, ''),
|
|
482
|
+
);
|
|
483
|
+
const heartbeat = readHeartbeat(transportRoot);
|
|
484
|
+
const dispatchHost = heartbeat?.alias;
|
|
485
|
+
const extraFrontmatter: Record<string, unknown> = { child_channel: childUuid };
|
|
486
|
+
if (dispatchHost) extraFrontmatter['dispatch_host'] = dispatchHost;
|
|
487
|
+
const relPath = writeMessageFile({
|
|
488
|
+
transportRoot,
|
|
489
|
+
channelUuid: parentUuid,
|
|
490
|
+
from,
|
|
491
|
+
to: 'workflow',
|
|
492
|
+
body: parsed.body,
|
|
493
|
+
re,
|
|
494
|
+
extraFrontmatter,
|
|
495
|
+
workflow: true,
|
|
496
|
+
});
|
|
497
|
+
const push = gitCommitAndPush(
|
|
498
|
+
transportRoot,
|
|
499
|
+
`run(workflow): ${from} child ${childUuid.slice(0, 8)}`,
|
|
500
|
+
);
|
|
501
|
+
sendWakeSignal(transportRoot);
|
|
502
|
+
if (!push.committed && push.error) {
|
|
503
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
504
|
+
}
|
|
505
|
+
// push.committed=true with push.pushed=false → local-only, OK.
|
|
506
|
+
return {
|
|
507
|
+
status: 201,
|
|
508
|
+
body: { relPath, channel: parentUuid, childChannel: childUuid, type: 'workflow' },
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
throw new HttpError(400, "`type` must be 'primitive' or 'workflow'");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function handleReplies(ctx: ApiContext, relPathQuery: string): JsonRes {
|
|
516
|
+
const targets = relPathQuery.split(',').map((s) => s.trim()).filter(Boolean);
|
|
517
|
+
if (targets.length === 0) {
|
|
518
|
+
throw new HttpError(400, 'at least one relPath query argument required');
|
|
519
|
+
}
|
|
520
|
+
const found = new Map<string, { from: string; relPath: string; failed: boolean }>();
|
|
521
|
+
for (const channel of discoverChannels(ctx.transportRoot)) {
|
|
522
|
+
for (const msg of listChannelMessages(ctx.transportRoot, channel)) {
|
|
523
|
+
for (const entry of reList(msg.data['re'])) {
|
|
524
|
+
if (targets.includes(entry) && !found.has(entry)) {
|
|
525
|
+
found.set(entry, {
|
|
526
|
+
from: String(msg.data['from']),
|
|
527
|
+
relPath: msg.relPath,
|
|
528
|
+
failed: msg.data['failed'] === true,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const results = targets.map((t) => {
|
|
535
|
+
const reply = found.get(t);
|
|
536
|
+
if (!reply) return { target: t, status: 'PENDING' };
|
|
537
|
+
return {
|
|
538
|
+
target: t,
|
|
539
|
+
status: reply.failed ? 'FAILED' : 'REPLIED',
|
|
540
|
+
from: reply.from,
|
|
541
|
+
replyRelPath: reply.relPath,
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
return { status: 200, body: { replies: results } };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ── HTML page rendering ──────────────────────────────────────────────
|
|
548
|
+
//
|
|
549
|
+
// The web UI lives at separate paths from the JSON API to avoid Accept-
|
|
550
|
+
// header ambiguity and to keep the JSON surface untouched. Paths:
|
|
551
|
+
// GET / → dashboard
|
|
552
|
+
// GET /c → channel list
|
|
553
|
+
// GET /c/<handle> → threaded message view (handle =
|
|
554
|
+
// channel name or uuid)
|
|
555
|
+
// GET /c/<handle>/messages.json → poll endpoint for the threaded view
|
|
556
|
+
// GET /agents → installed/yaml-referenced/claimed agents
|
|
557
|
+
//
|
|
558
|
+
// All HTML routes are synchronous reads of the transport state — no
|
|
559
|
+
// auth (engine binds 127.0.0.1; tailscale-serve front handles tailnet
|
|
560
|
+
// exposure if the operator wants it).
|
|
561
|
+
|
|
562
|
+
function hostString(): string {
|
|
563
|
+
try { return hostname() || 'localhost'; } catch { return 'localhost'; }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* v8 auth gate. Web pages (HTML routes) AND auth-API routes (POST
|
|
568
|
+
* /api/setup, POST /api/auth/login, POST /api/auth/logout) are gated by
|
|
569
|
+
* the user/token stores. JSON API routes (everything else under /api/*)
|
|
570
|
+
* stay open on 127.0.0.1 — the engine binds loopback only, so the
|
|
571
|
+
* security boundary is the bind address, not app-level auth. This
|
|
572
|
+
* matches the v7 model + only adds gating for browser/web access.
|
|
573
|
+
*
|
|
574
|
+
* Returns: 'allow' = caller may proceed; 'handled' = setup/login flow
|
|
575
|
+
* was served (HTML or JSON); 'redirect' = unauthed; caller wrote 302.
|
|
576
|
+
*/
|
|
577
|
+
async function tryWebAuthFlow(ctx: ApiContext, req: IncomingMessage, res: ServerResponse): Promise<'handled' | 'allow' | 'redirect'> {
|
|
578
|
+
if (!ctx.userStore || !ctx.tokenStore || !ctx.setupState) return 'allow';
|
|
579
|
+
const { method = 'GET', url = '/' } = req;
|
|
580
|
+
const u = new URL(url, 'http://localhost');
|
|
581
|
+
const path = u.pathname;
|
|
582
|
+
|
|
583
|
+
// POST /api/setup — open during first-run only.
|
|
584
|
+
if (method === 'POST' && path === '/api/setup') {
|
|
585
|
+
const body = JSON.parse(await readBody(req) || '{}') as { setupToken?: string; name?: string; username?: string; passphrase?: string };
|
|
586
|
+
const r = await handleSetupSubmit({
|
|
587
|
+
setupToken: body.setupToken ?? '',
|
|
588
|
+
name: body.name ?? '',
|
|
589
|
+
username: body.username ?? '',
|
|
590
|
+
passphrase: body.passphrase ?? '',
|
|
591
|
+
}, ctx.userStore, ctx.tokenStore, ctx.setupState);
|
|
592
|
+
if (r.ok && r.bootstrapToken) {
|
|
593
|
+
// Set the session cookie alongside the JSON body so the operator
|
|
594
|
+
// is auto-signed-in after setup completes.
|
|
595
|
+
const json = JSON.stringify(r);
|
|
596
|
+
res.writeHead(200, {
|
|
597
|
+
'Content-Type': 'application/json',
|
|
598
|
+
'Content-Length': Buffer.byteLength(json).toString(),
|
|
599
|
+
'X-Crosstalk-Engine-Version': ctx.version,
|
|
600
|
+
'Set-Cookie': sessionCookieFor(r.bootstrapToken.plaintextSecret),
|
|
601
|
+
});
|
|
602
|
+
res.end(json);
|
|
603
|
+
} else {
|
|
604
|
+
writeJson(res, r.ok ? 200 : 400, r, ctx.version);
|
|
605
|
+
}
|
|
606
|
+
return 'handled';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// POST /api/auth/login — open. Returns wire token + sets session cookie.
|
|
610
|
+
if (method === 'POST' && path === '/api/auth/login') {
|
|
611
|
+
const body = JSON.parse(await readBody(req) || '{}');
|
|
612
|
+
const r = await handleLogin(body, ctx.userStore, ctx.tokenStore);
|
|
613
|
+
if (r.status === 200 && 'token' in r.body) {
|
|
614
|
+
const json = JSON.stringify(r.body);
|
|
615
|
+
res.writeHead(200, {
|
|
616
|
+
'Content-Type': 'application/json',
|
|
617
|
+
'Content-Length': Buffer.byteLength(json).toString(),
|
|
618
|
+
'X-Crosstalk-Engine-Version': ctx.version,
|
|
619
|
+
'Set-Cookie': sessionCookieFor(r.body.token),
|
|
620
|
+
});
|
|
621
|
+
res.end(json);
|
|
622
|
+
} else {
|
|
623
|
+
writeJson(res, r.status, r.body, ctx.version);
|
|
624
|
+
}
|
|
625
|
+
return 'handled';
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// POST /api/auth/logout — requires v2 cookie; clears session.
|
|
629
|
+
if (method === 'POST' && path === '/api/auth/logout') {
|
|
630
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
631
|
+
if (!auth.ok) { writeJson(res, auth.status, auth.body, ctx.version); return 'handled'; }
|
|
632
|
+
const r = await handleLogout(auth.token, ctx.tokenStore);
|
|
633
|
+
const json = JSON.stringify(r.body);
|
|
634
|
+
res.writeHead(r.status, {
|
|
635
|
+
'Content-Type': 'application/json',
|
|
636
|
+
'Content-Length': Buffer.byteLength(json).toString(),
|
|
637
|
+
'X-Crosstalk-Engine-Version': ctx.version,
|
|
638
|
+
'Set-Cookie': clearSessionCookie(),
|
|
639
|
+
});
|
|
640
|
+
res.end(json);
|
|
641
|
+
return 'handled';
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// GET /setup — render the wizard. Only honored when no users exist;
|
|
645
|
+
// post-setup it 302s to /.
|
|
646
|
+
if (method === 'GET' && path === '/setup') {
|
|
647
|
+
if (!(await ctx.userStore.isEmpty())) { writeRedirect(res, '/'); return 'handled'; }
|
|
648
|
+
const token = u.searchParams.get('token') ?? '';
|
|
649
|
+
writeHtml(res, 200, authSetupPage({ setupToken: token, host: hostString(), alias: ctx.alias }), ctx.version);
|
|
650
|
+
return 'handled';
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// GET /login — render the form. Already-authed users redirect to /.
|
|
654
|
+
if (method === 'GET' && path === '/login') {
|
|
655
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
656
|
+
if (auth.ok) { writeRedirect(res, '/'); return 'handled'; }
|
|
657
|
+
const returnTo = u.searchParams.get('returnTo') ?? '/';
|
|
658
|
+
writeHtml(res, 200, authLoginPage({ host: hostString(), alias: ctx.alias, returnTo }), ctx.version);
|
|
659
|
+
return 'handled';
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Setup gate first-run: when no users exist AND the request isn't a
|
|
663
|
+
// setup/api-setup path, redirect to /setup so the first browser visit
|
|
664
|
+
// bootstraps the admin account.
|
|
665
|
+
const HTML_GATED_PATHS = new Set([
|
|
666
|
+
'/', '/agents', '/chat',
|
|
667
|
+
'/tokens', '/logs', '/settings', '/account', '/users', '/about',
|
|
668
|
+
]);
|
|
669
|
+
if (method === 'GET' && (HTML_GATED_PATHS.has(path) || path.startsWith('/c') || path.startsWith('/w'))) {
|
|
670
|
+
const gate = await setupGate(ctx.userStore, path);
|
|
671
|
+
if (!gate.allow) {
|
|
672
|
+
// Mint a setup token if none active; either way, include it in
|
|
673
|
+
// the redirect URL so the operator doesn't have to fish it out
|
|
674
|
+
// of the daemon log.
|
|
675
|
+
const token = ctx.setupState.currentToken
|
|
676
|
+
?? ctx.setupState.mint().token;
|
|
677
|
+
writeRedirect(res, `/setup?token=${encodeURIComponent(token)}`);
|
|
678
|
+
return 'redirect';
|
|
679
|
+
}
|
|
680
|
+
// Users exist — require a valid v2 cookie/bearer for HTML routes.
|
|
681
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
682
|
+
if (!auth.ok) {
|
|
683
|
+
writeRedirect(res, `/login?returnTo=${encodeURIComponent(u.pathname + u.search)}`);
|
|
684
|
+
return 'redirect';
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return 'allow';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Auth-aware JSON API routes for the web UI:
|
|
693
|
+
* POST /api/tokens — mint a token for current user
|
|
694
|
+
* DELETE /api/tokens/:tokenId — revoke a token (must own it)
|
|
695
|
+
* PATCH /api/tokens/:tokenId — rename a token (must own it)
|
|
696
|
+
* POST /api/account/passphrase — change own passphrase
|
|
697
|
+
* POST /api/users — admin: create user
|
|
698
|
+
* DELETE /api/users/:username — admin: delete user (not self)
|
|
699
|
+
* PATCH /api/users/:username — admin: toggle admin (not self)
|
|
700
|
+
*
|
|
701
|
+
* All return JSON. Auth required on every endpoint; admin endpoints
|
|
702
|
+
* additionally check the caller's admin flag. Returns true if the
|
|
703
|
+
* request was handled (response written), false to fall through.
|
|
704
|
+
*/
|
|
705
|
+
async function tryWebApiRoute(ctx: ApiContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
706
|
+
if (!ctx.userStore || !ctx.tokenStore) return false;
|
|
707
|
+
const { method = 'GET', url = '/' } = req;
|
|
708
|
+
const u = new URL(url, 'http://localhost');
|
|
709
|
+
const path = u.pathname;
|
|
710
|
+
|
|
711
|
+
const isApiPath =
|
|
712
|
+
path === '/api/tokens' || path.startsWith('/api/tokens/') ||
|
|
713
|
+
path === '/api/account' || path === '/api/account/passphrase' ||
|
|
714
|
+
path === '/api/users' || path.startsWith('/api/users/') ||
|
|
715
|
+
path === '/api/workflows' || path.startsWith('/api/workflows/') ||
|
|
716
|
+
path === '/api/logs' || path === '/api/logs/stream' ||
|
|
717
|
+
path === '/api/settings';
|
|
718
|
+
if (!isApiPath) return false;
|
|
719
|
+
|
|
720
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
721
|
+
if (!auth.ok) { writeJson(res, auth.status, auth.body, ctx.version); return true; }
|
|
722
|
+
const me = auth.user;
|
|
723
|
+
|
|
724
|
+
// ── Account read ─────────────────────────────────────────────────
|
|
725
|
+
// GET /api/account → who am I (used by `crosstalk auth whoami` + UI).
|
|
726
|
+
if (method === 'GET' && path === '/api/account') {
|
|
727
|
+
// Don't leak passphraseHash.
|
|
728
|
+
const { passphraseHash: _ph, ...safe } = me;
|
|
729
|
+
void _ph;
|
|
730
|
+
writeJson(res, 200, safe, ctx.version);
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── Tokens ────────────────────────────────────────────────────────
|
|
735
|
+
// GET /api/tokens → list current user's tokens (CLI + UI).
|
|
736
|
+
if (method === 'GET' && path === '/api/tokens') {
|
|
737
|
+
const tokens = await ctx.tokenStore.list(me.username);
|
|
738
|
+
writeJson(res, 200, { tokens }, ctx.version);
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
if (method === 'POST' && path === '/api/tokens') {
|
|
742
|
+
const body = await readJson<{ name?: string }>(req);
|
|
743
|
+
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
744
|
+
writeJson(res, 400, { error: 'token name required' }, ctx.version);
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
const r = await ctx.tokenStore.mint({ username: me.username, name: body.name.trim() });
|
|
748
|
+
writeJson(res, 201, { tokenId: r.token.tokenId, plaintextSecret: r.plaintextSecret, createdAt: r.token.createdAt }, ctx.version);
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
{
|
|
752
|
+
const m = path.match(/^\/api\/tokens\/([^/]+)$/);
|
|
753
|
+
if (m && method === 'DELETE') {
|
|
754
|
+
const tokenId = decodeURIComponent(m[1]!);
|
|
755
|
+
// Operators can only revoke tokens they own. Admin override
|
|
756
|
+
// isn't necessary here — admin can use the CLI / edit the json
|
|
757
|
+
// if they really need to nuke another user's token.
|
|
758
|
+
const tok = await ctx.tokenStore.get(tokenId);
|
|
759
|
+
if (!tok) { writeJson(res, 404, { error: 'token not found' }, ctx.version); return true; }
|
|
760
|
+
if (tok.username !== me.username) {
|
|
761
|
+
writeJson(res, 403, { error: 'cannot revoke a token you do not own' }, ctx.version);
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
await ctx.tokenStore.revoke(tokenId);
|
|
765
|
+
writeJson(res, 200, { ok: true }, ctx.version);
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
if (m && method === 'PATCH') {
|
|
769
|
+
const tokenId = decodeURIComponent(m[1]!);
|
|
770
|
+
const tok = await ctx.tokenStore.get(tokenId);
|
|
771
|
+
if (!tok) { writeJson(res, 404, { error: 'token not found' }, ctx.version); return true; }
|
|
772
|
+
if (tok.username !== me.username) {
|
|
773
|
+
writeJson(res, 403, { error: 'cannot rename a token you do not own' }, ctx.version);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
const body = await readJson<{ name?: string }>(req);
|
|
777
|
+
if (typeof body.name !== 'string' || body.name.trim().length === 0) {
|
|
778
|
+
writeJson(res, 400, { error: 'token name required' }, ctx.version);
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
const updated = await ctx.tokenStore.rename(tokenId, body.name.trim());
|
|
782
|
+
writeJson(res, 200, { token: updated }, ctx.version);
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ── Account: change passphrase ───────────────────────────────────
|
|
788
|
+
if (method === 'POST' && path === '/api/account/passphrase') {
|
|
789
|
+
const body = await readJson<{ oldPassphrase?: string; newPassphrase?: string }>(req);
|
|
790
|
+
if (!body.oldPassphrase || !body.newPassphrase) {
|
|
791
|
+
writeJson(res, 400, { error: 'oldPassphrase + newPassphrase required' }, ctx.version);
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
const ok = await ctx.userStore.verifyPassphrase(me.username, body.oldPassphrase);
|
|
795
|
+
if (!ok) { writeJson(res, 401, { error: 'current passphrase incorrect' }, ctx.version); return true; }
|
|
796
|
+
try {
|
|
797
|
+
await ctx.userStore.setPassphrase(me.username, body.newPassphrase);
|
|
798
|
+
writeJson(res, 200, { ok: true }, ctx.version);
|
|
799
|
+
} catch (err) {
|
|
800
|
+
writeJson(res, 400, { error: (err as Error).message }, ctx.version);
|
|
801
|
+
}
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ── Users: admin-gated ────────────────────────────────────────────
|
|
806
|
+
if (path.startsWith('/api/users')) {
|
|
807
|
+
if (!me.admin) { writeJson(res, 403, { error: 'admin only' }, ctx.version); return true; }
|
|
808
|
+
|
|
809
|
+
if (method === 'POST' && path === '/api/users') {
|
|
810
|
+
const body = await readJson<{ username?: string; name?: string; passphrase?: string; admin?: boolean }>(req);
|
|
811
|
+
if (!body.username || !body.name || !body.passphrase) {
|
|
812
|
+
writeJson(res, 400, { error: 'username + name + passphrase required' }, ctx.version);
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const created = await ctx.userStore.createUser({
|
|
817
|
+
username: body.username, name: body.name, passphrase: body.passphrase, admin: body.admin === true,
|
|
818
|
+
});
|
|
819
|
+
// Don't echo the passphrase hash back.
|
|
820
|
+
const { passphraseHash: _ph, ...safe } = created;
|
|
821
|
+
void _ph;
|
|
822
|
+
writeJson(res, 201, safe, ctx.version);
|
|
823
|
+
} catch (err) {
|
|
824
|
+
writeJson(res, 400, { error: (err as Error).message }, ctx.version);
|
|
825
|
+
}
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const m = path.match(/^\/api\/users\/([^/]+)$/);
|
|
830
|
+
if (m) {
|
|
831
|
+
const targetUsername = decodeURIComponent(m[1]!);
|
|
832
|
+
// Self-protection: admins can't delete or demote themselves —
|
|
833
|
+
// prevents the "last admin" footgun where deleting yourself
|
|
834
|
+
// locks the operator pool down with no path back without
|
|
835
|
+
// editing users.json by hand.
|
|
836
|
+
if (targetUsername === me.username) {
|
|
837
|
+
writeJson(res, 400, { error: 'cannot modify your own admin record via this endpoint' }, ctx.version);
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
if (method === 'DELETE') {
|
|
841
|
+
try {
|
|
842
|
+
await ctx.userStore.deleteUser(targetUsername);
|
|
843
|
+
// Revoke all tokens for the deleted user.
|
|
844
|
+
await ctx.tokenStore.revokeAllForUser(targetUsername);
|
|
845
|
+
writeJson(res, 200, { ok: true }, ctx.version);
|
|
846
|
+
} catch (err) {
|
|
847
|
+
writeJson(res, 404, { error: (err as Error).message }, ctx.version);
|
|
848
|
+
}
|
|
849
|
+
return true;
|
|
850
|
+
}
|
|
851
|
+
if (method === 'PATCH') {
|
|
852
|
+
const body = await readJson<{ admin?: boolean }>(req);
|
|
853
|
+
if (typeof body.admin !== 'boolean') {
|
|
854
|
+
writeJson(res, 400, { error: 'admin (boolean) required' }, ctx.version);
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
await ctx.userStore.setAdmin(targetUsername, body.admin);
|
|
859
|
+
writeJson(res, 200, { ok: true }, ctx.version);
|
|
860
|
+
} catch (err) {
|
|
861
|
+
writeJson(res, 404, { error: (err as Error).message }, ctx.version);
|
|
862
|
+
}
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ── Settings snapshot (JSON, for CLI `settings show`) ───────────
|
|
869
|
+
if (method === 'GET' && path === '/api/settings') {
|
|
870
|
+
const apiPort = Number(process.env['CROSSTALK_API_PORT']) || DEFAULT_API_PORT;
|
|
871
|
+
writeJson(res, 200, {
|
|
872
|
+
alias: ctx.alias,
|
|
873
|
+
version: ctx.version,
|
|
874
|
+
transportRoot: ctx.transportRoot,
|
|
875
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
876
|
+
pollSeconds: null,
|
|
877
|
+
apiPort,
|
|
878
|
+
}, ctx.version);
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// ── Workflow list / detail (JSON, for CLI `workflow status`) ────
|
|
883
|
+
if (method === 'GET' && path === '/api/workflows') {
|
|
884
|
+
writeJson(res, 200, {
|
|
885
|
+
workflows: collectWorkflowSummaries(ctx.transportRoot),
|
|
886
|
+
}, ctx.version);
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
{
|
|
890
|
+
const m = path.match(/^\/api\/workflows\/([^/]+)$/);
|
|
891
|
+
// The compose / submit endpoints use the same prefix but with
|
|
892
|
+
// literal verbs (compose, submit). Detail lookups use a UUID-shaped
|
|
893
|
+
// child channel id; reject the literal-verb cases so we fall
|
|
894
|
+
// through to the POST handlers below.
|
|
895
|
+
if (m && method === 'GET' && m[1] !== 'compose' && m[1] !== 'submit') {
|
|
896
|
+
const childUuid = decodeURIComponent(m[1]!);
|
|
897
|
+
const detail = collectWorkflowDetail(ctx.transportRoot, childUuid);
|
|
898
|
+
if (!detail) {
|
|
899
|
+
writeJson(res, 404, { error: 'workflow not found' }, ctx.version);
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
writeJson(res, 200, detail, ctx.version);
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ── Workflow compose ─────────────────────────────────────────────
|
|
908
|
+
//
|
|
909
|
+
// Operator types a plain-language prompt; engine invokes the compiler
|
|
910
|
+
// agent (defaults to first claimed model) with COMPILE_PROMPT + the
|
|
911
|
+
// prompt as the document. Compiler returns JSON matching WorkflowPlan;
|
|
912
|
+
// we YAML-stringify it for the preview UI. Operator reviews + edits
|
|
913
|
+
// the YAML, then submits it back to /api/workflows/submit which
|
|
914
|
+
// creates the workflow message + writes PLAN.json pre-compiled so the
|
|
915
|
+
// dispatcher skips its own compile step.
|
|
916
|
+
if (method === 'POST' && path === '/api/workflows/compose') {
|
|
917
|
+
const body = await readJson<{ prompt?: string; compilerAgent?: string }>(req);
|
|
918
|
+
if (typeof body.prompt !== 'string' || body.prompt.trim().length === 0) {
|
|
919
|
+
writeJson(res, 400, { error: 'prompt required' }, ctx.version);
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
// Resolve compiler agent. body.compilerAgent is the qualified model
|
|
923
|
+
// name (<provider>/<model>) as it appears in the claimed map.
|
|
924
|
+
let compiler: ModelEntry | undefined;
|
|
925
|
+
if (body.compilerAgent && ctx.claimed.has(body.compilerAgent)) {
|
|
926
|
+
compiler = ctx.claimed.get(body.compilerAgent);
|
|
927
|
+
} else {
|
|
928
|
+
compiler = ctx.claimed.values().next().value as ModelEntry | undefined;
|
|
929
|
+
}
|
|
930
|
+
if (!compiler) {
|
|
931
|
+
writeJson(res, 409, { error: 'no claimed model available to compile — check /agents' }, ctx.version);
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
const result = await invokeModelCli(compiler, COMPILE_PROMPT, body.prompt, {});
|
|
935
|
+
if (result.status !== 0) {
|
|
936
|
+
writeJson(res, 502, {
|
|
937
|
+
error: `compiler exit ${result.status}: ${result.stderr.slice(0, 500)}`,
|
|
938
|
+
stdout: result.stdout.slice(0, 800),
|
|
939
|
+
}, ctx.version);
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
const plan = extractPlanFromOutput(result.stdout);
|
|
943
|
+
if (!plan) {
|
|
944
|
+
writeJson(res, 422, {
|
|
945
|
+
error: 'compiler output did not parse as a workflow plan',
|
|
946
|
+
rawOutput: result.stdout.slice(0, 1500),
|
|
947
|
+
}, ctx.version);
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
const yaml = yamlStringify(plan, { indent: 2 });
|
|
951
|
+
writeJson(res, 200, { plan, yaml, compiler: compiler.name }, ctx.version);
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
if (method === 'POST' && path === '/api/workflows/submit') {
|
|
955
|
+
const body = await readJson<{ yaml?: string; channel?: string; prompt?: string }>(req);
|
|
956
|
+
if (typeof body.yaml !== 'string' || body.yaml.length === 0) {
|
|
957
|
+
writeJson(res, 400, { error: 'yaml required' }, ctx.version);
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
let parsed: unknown;
|
|
961
|
+
try {
|
|
962
|
+
parsed = yamlParse(body.yaml);
|
|
963
|
+
} catch (err) {
|
|
964
|
+
writeJson(res, 400, { error: `yaml parse error: ${(err as Error).message}` }, ctx.version);
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
if (!validatePlan(parsed)) {
|
|
968
|
+
writeJson(res, 400, { error: 'yaml does not match workflow plan schema (fanout.to/count/body + synthesize.to/body)' }, ctx.version);
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
const plan = parsed as WorkflowPlan;
|
|
972
|
+
let parentUuid: string;
|
|
973
|
+
try {
|
|
974
|
+
parentUuid = resolveChannelUuid(ctx.transportRoot, body.channel);
|
|
975
|
+
} catch (err) {
|
|
976
|
+
writeJson(res, (err as HttpError).status ?? 400, { error: (err as Error).message }, ctx.version);
|
|
977
|
+
return true;
|
|
978
|
+
}
|
|
979
|
+
// Create child channel + pre-write PLAN.json so the dispatcher skips
|
|
980
|
+
// its own compile (loadPlan returns non-null).
|
|
981
|
+
const childUuid = randomUUID();
|
|
982
|
+
const childDir = join(ctx.transportRoot, 'data', 'channels', childUuid);
|
|
983
|
+
mkdirSync(childDir, { recursive: true });
|
|
984
|
+
writeFileSync(
|
|
985
|
+
join(childDir, 'CHANNEL.md'),
|
|
986
|
+
serializeFrontmatter({ name: `workflow-${Date.now()}`, parent: parentUuid }, ''),
|
|
987
|
+
);
|
|
988
|
+
writeFileSync(
|
|
989
|
+
join(childDir, 'PLAN.json'),
|
|
990
|
+
JSON.stringify(plan, null, 2) + '\n',
|
|
991
|
+
);
|
|
992
|
+
// Marker message body = operator's original prompt (for traceability).
|
|
993
|
+
const markerBody = typeof body.prompt === 'string' && body.prompt.length > 0
|
|
994
|
+
? body.prompt
|
|
995
|
+
: '(composed via web UI)';
|
|
996
|
+
const heartbeat = readHeartbeat(ctx.transportRoot);
|
|
997
|
+
const dispatchHost = heartbeat?.alias;
|
|
998
|
+
const extraFrontmatter: Record<string, unknown> = { child_channel: childUuid };
|
|
999
|
+
if (dispatchHost) extraFrontmatter['dispatch_host'] = dispatchHost;
|
|
1000
|
+
const relPath = writeMessageFile({
|
|
1001
|
+
transportRoot: ctx.transportRoot,
|
|
1002
|
+
channelUuid: parentUuid,
|
|
1003
|
+
from: me.username,
|
|
1004
|
+
to: 'workflow',
|
|
1005
|
+
body: markerBody,
|
|
1006
|
+
re: [],
|
|
1007
|
+
extraFrontmatter,
|
|
1008
|
+
workflow: true,
|
|
1009
|
+
});
|
|
1010
|
+
const push = gitCommitAndPush(
|
|
1011
|
+
ctx.transportRoot,
|
|
1012
|
+
`run(workflow): ${me.username} child ${childUuid.slice(0, 8)} (composed)`,
|
|
1013
|
+
);
|
|
1014
|
+
sendWakeSignal(ctx.transportRoot);
|
|
1015
|
+
if (!push.committed && push.error) {
|
|
1016
|
+
// Commit itself failed — must surface as 500 to caller.
|
|
1017
|
+
writeJson(res, 500, { error: `commit failed: ${push.error.slice(0, 300)}` }, ctx.version);
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
// Push failed but commit landed locally — proceed; commits sync
|
|
1021
|
+
// up the next time the remote becomes reachable.
|
|
1022
|
+
writeJson(res, 201, { childUuid, parentUuid, relPath, plan }, ctx.version);
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ── Logs ─────────────────────────────────────────────────────────
|
|
1027
|
+
//
|
|
1028
|
+
// GET /api/logs?since=<seq> → JSON snapshot of entries with seq > since
|
|
1029
|
+
// GET /api/logs/stream → SSE stream of new entries as they happen
|
|
1030
|
+
//
|
|
1031
|
+
// Both auth-gated like the rest of /api/*. Engine emits structured
|
|
1032
|
+
// events via dispatch.ts's log() function which tees into the
|
|
1033
|
+
// in-memory ring buffer (log-buffer.ts).
|
|
1034
|
+
if (method === 'GET' && path === '/api/logs') {
|
|
1035
|
+
const since = Number(u.searchParams.get('since') ?? 0);
|
|
1036
|
+
const entries = logBuffer.recent(Number.isFinite(since) ? since : 0);
|
|
1037
|
+
writeJson(res, 200, { entries, currentSeq: logBuffer.currentSeq }, ctx.version);
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
if (method === 'GET' && path === '/api/logs/stream') {
|
|
1041
|
+
// SSE: keep the connection open + push every new log entry as a
|
|
1042
|
+
// `data: <json>\n\n` frame. Cache-Control: no-cache is critical —
|
|
1043
|
+
// intermediaries that buffer text/event-stream would coalesce
|
|
1044
|
+
// frames and break live tail.
|
|
1045
|
+
res.writeHead(200, {
|
|
1046
|
+
'Content-Type': 'text/event-stream',
|
|
1047
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
1048
|
+
'Connection': 'keep-alive',
|
|
1049
|
+
'X-Crosstalk-Engine-Version': ctx.version,
|
|
1050
|
+
});
|
|
1051
|
+
// Initial snapshot so the browser has context immediately. The
|
|
1052
|
+
// since= query param replays only newer entries (used when the
|
|
1053
|
+
// client reconnects after a transient disconnect).
|
|
1054
|
+
const sinceParam = Number(u.searchParams.get('since') ?? 0);
|
|
1055
|
+
const since = Number.isFinite(sinceParam) ? sinceParam : 0;
|
|
1056
|
+
for (const entry of logBuffer.recent(since)) {
|
|
1057
|
+
try { res.write(`data: ${JSON.stringify(entry)}\n\n`); } catch { /* socket dead */ }
|
|
1058
|
+
}
|
|
1059
|
+
const writeEntry = (entry: LogEntry): void => {
|
|
1060
|
+
try { res.write(`data: ${JSON.stringify(entry)}\n\n`); }
|
|
1061
|
+
catch { /* socket closing */ }
|
|
1062
|
+
};
|
|
1063
|
+
const unsubscribe = logBuffer.subscribe(writeEntry);
|
|
1064
|
+
// Idle keep-alive every 15s so proxies don't reap the connection.
|
|
1065
|
+
const keepAlive = setInterval(() => {
|
|
1066
|
+
try { res.write(`: keep-alive\n\n`); } catch { /* closed */ }
|
|
1067
|
+
}, 15_000);
|
|
1068
|
+
req.on('close', () => { unsubscribe(); clearInterval(keepAlive); });
|
|
1069
|
+
return true;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Path matched the prefix but no method handler — 405.
|
|
1073
|
+
writeJson(res, 405, { error: `method ${method} not allowed on ${path}` }, ctx.version);
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Try to handle the request as an HTML route. Returns true if handled. */
|
|
1078
|
+
async function tryHtmlRoute(ctx: ApiContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
1079
|
+
const { method = 'GET', url = '/' } = req;
|
|
1080
|
+
if (method !== 'GET') return false;
|
|
1081
|
+
const u = new URL(url, 'http://localhost');
|
|
1082
|
+
const path = u.pathname;
|
|
1083
|
+
const host = hostString();
|
|
1084
|
+
|
|
1085
|
+
if (path === '/') {
|
|
1086
|
+
const hb = readHeartbeat(ctx.transportRoot);
|
|
1087
|
+
const cursor = readCursor(ctx.transportRoot);
|
|
1088
|
+
const channels = allChannelMeta(ctx.transportRoot);
|
|
1089
|
+
writeHtml(res, 200, dashboardPage({
|
|
1090
|
+
host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
|
|
1091
|
+
heartbeat: hb ? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias } : null,
|
|
1092
|
+
cursor: cursor ?? null,
|
|
1093
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
1094
|
+
channels: channels.map(c => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
|
|
1095
|
+
errorsLogged: countErrors(ctx.transportRoot),
|
|
1096
|
+
pendingWork: collectPendingWork(ctx.transportRoot, ctx.claimed),
|
|
1097
|
+
}), ctx.version);
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (path === '/c') {
|
|
1102
|
+
const channels = allChannelMeta(ctx.transportRoot);
|
|
1103
|
+
writeHtml(res, 200, channelListPage({
|
|
1104
|
+
host, alias: ctx.alias,
|
|
1105
|
+
channels: channels.map(c => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
|
|
1106
|
+
}), ctx.version);
|
|
1107
|
+
return true;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const channelMatch = path.match(/^\/c\/([^/]+)(?:\/(.+))?$/);
|
|
1111
|
+
if (channelMatch) {
|
|
1112
|
+
const handle = decodeURIComponent(channelMatch[1]!);
|
|
1113
|
+
const sub = channelMatch[2];
|
|
1114
|
+
const meta = resolveChannelHandle(ctx.transportRoot, handle);
|
|
1115
|
+
if (!meta) {
|
|
1116
|
+
writeHtml(res, 404, channelListPage({
|
|
1117
|
+
host, alias: ctx.alias,
|
|
1118
|
+
channels: allChannelMeta(ctx.transportRoot).map(c => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
|
|
1119
|
+
}), ctx.version);
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
if (sub === 'messages.json') {
|
|
1123
|
+
const messages = collectBusMessages(ctx.transportRoot, meta.uuid);
|
|
1124
|
+
writeJson(res, 200, { messages }, ctx.version);
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
if (sub === undefined) {
|
|
1128
|
+
const messages = collectBusMessages(ctx.transportRoot, meta.uuid);
|
|
1129
|
+
writeHtml(res, 200, channelViewPage({
|
|
1130
|
+
host, alias: ctx.alias,
|
|
1131
|
+
channelHandle: handle, channelUuid: meta.uuid, channelName: meta.name, messages,
|
|
1132
|
+
}), ctx.version);
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (path === '/events') {
|
|
1138
|
+
// Server-Sent Events stream: emits status snapshots whenever the
|
|
1139
|
+
// dispatcher cursor or heartbeat changes. Dashboard subscribes
|
|
1140
|
+
// instead of polling every 5s. Headers per SSE spec; we keep the
|
|
1141
|
+
// connection open and write `data: <json>\n\n` frames.
|
|
1142
|
+
res.writeHead(200, {
|
|
1143
|
+
'Content-Type': 'text/event-stream',
|
|
1144
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
1145
|
+
'Connection': 'keep-alive',
|
|
1146
|
+
'X-Crosstalk-Engine-Version': ctx.version,
|
|
1147
|
+
});
|
|
1148
|
+
let lastCursor: string | null = readCursor(ctx.transportRoot) ?? null;
|
|
1149
|
+
let lastHb: string | null = readHeartbeat(ctx.transportRoot)?.ts ?? null;
|
|
1150
|
+
const emit = (event: string, data: unknown): void => {
|
|
1151
|
+
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }
|
|
1152
|
+
catch { /* socket closed */ }
|
|
1153
|
+
};
|
|
1154
|
+
// Initial snapshot so the client has state immediately.
|
|
1155
|
+
emit('snapshot', {
|
|
1156
|
+
cursor: lastCursor,
|
|
1157
|
+
heartbeat: readHeartbeat(ctx.transportRoot),
|
|
1158
|
+
channels: allChannelMeta(ctx.transportRoot).length,
|
|
1159
|
+
errors: countErrors(ctx.transportRoot),
|
|
1160
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
1161
|
+
});
|
|
1162
|
+
const tick = setInterval(() => {
|
|
1163
|
+
const hb = readHeartbeat(ctx.transportRoot);
|
|
1164
|
+
const cur = readCursor(ctx.transportRoot) ?? null;
|
|
1165
|
+
const hbTs = hb?.ts ?? null;
|
|
1166
|
+
if (cur !== lastCursor || hbTs !== lastHb) {
|
|
1167
|
+
lastCursor = cur; lastHb = hbTs;
|
|
1168
|
+
emit('update', {
|
|
1169
|
+
cursor: cur,
|
|
1170
|
+
heartbeat: hb,
|
|
1171
|
+
channels: allChannelMeta(ctx.transportRoot).length,
|
|
1172
|
+
errors: countErrors(ctx.transportRoot),
|
|
1173
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
1174
|
+
});
|
|
1175
|
+
} else {
|
|
1176
|
+
// Keep-alive comment frame every tick when idle (the spec allows
|
|
1177
|
+
// \`:\` lines; intermediate proxies that buffer SSE will release
|
|
1178
|
+
// them, holding the connection open across long idle windows).
|
|
1179
|
+
try { res.write(`: keep-alive\n\n`); } catch { /* closed */ }
|
|
1180
|
+
}
|
|
1181
|
+
}, 2000);
|
|
1182
|
+
req.on('close', () => { clearInterval(tick); });
|
|
1183
|
+
return true;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (path === '/w') {
|
|
1187
|
+
const channels = allChannelMeta(ctx.transportRoot)
|
|
1188
|
+
.map((c) => ({ uuid: c.uuid, name: c.name }));
|
|
1189
|
+
writeHtml(res, 200, workflowsListPage({
|
|
1190
|
+
host, alias: ctx.alias,
|
|
1191
|
+
workflows: collectWorkflowSummaries(ctx.transportRoot),
|
|
1192
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
1193
|
+
channels,
|
|
1194
|
+
}), ctx.version);
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1197
|
+
const wMatch = path.match(/^\/w\/([^/]+)$/);
|
|
1198
|
+
if (wMatch) {
|
|
1199
|
+
const childUuid = decodeURIComponent(wMatch[1]!);
|
|
1200
|
+
const detail = collectWorkflowDetail(ctx.transportRoot, childUuid);
|
|
1201
|
+
if (!detail) {
|
|
1202
|
+
writeHtml(res, 404, workflowsListPage({
|
|
1203
|
+
host, alias: ctx.alias,
|
|
1204
|
+
workflows: collectWorkflowSummaries(ctx.transportRoot),
|
|
1205
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
1206
|
+
channels: allChannelMeta(ctx.transportRoot).map((c) => ({ uuid: c.uuid, name: c.name })),
|
|
1207
|
+
}), ctx.version);
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1210
|
+
writeHtml(res, 200, workflowDetailPage({ host, alias: ctx.alias, ...detail }), ctx.version);
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (path === '/agents') {
|
|
1215
|
+
const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
|
|
1216
|
+
const yamlReferenced = [...new Set([...ctx.registry.all.values()].map((e) => e.cli))];
|
|
1217
|
+
const claimed = [...new Set([...ctx.claimed.values()].map((e) => e.cli))];
|
|
1218
|
+
writeHtml(res, 200, agentsPage({
|
|
1219
|
+
host, alias: ctx.alias, installed, known: KNOWN_AGENT_BINARIES, yamlReferenced, claimed,
|
|
1220
|
+
}), ctx.version);
|
|
1221
|
+
return true;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (path === '/chat') {
|
|
1225
|
+
const installed = KNOWN_AGENT_BINARIES.filter((bin) => isOnPath(bin));
|
|
1226
|
+
writeHtml(res, 200, chatPage({
|
|
1227
|
+
host, alias: ctx.alias, installedAgents: installed, version: ctx.version,
|
|
1228
|
+
home: homedir(),
|
|
1229
|
+
}), ctx.version);
|
|
1230
|
+
return true;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Auth-aware stub pages. authenticateRequest is a no-op (returns
|
|
1234
|
+
// notOk) when the stores aren't configured — the gate above already
|
|
1235
|
+
// short-circuited unauthed access, so by the time we get here we
|
|
1236
|
+
// know the user is authed in normal operation.
|
|
1237
|
+
if (path === '/account') {
|
|
1238
|
+
let user = null;
|
|
1239
|
+
if (ctx.userStore && ctx.tokenStore) {
|
|
1240
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
1241
|
+
if (auth.ok) user = auth.user;
|
|
1242
|
+
}
|
|
1243
|
+
writeHtml(res, 200, accountPage({ host, alias: ctx.alias, user }), ctx.version);
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
if (path === '/users') {
|
|
1247
|
+
let users: import('./auth/users.js').User[] = [];
|
|
1248
|
+
let isAdmin = false;
|
|
1249
|
+
let viewerUsername: string | null = null;
|
|
1250
|
+
if (ctx.userStore && ctx.tokenStore) {
|
|
1251
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
1252
|
+
if (auth.ok) { isAdmin = auth.user.admin; viewerUsername = auth.user.username; }
|
|
1253
|
+
if (isAdmin) users = await ctx.userStore.listUsers();
|
|
1254
|
+
}
|
|
1255
|
+
writeHtml(res, 200, usersPage({ host, alias: ctx.alias, users, viewerIsAdmin: isAdmin, viewerUsername }), ctx.version);
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
if (path === '/tokens') {
|
|
1259
|
+
let tokens: import('./auth/tokens.js').IdentityToken[] = [];
|
|
1260
|
+
let viewerUsername: string | null = null;
|
|
1261
|
+
if (ctx.userStore && ctx.tokenStore) {
|
|
1262
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
1263
|
+
if (auth.ok) {
|
|
1264
|
+
viewerUsername = auth.user.username;
|
|
1265
|
+
tokens = await ctx.tokenStore.list(auth.user.username);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
writeHtml(res, 200, tokensPage({ host, alias: ctx.alias, tokens, viewerUsername }), ctx.version);
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
if (path === '/logs') {
|
|
1272
|
+
writeHtml(res, 200, logsPage({
|
|
1273
|
+
host, alias: ctx.alias, errorsLogged: countErrors(ctx.transportRoot),
|
|
1274
|
+
transportRoot: ctx.transportRoot,
|
|
1275
|
+
}), ctx.version);
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
if (path === '/settings') {
|
|
1279
|
+
const port = Number(process.env['CROSSTALK_API_PORT']) || DEFAULT_API_PORT;
|
|
1280
|
+
writeHtml(res, 200, settingsPage({
|
|
1281
|
+
host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
|
|
1282
|
+
claimedModels: [...ctx.claimed.keys()],
|
|
1283
|
+
pollSeconds: null, // dispatcher cadence isn't on ctx yet; nullable so the UI shows '—'
|
|
1284
|
+
apiPort: port,
|
|
1285
|
+
}), ctx.version);
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
if (path === '/about') {
|
|
1289
|
+
writeHtml(res, 200, aboutPage({
|
|
1290
|
+
host, alias: ctx.alias, version: ctx.version, transportRoot: ctx.transportRoot,
|
|
1291
|
+
}), ctx.version);
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Build a summary row for every workflow marker (open or complete) on the bus. */
|
|
1299
|
+
function collectWorkflowSummaries(transportRoot: string): WorkflowSummary[] {
|
|
1300
|
+
const channels = discoverChannels(transportRoot);
|
|
1301
|
+
const out: WorkflowSummary[] = [];
|
|
1302
|
+
for (const parentUuid of channels) {
|
|
1303
|
+
const parentMeta = readChannelMeta(transportRoot, parentUuid);
|
|
1304
|
+
const parentMessages = listChannelMessages(transportRoot, parentUuid);
|
|
1305
|
+
for (const m of parentMessages) {
|
|
1306
|
+
if (m.data['type'] !== 'workflow') continue;
|
|
1307
|
+
const childUuid = m.data['child_channel'];
|
|
1308
|
+
if (typeof childUuid !== 'string') continue;
|
|
1309
|
+
out.push(buildWorkflowSummary(transportRoot, parentMeta, childUuid, m));
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
// Newest workflow markers first.
|
|
1313
|
+
out.sort((a, b) => (b.markerTimestamp || '').localeCompare(a.markerTimestamp || ''));
|
|
1314
|
+
return out;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function buildWorkflowSummary(
|
|
1318
|
+
transportRoot: string,
|
|
1319
|
+
parentMeta: ChannelMeta,
|
|
1320
|
+
childUuid: string,
|
|
1321
|
+
marker: { data: Record<string, unknown>; relPath: string; body: string },
|
|
1322
|
+
): WorkflowSummary {
|
|
1323
|
+
const completeFile = join(transportRoot, 'data', 'channels', childUuid, 'COMPLETE');
|
|
1324
|
+
const planFile = join(transportRoot, 'data', 'channels', childUuid, 'PLAN.json');
|
|
1325
|
+
const isComplete = existsSync(completeFile);
|
|
1326
|
+
const childMessages = existsSync(join(transportRoot, 'data', 'channels', childUuid))
|
|
1327
|
+
? listChannelMessages(transportRoot, childUuid)
|
|
1328
|
+
: [];
|
|
1329
|
+
|
|
1330
|
+
// Count fanout dispatches + their replies.
|
|
1331
|
+
const fanoutDispatches = childMessages.filter((m) => m.data['workflow_phase'] === 'fanout');
|
|
1332
|
+
const fanoutDispatchPaths = new Set(fanoutDispatches.map((m) => m.relPath));
|
|
1333
|
+
const fanoutReplies = childMessages.filter((m) => {
|
|
1334
|
+
const re = m.data['re'];
|
|
1335
|
+
const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
|
|
1336
|
+
return list.some((p) => fanoutDispatchPaths.has(p));
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
// Failed: any fanout or synthesis reply with failed:true, OR no plan after long enough.
|
|
1340
|
+
const anyFailedReply = childMessages.some((m) => m.data['failed'] === true);
|
|
1341
|
+
|
|
1342
|
+
let phase: WorkflowSummary['phase'];
|
|
1343
|
+
if (isComplete) phase = 'complete';
|
|
1344
|
+
else if (anyFailedReply) phase = 'failed';
|
|
1345
|
+
else if (!existsSync(planFile)) phase = 'pending_compile';
|
|
1346
|
+
else {
|
|
1347
|
+
// Plan exists. Check if synthesis phase has started.
|
|
1348
|
+
const synth = childMessages.find((m) => m.data['workflow_phase'] === 'synthesize');
|
|
1349
|
+
phase = synth ? 'synthesize' : 'fanout';
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const dispatchHost = typeof marker.data['dispatch_host'] === 'string'
|
|
1353
|
+
? (marker.data['dispatch_host'] as string)
|
|
1354
|
+
: null;
|
|
1355
|
+
|
|
1356
|
+
return {
|
|
1357
|
+
childChannelUuid: childUuid,
|
|
1358
|
+
parentChannelUuid: parentMeta.uuid,
|
|
1359
|
+
parentChannelName: parentMeta.name,
|
|
1360
|
+
markerFrom: String(marker.data['from'] ?? 'unknown'),
|
|
1361
|
+
markerTimestamp: String(marker.data['timestamp'] ?? ''),
|
|
1362
|
+
phase,
|
|
1363
|
+
fanoutTotal: fanoutDispatches.length,
|
|
1364
|
+
fanoutReplied: fanoutReplies.length,
|
|
1365
|
+
dispatchHost,
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** Full detail for one workflow. Returns null if no matching marker found. */
|
|
1370
|
+
function collectWorkflowDetail(transportRoot: string, childUuid: string):
|
|
1371
|
+
Omit<Parameters<typeof workflowDetailPage>[0], 'host' | 'alias'> | null {
|
|
1372
|
+
const channels = discoverChannels(transportRoot);
|
|
1373
|
+
// Find the parent — the channel that contains the workflow marker
|
|
1374
|
+
// pointing at this child uuid.
|
|
1375
|
+
let markerData: Record<string, unknown> | null = null;
|
|
1376
|
+
let markerBody = '';
|
|
1377
|
+
let markerRelPath = '';
|
|
1378
|
+
let parentMeta: ChannelMeta | null = null;
|
|
1379
|
+
for (const parentUuid of channels) {
|
|
1380
|
+
const parentMessages = listChannelMessages(transportRoot, parentUuid);
|
|
1381
|
+
for (const m of parentMessages) {
|
|
1382
|
+
if (m.data['type'] === 'workflow' && m.data['child_channel'] === childUuid) {
|
|
1383
|
+
markerData = m.data;
|
|
1384
|
+
markerBody = m.body;
|
|
1385
|
+
markerRelPath = m.relPath;
|
|
1386
|
+
parentMeta = readChannelMeta(transportRoot, parentUuid);
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (markerData) break;
|
|
1391
|
+
}
|
|
1392
|
+
if (!markerData || !parentMeta) return null;
|
|
1393
|
+
|
|
1394
|
+
const planFile = join(transportRoot, 'data', 'channels', childUuid, 'PLAN.json');
|
|
1395
|
+
let plan: unknown = null;
|
|
1396
|
+
if (existsSync(planFile)) {
|
|
1397
|
+
try {
|
|
1398
|
+
const parsed = JSON.parse(readFileSync(planFile, 'utf-8')) as unknown;
|
|
1399
|
+
plan = validatePlan(parsed) ? parsed : { invalid: true, raw: parsed };
|
|
1400
|
+
} catch { plan = null; }
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const childMessages = existsSync(join(transportRoot, 'data', 'channels', childUuid))
|
|
1404
|
+
? listChannelMessages(transportRoot, childUuid)
|
|
1405
|
+
: [];
|
|
1406
|
+
|
|
1407
|
+
const fanoutDispatches = childMessages
|
|
1408
|
+
.filter((m) => m.data['workflow_phase'] === 'fanout')
|
|
1409
|
+
.map((m) => ({
|
|
1410
|
+
relPath: m.relPath,
|
|
1411
|
+
to: Array.isArray(m.data['to']) ? (m.data['to'] as string[]).join(',') : String(m.data['to'] ?? ''),
|
|
1412
|
+
body: m.body,
|
|
1413
|
+
}));
|
|
1414
|
+
const fanoutDispatchPaths = new Set(fanoutDispatches.map((d) => d.relPath));
|
|
1415
|
+
const fanoutReplies = childMessages
|
|
1416
|
+
.filter((m) => {
|
|
1417
|
+
const re = m.data['re'];
|
|
1418
|
+
const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
|
|
1419
|
+
return list.some((p) => fanoutDispatchPaths.has(p));
|
|
1420
|
+
})
|
|
1421
|
+
.map((m) => ({
|
|
1422
|
+
relPath: m.relPath,
|
|
1423
|
+
from: String(m.data['from'] ?? 'unknown'),
|
|
1424
|
+
re: (m.data['re'] as string | string[]) ?? '',
|
|
1425
|
+
body: m.body,
|
|
1426
|
+
failed: m.data['failed'] === true,
|
|
1427
|
+
}));
|
|
1428
|
+
|
|
1429
|
+
const synthDispatchMsg = childMessages.find((m) => m.data['workflow_phase'] === 'synthesize');
|
|
1430
|
+
const synthesisDispatch = synthDispatchMsg
|
|
1431
|
+
? {
|
|
1432
|
+
relPath: synthDispatchMsg.relPath,
|
|
1433
|
+
to: Array.isArray(synthDispatchMsg.data['to']) ? (synthDispatchMsg.data['to'] as string[]).join(',') : String(synthDispatchMsg.data['to'] ?? ''),
|
|
1434
|
+
body: synthDispatchMsg.body,
|
|
1435
|
+
}
|
|
1436
|
+
: null;
|
|
1437
|
+
const synthesisReplyMsg = synthDispatchMsg
|
|
1438
|
+
? childMessages.find((m) => {
|
|
1439
|
+
const re = m.data['re'];
|
|
1440
|
+
const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
|
|
1441
|
+
return list.includes(synthDispatchMsg.relPath);
|
|
1442
|
+
})
|
|
1443
|
+
: undefined;
|
|
1444
|
+
const synthesisReply = synthesisReplyMsg
|
|
1445
|
+
? {
|
|
1446
|
+
relPath: synthesisReplyMsg.relPath,
|
|
1447
|
+
from: String(synthesisReplyMsg.data['from'] ?? 'unknown'),
|
|
1448
|
+
body: synthesisReplyMsg.body,
|
|
1449
|
+
failed: synthesisReplyMsg.data['failed'] === true,
|
|
1450
|
+
}
|
|
1451
|
+
: null;
|
|
1452
|
+
|
|
1453
|
+
const completeFile = join(transportRoot, 'data', 'channels', childUuid, 'COMPLETE');
|
|
1454
|
+
const isComplete = existsSync(completeFile);
|
|
1455
|
+
const anyFailed = childMessages.some((m) => m.data['failed'] === true);
|
|
1456
|
+
let phase: WorkflowSummary['phase'];
|
|
1457
|
+
if (isComplete) phase = 'complete';
|
|
1458
|
+
else if (anyFailed) phase = 'failed';
|
|
1459
|
+
else if (!existsSync(planFile)) phase = 'pending_compile';
|
|
1460
|
+
else if (synthDispatchMsg) phase = 'synthesize';
|
|
1461
|
+
else phase = 'fanout';
|
|
1462
|
+
|
|
1463
|
+
// Reference markerRelPath so unused-var checks pass; also useful for
|
|
1464
|
+
// future "open the marker message" deep-links from the detail page.
|
|
1465
|
+
void markerRelPath;
|
|
1466
|
+
|
|
1467
|
+
return {
|
|
1468
|
+
childUuid,
|
|
1469
|
+
parentChannelUuid: parentMeta.uuid,
|
|
1470
|
+
parentChannelName: parentMeta.name,
|
|
1471
|
+
markerFrom: String(markerData['from'] ?? 'unknown'),
|
|
1472
|
+
markerTimestamp: String(markerData['timestamp'] ?? ''),
|
|
1473
|
+
markerBody,
|
|
1474
|
+
plan,
|
|
1475
|
+
phase,
|
|
1476
|
+
fanoutDispatches,
|
|
1477
|
+
fanoutReplies,
|
|
1478
|
+
synthesisDispatch,
|
|
1479
|
+
synthesisReply,
|
|
1480
|
+
dispatchHost: typeof markerData['dispatch_host'] === 'string' ? (markerData['dispatch_host'] as string) : null,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Walk every channel and count messages with no reply pointing at
|
|
1486
|
+
* them, grouped by recipient. Powers the dashboard's "pending work"
|
|
1487
|
+
* card — gives operators an at-a-glance view of what the dispatcher
|
|
1488
|
+
* will be picking up on its next tick(s).
|
|
1489
|
+
*
|
|
1490
|
+
* O(N) over all messages on the bus; cheap for the typical capstone-
|
|
1491
|
+
* scale transport, would need indexing if we ever push past ~10k.
|
|
1492
|
+
*/
|
|
1493
|
+
function collectPendingWork(
|
|
1494
|
+
transportRoot: string,
|
|
1495
|
+
claimed: Map<string, ModelEntry>,
|
|
1496
|
+
): { to: string; count: number; channelLabels: string[] }[] {
|
|
1497
|
+
const channels = discoverChannels(transportRoot);
|
|
1498
|
+
const acc = new Map<string, { count: number; channels: Set<string> }>();
|
|
1499
|
+
// A recipient is dispatcher-actionable if some claimed model name
|
|
1500
|
+
// matches the actor portion (before '@'). Anything else (humans,
|
|
1501
|
+
// unclaimed models, agents on other hosts) won't be picked up by
|
|
1502
|
+
// this dispatcher's next tick, so it shouldn't count as pending.
|
|
1503
|
+
const claimedActors = new Set([...claimed.keys()]);
|
|
1504
|
+
function isClaimable(recipient: string): boolean {
|
|
1505
|
+
if (recipient === 'all') return true;
|
|
1506
|
+
const at = recipient.indexOf('@');
|
|
1507
|
+
const actor = at === -1 ? recipient : recipient.slice(0, at);
|
|
1508
|
+
return claimedActors.has(actor);
|
|
1509
|
+
}
|
|
1510
|
+
for (const channelUuid of channels) {
|
|
1511
|
+
const meta = readChannelMeta(transportRoot, channelUuid);
|
|
1512
|
+
const label = meta.name ?? channelUuid.slice(0, 8) + '…';
|
|
1513
|
+
const messages = listChannelMessages(transportRoot, channelUuid);
|
|
1514
|
+
const replied = new Set<string>();
|
|
1515
|
+
for (const m of messages) {
|
|
1516
|
+
const re = m.data['re'];
|
|
1517
|
+
const list = Array.isArray(re) ? re : (typeof re === 'string' ? [re] : []);
|
|
1518
|
+
for (const t of list) replied.add(t as string);
|
|
1519
|
+
}
|
|
1520
|
+
for (const m of messages) {
|
|
1521
|
+
// Skip workflow markers — they live in the Workflows surface.
|
|
1522
|
+
if (m.data['type'] === 'workflow') continue;
|
|
1523
|
+
// Skip messages internal to a workflow run. Once a fanout
|
|
1524
|
+
// dispatch is sent, its reply is consumed by the workflow
|
|
1525
|
+
// runtime advancing phase — not by another message being
|
|
1526
|
+
// posted in reply. Counting them as pending overstates the
|
|
1527
|
+
// dispatcher's actual queue.
|
|
1528
|
+
if (typeof m.data['workflow_phase'] === 'string') continue;
|
|
1529
|
+
// Skip messages SENT BY the workflow runtime (workflow@<alias>)
|
|
1530
|
+
// — same reason; runtime-managed, not bus-pending.
|
|
1531
|
+
const from = String(m.data['from'] ?? '');
|
|
1532
|
+
if (from.startsWith('workflow@')) continue;
|
|
1533
|
+
if (replied.has(m.relPath)) continue;
|
|
1534
|
+
const to = m.data['to'];
|
|
1535
|
+
const recipients = Array.isArray(to) ? to : (typeof to === 'string' ? [to] : []);
|
|
1536
|
+
for (const r of recipients) {
|
|
1537
|
+
const recipient = String(r);
|
|
1538
|
+
// Skip recipients this dispatcher can't service. Humans
|
|
1539
|
+
// (named users), unclaimed models, or models claimed on a
|
|
1540
|
+
// different host — none of these will be picked up.
|
|
1541
|
+
if (!isClaimable(recipient)) continue;
|
|
1542
|
+
if (!acc.has(recipient)) acc.set(recipient, { count: 0, channels: new Set() });
|
|
1543
|
+
const e = acc.get(recipient)!;
|
|
1544
|
+
e.count++;
|
|
1545
|
+
e.channels.add(label);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return Array.from(acc.entries())
|
|
1550
|
+
.map(([to, e]) => ({ to, count: e.count, channelLabels: Array.from(e.channels) }))
|
|
1551
|
+
.sort((a, b) => b.count - a.count);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** Pull all bus messages for a channel into the shape the web UI consumes. */
|
|
1555
|
+
function collectBusMessages(transportRoot: string, channelUuid: string): BusMessage[] {
|
|
1556
|
+
const raw = listChannelMessages(transportRoot, channelUuid);
|
|
1557
|
+
return raw.map((m) => ({
|
|
1558
|
+
relPath: m.relPath,
|
|
1559
|
+
from: String(m.data['from'] ?? ''),
|
|
1560
|
+
to: (m.data['to'] as string | string[]) ?? '',
|
|
1561
|
+
re: (m.data['re'] as string | string[] | null) ?? null,
|
|
1562
|
+
body: m.body,
|
|
1563
|
+
timestamp: String(m.data['timestamp'] ?? ''),
|
|
1564
|
+
failed: m.data['failed'] === true,
|
|
1565
|
+
}));
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// ── router ───────────────────────────────────────────────────────────
|
|
1569
|
+
|
|
1570
|
+
async function route(ctx: ApiContext, req: IncomingMessage): Promise<JsonRes> {
|
|
1571
|
+
const { method = 'GET', url = '/' } = req;
|
|
1572
|
+
const u = new URL(url, 'http://localhost');
|
|
1573
|
+
const path = u.pathname;
|
|
1574
|
+
|
|
1575
|
+
if (method === 'GET' && path === '/healthz') return handleHealthz(ctx);
|
|
1576
|
+
if (method === 'GET' && path === '/version') return handleVersion(ctx);
|
|
1577
|
+
if (method === 'GET' && path === '/status') return handleStatus(ctx);
|
|
1578
|
+
if (method === 'GET' && path === '/agents/installed') return handleAgentsInstalled(ctx);
|
|
1579
|
+
if (method === 'GET' && path === '/channels') return handleListChannels(ctx);
|
|
1580
|
+
if (method === 'POST' && path === '/channels') {
|
|
1581
|
+
const body = await readJson<CreateChannelReq>(req);
|
|
1582
|
+
return handleCreateChannel(ctx, body);
|
|
1583
|
+
}
|
|
1584
|
+
// PATCH /channels/<handle> — rename
|
|
1585
|
+
// DELETE /channels/<handle>?confirm=<typed-name> — delete
|
|
1586
|
+
{
|
|
1587
|
+
const m = path.match(/^\/channels\/([^/]+)$/);
|
|
1588
|
+
if (m) {
|
|
1589
|
+
const handle = decodeURIComponent(m[1]!);
|
|
1590
|
+
if (method === 'PATCH') {
|
|
1591
|
+
const body = await readJson<RenameChannelReq>(req);
|
|
1592
|
+
return handleRenameChannel(ctx, handle, body);
|
|
1593
|
+
}
|
|
1594
|
+
if (method === 'DELETE') {
|
|
1595
|
+
return handleDeleteChannel(ctx, handle, u.searchParams.get('confirm'));
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (method === 'POST' && path === '/messages') {
|
|
1600
|
+
const body = await readJson<PostMessageReq>(req);
|
|
1601
|
+
return handlePostMessage(ctx, body);
|
|
1602
|
+
}
|
|
1603
|
+
if (method === 'GET' && path === '/replies') {
|
|
1604
|
+
const targets = u.searchParams.get('relPaths') ?? '';
|
|
1605
|
+
return handleReplies(ctx, targets);
|
|
1606
|
+
}
|
|
1607
|
+
return { status: 404, body: { error: `no route for ${method} ${path}` } };
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// ── server lifecycle ─────────────────────────────────────────────────
|
|
1611
|
+
|
|
1612
|
+
export const DEFAULT_API_PORT = 7000;
|
|
1613
|
+
|
|
1614
|
+
export interface StartApiOptions {
|
|
1615
|
+
port?: number;
|
|
1616
|
+
log: (event: string, fields?: Record<string, unknown>) => void;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
export function startApi(ctx: ApiContext, opts: StartApiOptions): Server {
|
|
1620
|
+
const port = opts.port ?? (Number(process.env['CROSSTALK_API_PORT']) || DEFAULT_API_PORT);
|
|
1621
|
+
// Bind address resolution:
|
|
1622
|
+
// - Native (default): 127.0.0.1 — engine running as a host process
|
|
1623
|
+
// should not expose the API to the network. Localhost only.
|
|
1624
|
+
// - Container: 0.0.0.0 (set via ENV CROSSTALK_API_BIND in
|
|
1625
|
+
// the Dockerfile) — Docker's bridge networking means "127.0.0.1
|
|
1626
|
+
// inside the container" can't be reached via the daemon's port
|
|
1627
|
+
// forwarder. The actual security boundary moves up to compose's
|
|
1628
|
+
// port mapping (`127.0.0.1:HOST:CONTAINER`), which restricts
|
|
1629
|
+
// reachability to host loopback only.
|
|
1630
|
+
//
|
|
1631
|
+
// The engine bind address is therefore env-controlled, BUT operators
|
|
1632
|
+
// never need to set it manually — the container image does it.
|
|
1633
|
+
const bind = process.env['CROSSTALK_API_BIND'] ?? '127.0.0.1';
|
|
1634
|
+
const server = createServer(async (req, res) => {
|
|
1635
|
+
try {
|
|
1636
|
+
// v8 auth gate for web routes. Either handles the request fully
|
|
1637
|
+
// (setup wizard, login, logout, redirect to /setup or /login),
|
|
1638
|
+
// or returns 'allow' to continue to the HTML / JSON routers.
|
|
1639
|
+
const authOutcome = await tryWebAuthFlow(ctx, req, res);
|
|
1640
|
+
if (authOutcome === 'handled' || authOutcome === 'redirect') return;
|
|
1641
|
+
|
|
1642
|
+
// Web-side JSON API (token mgmt, account passphrase, user
|
|
1643
|
+
// admin). Runs before tryHtmlRoute since paths start with /api.
|
|
1644
|
+
if (await tryWebApiRoute(ctx, req, res)) return;
|
|
1645
|
+
|
|
1646
|
+
// Try HTML routes first (/, /c, /c/<handle>, /agents). The HTML
|
|
1647
|
+
// surface is GET-only and lives at paths distinct from the JSON
|
|
1648
|
+
// API, so no conflicts. If no HTML route matches, fall through to
|
|
1649
|
+
// the JSON router (which handles the existing API surface).
|
|
1650
|
+
if (await tryHtmlRoute(ctx, req, res)) return;
|
|
1651
|
+
|
|
1652
|
+
// v8 operational-API auth gate. The historical "if you're on the
|
|
1653
|
+
// host you're trusted" model is replaced by uniform bearer-token
|
|
1654
|
+
// auth — CLI commands authenticate the same way the web UI does.
|
|
1655
|
+
// /healthz and /version stay open as monitoring probes (standard
|
|
1656
|
+
// pattern — kubelet/loadbalancer needs to ping them).
|
|
1657
|
+
if (ctx.userStore && ctx.tokenStore) {
|
|
1658
|
+
const u = new URL(req.url ?? '/', 'http://localhost');
|
|
1659
|
+
const path = u.pathname;
|
|
1660
|
+
const isExempt = path === '/healthz' || path === '/version';
|
|
1661
|
+
if (!isExempt) {
|
|
1662
|
+
// If setup hasn't run yet, return a clear "setup needed"
|
|
1663
|
+
// response with the URL — operators see this immediately
|
|
1664
|
+
// when their first CLI call fails after engine boot.
|
|
1665
|
+
const setup = await setupGate(ctx.userStore, path);
|
|
1666
|
+
if (!setup.allow) {
|
|
1667
|
+
writeJson(res, setup.status ?? 503, setup.body ?? { error: 'setup needed' }, ctx.version);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
const auth = await authenticateRequest(req, { tokenStore: ctx.tokenStore, userStore: ctx.userStore });
|
|
1671
|
+
if (!auth.ok) {
|
|
1672
|
+
// Body includes a hint operators can act on: run
|
|
1673
|
+
// 'crosstalk auth login' to bootstrap a token.
|
|
1674
|
+
writeJson(res, auth.status, {
|
|
1675
|
+
...(auth.body as Record<string, unknown>),
|
|
1676
|
+
hint: "run 'crosstalk auth login' to obtain a token",
|
|
1677
|
+
}, ctx.version);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const result = await route(ctx, req);
|
|
1684
|
+
writeJson(res, result.status, result.body, ctx.version);
|
|
1685
|
+
} catch (err) {
|
|
1686
|
+
if (err instanceof HttpError) {
|
|
1687
|
+
writeJson(res, err.status, { error: err.message }, ctx.version);
|
|
1688
|
+
} else {
|
|
1689
|
+
const msg = (err as Error).message;
|
|
1690
|
+
opts.log('api_handler_crash', { error: msg.slice(0, 300) });
|
|
1691
|
+
writeJson(res, 500, { error: `internal error: ${msg.slice(0, 200)}` }, ctx.version);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
});
|
|
1695
|
+
// Mount the chat pty WS bridge. Same auth model as the HTML routes —
|
|
1696
|
+
// if user/token stores are configured, the WS upgrade is gated by a
|
|
1697
|
+
// valid crosstalk_session cookie (or bearer token). Without auth
|
|
1698
|
+
// stores (legacy/loopback-only mode), the WS is open since the bind
|
|
1699
|
+
// address is the security boundary.
|
|
1700
|
+
mountChatPty(server, {
|
|
1701
|
+
authorize: ctx.userStore && ctx.tokenStore
|
|
1702
|
+
? async (req) => {
|
|
1703
|
+
const auth = await authenticateRequest(req, {
|
|
1704
|
+
tokenStore: ctx.tokenStore!,
|
|
1705
|
+
userStore: ctx.userStore!,
|
|
1706
|
+
});
|
|
1707
|
+
return auth.ok;
|
|
1708
|
+
}
|
|
1709
|
+
: undefined,
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
server.listen(port, bind, () => {
|
|
1713
|
+
opts.log('api_listening', { host: bind, port });
|
|
1714
|
+
});
|
|
1715
|
+
return server;
|
|
1716
|
+
}
|