@cordfuse/crosstalkd 7.0.0-alpha.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 +315 -0
- package/GUIDE-PROMPTS.md +107 -0
- package/README.md +118 -0
- package/bin/crosstalkd.js +101 -0
- package/package.json +48 -0
- package/src/activation.ts +104 -0
- package/src/api.ts +430 -0
- package/src/channel.ts +202 -0
- package/src/dispatch.ts +430 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +108 -0
- package/src/invoke.ts +148 -0
- package/src/models.ts +86 -0
- package/src/replies.ts +73 -0
- package/src/run.ts +236 -0
- package/src/state.ts +159 -0
- package/src/status.ts +84 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +236 -0
- package/src/workflow.ts +458 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +242 -0
- package/template/PROTOCOL.md +66 -0
- package/template/README.md +69 -0
- package/template/data/models.yaml +27 -0
- package/template/gitignore +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cordfuse/crosstalkd",
|
|
3
|
+
"version": "7.0.0-alpha.1",
|
|
4
|
+
"description": "Crosstalk daemon — the engine that runs inside the crosstalk-server container. Operators interact via @cordfuse/crosstalk on the host.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/cordfuse/crosstalk",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/cordfuse/crosstalk.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"crosstalkd": "./bin/crosstalkd.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"src/",
|
|
18
|
+
"template/",
|
|
19
|
+
"GUIDE-CLI.md",
|
|
20
|
+
"GUIDE-PROMPTS.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc --noEmit",
|
|
24
|
+
"lint": "tsc --noEmit",
|
|
25
|
+
"test": "bun test",
|
|
26
|
+
"prepack": "cp -r ../transport template && cp ../GUIDE-CLI.md ../GUIDE-PROMPTS.md .",
|
|
27
|
+
"postpack": "rm -rf template GUIDE-CLI.md GUIDE-PROMPTS.md"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"tsx": "^4.20.0",
|
|
31
|
+
"yaml": "^2.8.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"typescript": "^5.7.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"ai",
|
|
42
|
+
"agents",
|
|
43
|
+
"messaging",
|
|
44
|
+
"git",
|
|
45
|
+
"crosstalk",
|
|
46
|
+
"cordfuse"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The activation rule — pure functions, no I/O, exhaustively unit-tested.
|
|
2
|
+
//
|
|
3
|
+
// One rule (CROSSTALK.md "Activation"):
|
|
4
|
+
//
|
|
5
|
+
// A message wakes its addressee if it has no `re:` (a new task), or its
|
|
6
|
+
// `re:` points at a message the addressee sent.
|
|
7
|
+
//
|
|
8
|
+
// `re:` is written by the runtime at send time, never inferred at read
|
|
9
|
+
// time. It is a string or a list: a reply that answers a batch of N
|
|
10
|
+
// messages records ALL N relPaths, so no answered message is ever lost to
|
|
11
|
+
// batching.
|
|
12
|
+
|
|
13
|
+
export interface ActivationMessage {
|
|
14
|
+
from: string;
|
|
15
|
+
to: string[];
|
|
16
|
+
re?: string | string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recipients(toField: unknown): string[] {
|
|
20
|
+
if (Array.isArray(toField)) return toField.map(String);
|
|
21
|
+
if (typeof toField === 'string') return [toField];
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function reList(reField: unknown): string[] {
|
|
26
|
+
if (Array.isArray(reField)) return reField.map(String);
|
|
27
|
+
if (typeof reField === 'string') return [reField];
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A recipient is `actor` or `actor@host`.
|
|
32
|
+
export function extractActor(recipient: string): string {
|
|
33
|
+
const at = recipient.indexOf('@');
|
|
34
|
+
return at === -1 ? recipient : recipient.slice(0, at);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function targetHost(recipient: string): string | null {
|
|
38
|
+
const at = recipient.indexOf('@');
|
|
39
|
+
return at === -1 ? null : recipient.slice(at + 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RoutingResult {
|
|
43
|
+
addressed: boolean;
|
|
44
|
+
// Actor was named, but every instance targeted a different host — logged
|
|
45
|
+
// by the dispatcher so wrong-host routes are visible, never silent.
|
|
46
|
+
wrongHost: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function matchRouting(
|
|
50
|
+
recipientList: string[],
|
|
51
|
+
actorName: string,
|
|
52
|
+
thisHost: string,
|
|
53
|
+
): RoutingResult {
|
|
54
|
+
let actorNamedAtAll = false;
|
|
55
|
+
for (const r of recipientList) {
|
|
56
|
+
if (r === 'all') return { addressed: true, wrongHost: false };
|
|
57
|
+
if (extractActor(r) !== actorName) continue;
|
|
58
|
+
actorNamedAtAll = true;
|
|
59
|
+
const host = targetHost(r);
|
|
60
|
+
if (host === null || host === thisHost) return { addressed: true, wrongHost: false };
|
|
61
|
+
}
|
|
62
|
+
return { addressed: false, wrongHost: actorNamedAtAll };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type WakeDecision = 'wake' | 'skip' | 'wrong-host';
|
|
66
|
+
|
|
67
|
+
// `senderOf` resolves a channel relPath to the `from:` of the message there,
|
|
68
|
+
// or undefined if no such message exists. A dangling `re:` entry (target
|
|
69
|
+
// missing) wakes the addressee — fail open so a message is never silently
|
|
70
|
+
// dropped; no loop is possible because the reply to it carries a
|
|
71
|
+
// resolvable `re:`.
|
|
72
|
+
export function decideWake(
|
|
73
|
+
msg: ActivationMessage,
|
|
74
|
+
actorName: string,
|
|
75
|
+
thisHost: string,
|
|
76
|
+
senderOf: (relPath: string) => string | undefined,
|
|
77
|
+
): WakeDecision {
|
|
78
|
+
const routing = matchRouting(msg.to, actorName, thisHost);
|
|
79
|
+
if (!routing.addressed) return routing.wrongHost ? 'wrong-host' : 'skip';
|
|
80
|
+
if (msg.from === actorName) return 'skip';
|
|
81
|
+
const targets = reList(msg.re);
|
|
82
|
+
if (targets.length === 0) return 'wake';
|
|
83
|
+
for (const target of targets) {
|
|
84
|
+
const asker = senderOf(target);
|
|
85
|
+
if (asker === undefined || asker === actorName) return 'wake';
|
|
86
|
+
}
|
|
87
|
+
return 'skip';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Split a channel's pending messages (already sorted by relPath) into
|
|
91
|
+
// contiguous batches sized for the actor's concurrency. Contiguous so each
|
|
92
|
+
// batch's highest relPath is monotone across batches — the cursor advances
|
|
93
|
+
// safely per batch. When pending fits within concurrency, every batch is a
|
|
94
|
+
// single message (parallel fan-out); when it exceeds, batches collapse into
|
|
95
|
+
// ~concurrency invocations (fan-in stays O(1) per actor).
|
|
96
|
+
export function splitForConcurrency<T>(msgs: T[], concurrency: number): T[][] {
|
|
97
|
+
if (concurrency <= 1 || msgs.length <= 1) return [msgs];
|
|
98
|
+
const chunkSize = Math.max(1, Math.ceil(msgs.length / concurrency));
|
|
99
|
+
const out: T[][] = [];
|
|
100
|
+
for (let i = 0; i < msgs.length; i += chunkSize) {
|
|
101
|
+
out.push(msgs.slice(i, i + chunkSize));
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
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 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, writeFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { now, messageFilename } from './filenames.js';
|
|
32
|
+
import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
|
|
33
|
+
import {
|
|
34
|
+
discoverChannels,
|
|
35
|
+
gitCommitAndPush,
|
|
36
|
+
listChannelMessages,
|
|
37
|
+
} from './transport.js';
|
|
38
|
+
import { sendWakeSignal, readHeartbeat, readCursor, countErrors } from './state.js';
|
|
39
|
+
import { reList } from './activation.js';
|
|
40
|
+
import type { ModelEntry } from './models.js';
|
|
41
|
+
|
|
42
|
+
export interface ApiContext {
|
|
43
|
+
transportRoot: string;
|
|
44
|
+
alias: string;
|
|
45
|
+
version: string;
|
|
46
|
+
claimed: Map<string, ModelEntry>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface JsonRes {
|
|
50
|
+
status: number;
|
|
51
|
+
body: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
55
|
+
|
|
56
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
57
|
+
return new Promise((resolveBody, reject) => {
|
|
58
|
+
const chunks: Buffer[] = [];
|
|
59
|
+
req.on('data', (c) => chunks.push(Buffer.from(c)));
|
|
60
|
+
req.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf-8')));
|
|
61
|
+
req.on('error', reject);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readJson<T = unknown>(req: IncomingMessage): Promise<T> {
|
|
66
|
+
const raw = await readBody(req);
|
|
67
|
+
if (raw.length === 0) return {} as T;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(raw) as T;
|
|
70
|
+
} catch {
|
|
71
|
+
throw new HttpError(400, 'invalid JSON body');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class HttpError extends Error {
|
|
76
|
+
constructor(public status: number, message: string) {
|
|
77
|
+
super(message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
82
|
+
const json = JSON.stringify(body);
|
|
83
|
+
res.writeHead(status, {
|
|
84
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
85
|
+
'Content-Length': Buffer.byteLength(json).toString(),
|
|
86
|
+
});
|
|
87
|
+
res.end(json);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── channel discovery helpers ────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
interface ChannelMeta {
|
|
93
|
+
uuid: string;
|
|
94
|
+
name: string | null;
|
|
95
|
+
parent: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readChannelMeta(transportRoot: string, uuid: string): ChannelMeta {
|
|
99
|
+
const chPath = join(transportRoot, 'data', 'channels', uuid, 'CHANNEL.md');
|
|
100
|
+
if (!existsSync(chPath)) return { uuid, name: null, parent: null };
|
|
101
|
+
const raw = readFileSync(chPath, 'utf-8');
|
|
102
|
+
const { data } = parseFrontmatter<{ name?: unknown; parent?: unknown }>(raw);
|
|
103
|
+
return {
|
|
104
|
+
uuid,
|
|
105
|
+
name: typeof data.name === 'string' ? data.name : null,
|
|
106
|
+
parent: typeof data.parent === 'string' ? data.parent : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function allChannelMeta(transportRoot: string): ChannelMeta[] {
|
|
111
|
+
return discoverChannels(transportRoot).map((u) => readChannelMeta(transportRoot, u));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function resolveChannelHandle(transportRoot: string, handle: string): ChannelMeta | null {
|
|
115
|
+
if (UUID_RE.test(handle)) {
|
|
116
|
+
const path = join(transportRoot, 'data', 'channels', handle);
|
|
117
|
+
if (!existsSync(path)) return null;
|
|
118
|
+
return readChannelMeta(transportRoot, handle);
|
|
119
|
+
}
|
|
120
|
+
for (const meta of allChannelMeta(transportRoot)) {
|
|
121
|
+
if (meta.name === handle) return meta;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── route handlers ───────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function handleHealthz(ctx: ApiContext): JsonRes {
|
|
129
|
+
return { status: 200, body: { ok: true, alias: ctx.alias, version: ctx.version } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function handleVersion(ctx: ApiContext): JsonRes {
|
|
133
|
+
return {
|
|
134
|
+
status: 200,
|
|
135
|
+
body: { engine: 'crosstalkd', version: ctx.version, alias: ctx.alias },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function handleStatus(ctx: ApiContext): JsonRes {
|
|
140
|
+
const { transportRoot } = ctx;
|
|
141
|
+
const hb = readHeartbeat(transportRoot);
|
|
142
|
+
const cursor = readCursor(transportRoot);
|
|
143
|
+
const channels = allChannelMeta(transportRoot);
|
|
144
|
+
return {
|
|
145
|
+
status: 200,
|
|
146
|
+
body: {
|
|
147
|
+
transport_root: transportRoot,
|
|
148
|
+
alias: ctx.alias,
|
|
149
|
+
version: ctx.version,
|
|
150
|
+
claimed_models: [...ctx.claimed.keys()],
|
|
151
|
+
heartbeat: hb
|
|
152
|
+
? { ts: hb.ts, pid: hb.pid, version: hb.version, alias: hb.alias }
|
|
153
|
+
: null,
|
|
154
|
+
cursor: cursor ?? null,
|
|
155
|
+
channels: channels.map((c) => ({ uuid: c.uuid, name: c.name, parent: c.parent })),
|
|
156
|
+
errors_logged: countErrors(transportRoot),
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleListChannels(ctx: ApiContext): JsonRes {
|
|
162
|
+
return {
|
|
163
|
+
status: 200,
|
|
164
|
+
body: { channels: allChannelMeta(ctx.transportRoot) },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface CreateChannelReq { name: string }
|
|
169
|
+
|
|
170
|
+
function handleCreateChannel(ctx: ApiContext, body: CreateChannelReq): JsonRes {
|
|
171
|
+
const { transportRoot } = ctx;
|
|
172
|
+
if (typeof body.name !== 'string' || body.name.length === 0) {
|
|
173
|
+
throw new HttpError(400, 'channel name is required');
|
|
174
|
+
}
|
|
175
|
+
if (UUID_RE.test(body.name)) {
|
|
176
|
+
throw new HttpError(400, 'channel name cannot be UUID-shaped');
|
|
177
|
+
}
|
|
178
|
+
if (allChannelMeta(transportRoot).some((c) => c.name === body.name)) {
|
|
179
|
+
throw new HttpError(409, `a channel named '${body.name}' already exists`);
|
|
180
|
+
}
|
|
181
|
+
const uuid = randomUUID();
|
|
182
|
+
const dir = join(transportRoot, 'data', 'channels', uuid);
|
|
183
|
+
mkdirSync(dir, { recursive: true });
|
|
184
|
+
writeFileSync(join(dir, 'CHANNEL.md'), serializeFrontmatter({ name: body.name }, ''));
|
|
185
|
+
const push = gitCommitAndPush(transportRoot, `channel(create): ${body.name} (${uuid.slice(0, 8)})`);
|
|
186
|
+
if (!push.ok && push.error) {
|
|
187
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
188
|
+
}
|
|
189
|
+
return { status: 201, body: { uuid, name: body.name } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface PostMessageReq {
|
|
193
|
+
type: 'primitive' | 'workflow';
|
|
194
|
+
channel?: string; // name or UUID; defaults to single channel if exactly one exists
|
|
195
|
+
from?: string;
|
|
196
|
+
to?: string; // required for primitive
|
|
197
|
+
as?: string; // optional persona for primitive
|
|
198
|
+
fanout?: number; // optional, default 1
|
|
199
|
+
body: string;
|
|
200
|
+
re?: string[]; // optional explicit re: targets
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function resolveChannelUuid(transportRoot: string, handle?: string): string {
|
|
204
|
+
if (handle) {
|
|
205
|
+
const meta = resolveChannelHandle(transportRoot, handle);
|
|
206
|
+
if (!meta) throw new HttpError(404, `channel '${handle}' not found`);
|
|
207
|
+
return meta.uuid;
|
|
208
|
+
}
|
|
209
|
+
const all = discoverChannels(transportRoot);
|
|
210
|
+
if (all.length === 1) return all[0]!;
|
|
211
|
+
if (all.length === 0) {
|
|
212
|
+
throw new HttpError(409, "no channels exist — create one via POST /channels first");
|
|
213
|
+
}
|
|
214
|
+
throw new HttpError(409, 'multiple channels exist — specify "channel" in request body');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function writeMessageFile(opts: {
|
|
218
|
+
transportRoot: string;
|
|
219
|
+
channelUuid: string;
|
|
220
|
+
from: string;
|
|
221
|
+
to: string;
|
|
222
|
+
as?: string;
|
|
223
|
+
body: string;
|
|
224
|
+
re: string[];
|
|
225
|
+
extraFrontmatter?: Record<string, unknown>;
|
|
226
|
+
workflow?: boolean;
|
|
227
|
+
}): string {
|
|
228
|
+
const ts = now();
|
|
229
|
+
const dir = join(opts.transportRoot, 'data', 'channels', opts.channelUuid, ts.pathDate);
|
|
230
|
+
mkdirSync(dir, { recursive: true });
|
|
231
|
+
const fm: Record<string, unknown> = {
|
|
232
|
+
from: opts.from,
|
|
233
|
+
to: opts.to,
|
|
234
|
+
timestamp: ts.iso,
|
|
235
|
+
};
|
|
236
|
+
if (opts.as) fm['as'] = opts.as;
|
|
237
|
+
if (opts.workflow) fm['type'] = 'workflow';
|
|
238
|
+
if (opts.re.length === 1) fm['re'] = opts.re[0];
|
|
239
|
+
else if (opts.re.length > 1) fm['re'] = opts.re;
|
|
240
|
+
if (opts.extraFrontmatter) Object.assign(fm, opts.extraFrontmatter);
|
|
241
|
+
const filename = messageFilename(ts);
|
|
242
|
+
writeFileSync(join(dir, filename), serializeFrontmatter(fm, opts.body));
|
|
243
|
+
return join(ts.pathDate, filename);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function handlePostMessage(ctx: ApiContext, body: PostMessageReq): JsonRes {
|
|
247
|
+
const { transportRoot } = ctx;
|
|
248
|
+
if (typeof body.body !== 'string') {
|
|
249
|
+
throw new HttpError(400, 'message body (`body` field) is required and must be a string');
|
|
250
|
+
}
|
|
251
|
+
const re = Array.isArray(body.re) ? body.re.filter((s) => typeof s === 'string') : [];
|
|
252
|
+
const from = typeof body.from === 'string' && body.from.length > 0 ? body.from : 'operator';
|
|
253
|
+
|
|
254
|
+
if (body.type === 'primitive') {
|
|
255
|
+
if (typeof body.to !== 'string' || body.to.length === 0) {
|
|
256
|
+
throw new HttpError(400, 'primitive requires `to` (model name)');
|
|
257
|
+
}
|
|
258
|
+
const channelUuid = resolveChannelUuid(transportRoot, body.channel);
|
|
259
|
+
const fanout = Math.max(1, Math.min(50, body.fanout ?? 1));
|
|
260
|
+
const relPaths: string[] = [];
|
|
261
|
+
for (let i = 0; i < fanout; i++) {
|
|
262
|
+
relPaths.push(
|
|
263
|
+
writeMessageFile({
|
|
264
|
+
transportRoot,
|
|
265
|
+
channelUuid,
|
|
266
|
+
from,
|
|
267
|
+
to: body.to,
|
|
268
|
+
as: body.as,
|
|
269
|
+
body: body.body,
|
|
270
|
+
re,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const commitMsg =
|
|
275
|
+
fanout === 1
|
|
276
|
+
? `run: ${from} -> ${body.to} in ${channelUuid.slice(0, 8)}`
|
|
277
|
+
: `run: ${from} -> ${body.to} x${fanout} in ${channelUuid.slice(0, 8)}`;
|
|
278
|
+
const push = gitCommitAndPush(transportRoot, commitMsg);
|
|
279
|
+
sendWakeSignal(transportRoot);
|
|
280
|
+
if (!push.ok && push.error) {
|
|
281
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
282
|
+
}
|
|
283
|
+
return { status: 201, body: { relPaths, channel: channelUuid, type: 'primitive' } };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (body.type === 'workflow') {
|
|
287
|
+
const parsed = parseFrontmatter<Record<string, unknown>>(body.body);
|
|
288
|
+
if (parsed.data['type'] !== 'workflow') {
|
|
289
|
+
throw new HttpError(400, "workflow body must declare 'type: workflow' in frontmatter");
|
|
290
|
+
}
|
|
291
|
+
const parentUuid = resolveChannelUuid(transportRoot, body.channel);
|
|
292
|
+
const childUuid = randomUUID();
|
|
293
|
+
const childDir = join(transportRoot, 'data', 'channels', childUuid);
|
|
294
|
+
mkdirSync(childDir, { recursive: true });
|
|
295
|
+
writeFileSync(
|
|
296
|
+
join(childDir, 'CHANNEL.md'),
|
|
297
|
+
serializeFrontmatter({ name: `workflow-${Date.now()}`, parent: parentUuid }, ''),
|
|
298
|
+
);
|
|
299
|
+
const heartbeat = readHeartbeat(transportRoot);
|
|
300
|
+
const dispatchHost = heartbeat?.alias;
|
|
301
|
+
const extraFrontmatter: Record<string, unknown> = { child_channel: childUuid };
|
|
302
|
+
if (dispatchHost) extraFrontmatter['dispatch_host'] = dispatchHost;
|
|
303
|
+
const relPath = writeMessageFile({
|
|
304
|
+
transportRoot,
|
|
305
|
+
channelUuid: parentUuid,
|
|
306
|
+
from,
|
|
307
|
+
to: 'workflow',
|
|
308
|
+
body: parsed.body,
|
|
309
|
+
re,
|
|
310
|
+
extraFrontmatter,
|
|
311
|
+
workflow: true,
|
|
312
|
+
});
|
|
313
|
+
const push = gitCommitAndPush(
|
|
314
|
+
transportRoot,
|
|
315
|
+
`run(workflow): ${from} child ${childUuid.slice(0, 8)}`,
|
|
316
|
+
);
|
|
317
|
+
sendWakeSignal(transportRoot);
|
|
318
|
+
if (!push.ok && push.error) {
|
|
319
|
+
throw new HttpError(500, `commit failed: ${push.error.slice(0, 300)}`);
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
status: 201,
|
|
323
|
+
body: { relPath, channel: parentUuid, childChannel: childUuid, type: 'workflow' },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
throw new HttpError(400, "`type` must be 'primitive' or 'workflow'");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function handleReplies(ctx: ApiContext, relPathQuery: string): JsonRes {
|
|
331
|
+
const targets = relPathQuery.split(',').map((s) => s.trim()).filter(Boolean);
|
|
332
|
+
if (targets.length === 0) {
|
|
333
|
+
throw new HttpError(400, 'at least one relPath query argument required');
|
|
334
|
+
}
|
|
335
|
+
const found = new Map<string, { from: string; relPath: string; failed: boolean }>();
|
|
336
|
+
for (const channel of discoverChannels(ctx.transportRoot)) {
|
|
337
|
+
for (const msg of listChannelMessages(ctx.transportRoot, channel)) {
|
|
338
|
+
for (const entry of reList(msg.data['re'])) {
|
|
339
|
+
if (targets.includes(entry) && !found.has(entry)) {
|
|
340
|
+
found.set(entry, {
|
|
341
|
+
from: String(msg.data['from']),
|
|
342
|
+
relPath: msg.relPath,
|
|
343
|
+
failed: msg.data['failed'] === true,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const results = targets.map((t) => {
|
|
350
|
+
const reply = found.get(t);
|
|
351
|
+
if (!reply) return { target: t, status: 'PENDING' };
|
|
352
|
+
return {
|
|
353
|
+
target: t,
|
|
354
|
+
status: reply.failed ? 'FAILED' : 'REPLIED',
|
|
355
|
+
from: reply.from,
|
|
356
|
+
replyRelPath: reply.relPath,
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
return { status: 200, body: { replies: results } };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── router ───────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
async function route(ctx: ApiContext, req: IncomingMessage): Promise<JsonRes> {
|
|
365
|
+
const { method = 'GET', url = '/' } = req;
|
|
366
|
+
const u = new URL(url, 'http://localhost');
|
|
367
|
+
const path = u.pathname;
|
|
368
|
+
|
|
369
|
+
if (method === 'GET' && path === '/healthz') return handleHealthz(ctx);
|
|
370
|
+
if (method === 'GET' && path === '/version') return handleVersion(ctx);
|
|
371
|
+
if (method === 'GET' && path === '/status') return handleStatus(ctx);
|
|
372
|
+
if (method === 'GET' && path === '/channels') return handleListChannels(ctx);
|
|
373
|
+
if (method === 'POST' && path === '/channels') {
|
|
374
|
+
const body = await readJson<CreateChannelReq>(req);
|
|
375
|
+
return handleCreateChannel(ctx, body);
|
|
376
|
+
}
|
|
377
|
+
if (method === 'POST' && path === '/messages') {
|
|
378
|
+
const body = await readJson<PostMessageReq>(req);
|
|
379
|
+
return handlePostMessage(ctx, body);
|
|
380
|
+
}
|
|
381
|
+
if (method === 'GET' && path === '/replies') {
|
|
382
|
+
const targets = u.searchParams.get('relPaths') ?? '';
|
|
383
|
+
return handleReplies(ctx, targets);
|
|
384
|
+
}
|
|
385
|
+
return { status: 404, body: { error: `no route for ${method} ${path}` } };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── server lifecycle ─────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
export const DEFAULT_API_PORT = 7000;
|
|
391
|
+
|
|
392
|
+
export interface StartApiOptions {
|
|
393
|
+
port?: number;
|
|
394
|
+
log: (event: string, fields?: Record<string, unknown>) => void;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function startApi(ctx: ApiContext, opts: StartApiOptions): Server {
|
|
398
|
+
const port = opts.port ?? (Number(process.env['CROSSTALKD_API_PORT']) || DEFAULT_API_PORT);
|
|
399
|
+
// Bind address resolution:
|
|
400
|
+
// - Native (default): 127.0.0.1 — engine running as a host process
|
|
401
|
+
// should not expose the API to the network. Localhost only.
|
|
402
|
+
// - Container: 0.0.0.0 (set via ENV CROSSTALKD_API_BIND in
|
|
403
|
+
// the Dockerfile) — Docker's bridge networking means "127.0.0.1
|
|
404
|
+
// inside the container" can't be reached via the daemon's port
|
|
405
|
+
// forwarder. The actual security boundary moves up to compose's
|
|
406
|
+
// port mapping (`127.0.0.1:HOST:CONTAINER`), which restricts
|
|
407
|
+
// reachability to host loopback only.
|
|
408
|
+
//
|
|
409
|
+
// The engine bind address is therefore env-controlled, BUT operators
|
|
410
|
+
// never need to set it manually — the container image does it.
|
|
411
|
+
const bind = process.env['CROSSTALKD_API_BIND'] ?? '127.0.0.1';
|
|
412
|
+
const server = createServer(async (req, res) => {
|
|
413
|
+
try {
|
|
414
|
+
const result = await route(ctx, req);
|
|
415
|
+
writeJson(res, result.status, result.body);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (err instanceof HttpError) {
|
|
418
|
+
writeJson(res, err.status, { error: err.message });
|
|
419
|
+
} else {
|
|
420
|
+
const msg = (err as Error).message;
|
|
421
|
+
opts.log('api_handler_crash', { error: msg.slice(0, 300) });
|
|
422
|
+
writeJson(res, 500, { error: `internal error: ${msg.slice(0, 200)}` });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
server.listen(port, bind, () => {
|
|
427
|
+
opts.log('api_listening', { host: bind, port });
|
|
428
|
+
});
|
|
429
|
+
return server;
|
|
430
|
+
}
|