@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/dispatch.ts
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// crosstalk daemon dispatch — the engine main loop.
|
|
2
|
+
//
|
|
3
|
+
// Tick: pull → scan channels for messages past the global cursor → for each
|
|
4
|
+
// addressed message that wakes a claimed model, invoke the model CLI →
|
|
5
|
+
// write reply (success body or failed:true + error) → commit + push.
|
|
6
|
+
//
|
|
7
|
+
// v7 changes vs v6 dispatch:
|
|
8
|
+
// - No host file. Machine identity is the --alias flag; claimed models
|
|
9
|
+
// come from data/crosstalk.yaml + PATH self-selection (models.ts).
|
|
10
|
+
// - Single global cursor (state.ts), not per-actor-per-channel.
|
|
11
|
+
// - No DLQ. Failures land in the channel as normal messages with
|
|
12
|
+
// failed:true + error: in frontmatter — wake-loud.
|
|
13
|
+
// - No turnq. Git rebase-retry is the correctness mechanism; turnq was
|
|
14
|
+
// a churn-reducer that didn't earn its weight.
|
|
15
|
+
// - No host-file commit / waterline logic. Cursors seed to HEAD on first
|
|
16
|
+
// boot; pre-join history is ignored (same effect as v6 waterline).
|
|
17
|
+
|
|
18
|
+
import { resolve, join, dirname } from 'path';
|
|
19
|
+
import { readFileSync, existsSync, appendFileSync } from 'fs';
|
|
20
|
+
import { watch } from 'fs/promises';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
|
|
23
|
+
import { loadRegistry, type ModelEntry, type ModelsRegistry } from './models.js';
|
|
24
|
+
import {
|
|
25
|
+
discoverChannels,
|
|
26
|
+
listChannelMessages,
|
|
27
|
+
gitPull,
|
|
28
|
+
gitCommitAndPush,
|
|
29
|
+
cursorBaseline,
|
|
30
|
+
newFilesSince,
|
|
31
|
+
type ChannelMessage,
|
|
32
|
+
} from './transport.js';
|
|
33
|
+
import {
|
|
34
|
+
stateDir,
|
|
35
|
+
readCursor,
|
|
36
|
+
writeCursor,
|
|
37
|
+
writeHeartbeat,
|
|
38
|
+
writePidfile,
|
|
39
|
+
removePidfile,
|
|
40
|
+
logError,
|
|
41
|
+
} from './state.js';
|
|
42
|
+
import {
|
|
43
|
+
recipients,
|
|
44
|
+
reList,
|
|
45
|
+
extractActor,
|
|
46
|
+
decideWake,
|
|
47
|
+
} from './activation.js';
|
|
48
|
+
import {
|
|
49
|
+
loadProtocolPrompt,
|
|
50
|
+
loadActorPersona,
|
|
51
|
+
composeSystemPrompt,
|
|
52
|
+
invokeModelCli,
|
|
53
|
+
formatBatchedUserMessage,
|
|
54
|
+
messageSender,
|
|
55
|
+
writeReply,
|
|
56
|
+
} from './invoke.js';
|
|
57
|
+
import { workflowTick } from './workflow.js';
|
|
58
|
+
import { writeRegistryEntry, removeRegistryEntry } from './dispatchers.js';
|
|
59
|
+
import { startApi } from './api.js';
|
|
60
|
+
import { logBuffer } from './log-buffer.js';
|
|
61
|
+
import { FileUserStore } from './auth/users.js';
|
|
62
|
+
import { FileTokenStore } from './auth/tokens.js';
|
|
63
|
+
import { SetupState } from './auth/setup.js';
|
|
64
|
+
import type { Server as HttpServer } from 'http';
|
|
65
|
+
|
|
66
|
+
const RUNTIME_VERSION: string = (() => {
|
|
67
|
+
try {
|
|
68
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
69
|
+
return (JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }).version ?? 'unknown';
|
|
70
|
+
} catch {
|
|
71
|
+
return 'unknown';
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
|
|
75
|
+
const transportRoot = resolve(process.cwd());
|
|
76
|
+
const argv = process.argv.slice(2);
|
|
77
|
+
|
|
78
|
+
function flag(name: string): string | undefined {
|
|
79
|
+
const i = argv.indexOf(name);
|
|
80
|
+
if (i === -1 || i === argv.length - 1) return undefined;
|
|
81
|
+
return argv[i + 1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const alias = flag('--alias');
|
|
85
|
+
const onceMode = argv.includes('--once');
|
|
86
|
+
const jsonMode = argv.includes('--json');
|
|
87
|
+
const pollSeconds = Number(flag('--poll')) || 30;
|
|
88
|
+
const logFile = flag('--log-file');
|
|
89
|
+
|
|
90
|
+
if (!alias) {
|
|
91
|
+
console.error('crosstalk daemon dispatch: --alias <name> is required (machine identity in the bus).');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function log(event: string, fields: Record<string, unknown> = {}): void {
|
|
96
|
+
let line: string;
|
|
97
|
+
if (jsonMode) {
|
|
98
|
+
line = JSON.stringify({ ts: new Date().toISOString(), event, ...fields });
|
|
99
|
+
} else {
|
|
100
|
+
const tail = Object.entries(fields)
|
|
101
|
+
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
102
|
+
.join(' ');
|
|
103
|
+
line = `[${new Date().toISOString()}] ${event}${tail ? ' ' + tail : ''}`;
|
|
104
|
+
}
|
|
105
|
+
console.log(line);
|
|
106
|
+
if (logFile) {
|
|
107
|
+
try { appendFileSync(logFile, line + '\n'); } catch { /* best-effort */ }
|
|
108
|
+
}
|
|
109
|
+
// Tee into the in-memory ring buffer so the /logs web page + the
|
|
110
|
+
// /api/logs/stream SSE endpoint can serve a live tail without
|
|
111
|
+
// re-parsing stdout from another process.
|
|
112
|
+
logBuffer.push(event, fields);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// One pending dispatch = one message → one CLI invocation → one reply.
|
|
116
|
+
// v6 grouped by (channel, model) into batches; v7 doesn't (see V7-SPEC
|
|
117
|
+
// post-M2 fanout note). Each fanout sibling, each operator-piled-up
|
|
118
|
+
// request, each fan-in reply gets its own invocation in parallel — N
|
|
119
|
+
// model calls, N replies, fanout works as designed.
|
|
120
|
+
interface PendingDispatch {
|
|
121
|
+
channelUuid: string;
|
|
122
|
+
modelName: string;
|
|
123
|
+
model: ModelEntry;
|
|
124
|
+
msg: ChannelMessage;
|
|
125
|
+
// Persona resolved by dispatchTick: prefers msg.as, falls back to the
|
|
126
|
+
// re:'d message's wake_as (workflow persona continuity).
|
|
127
|
+
invokeAs?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function dispatchOne(d: PendingDispatch, protocolPrompt: string): Promise<boolean> {
|
|
131
|
+
const childChannel = typeof d.msg.data['child_channel'] === 'string'
|
|
132
|
+
? (d.msg.data['child_channel'] as string)
|
|
133
|
+
: undefined;
|
|
134
|
+
const actorName = d.invokeAs;
|
|
135
|
+
const persona = loadActorPersona(transportRoot, actorName);
|
|
136
|
+
const systemPrompt = composeSystemPrompt([protocolPrompt, persona]);
|
|
137
|
+
const fromIdentity = `${d.modelName}@${alias}`;
|
|
138
|
+
const sender = messageSender(d.msg);
|
|
139
|
+
|
|
140
|
+
log('dispatch', {
|
|
141
|
+
model: d.modelName,
|
|
142
|
+
alias,
|
|
143
|
+
channel: d.channelUuid.slice(0, 8),
|
|
144
|
+
actor: actorName ?? null,
|
|
145
|
+
child_channel: childChannel ? childChannel.slice(0, 8) : null,
|
|
146
|
+
msg: d.msg.relPath,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const env: Record<string, string> = {
|
|
150
|
+
CROSSTALK_DISPATCH_ACTOR: fromIdentity,
|
|
151
|
+
CROSSTALK_DISPATCH_CHANNEL: d.channelUuid,
|
|
152
|
+
CROSSTALK_DISPATCH_RE: d.msg.relPath,
|
|
153
|
+
};
|
|
154
|
+
if (childChannel) env['CROSSTALK_CHILD_CHANNEL'] = childChannel;
|
|
155
|
+
|
|
156
|
+
const result = await invokeModelCli(d.model, systemPrompt, d.msg.body, env);
|
|
157
|
+
|
|
158
|
+
if (result.status !== 0) {
|
|
159
|
+
// Some CLIs (claude --print, codex exec, others) write auth/config
|
|
160
|
+
// errors to stdout, not stderr. Surface both so failures aren't a
|
|
161
|
+
// black box when only one stream has the message.
|
|
162
|
+
const errBody = result.stderr.trim() || result.stdout.trim() || '(no output captured)';
|
|
163
|
+
const errDetail = [
|
|
164
|
+
`model exit=${result.status}`,
|
|
165
|
+
result.stderr.trim() ? `stderr:\n${result.stderr.slice(0, 1000)}` : '',
|
|
166
|
+
result.stdout.trim() ? `stdout:\n${result.stdout.slice(0, 1000)}` : '',
|
|
167
|
+
].filter(Boolean).join('\n');
|
|
168
|
+
writeReply({
|
|
169
|
+
transportRoot,
|
|
170
|
+
channelUuid: d.channelUuid,
|
|
171
|
+
fromModel: fromIdentity,
|
|
172
|
+
to: sender === 'unknown' ? messageSender(d.msg) : sender,
|
|
173
|
+
re: d.msg.relPath,
|
|
174
|
+
body: errBody.slice(0, 4000),
|
|
175
|
+
failed: { error: errDetail },
|
|
176
|
+
});
|
|
177
|
+
log('dispatch_failed', {
|
|
178
|
+
model: d.modelName,
|
|
179
|
+
channel: d.channelUuid.slice(0, 8),
|
|
180
|
+
msg: d.msg.relPath,
|
|
181
|
+
exit: result.status,
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const reply = result.stdout.trim();
|
|
187
|
+
if (reply.length === 0) {
|
|
188
|
+
// Actor routed its answer via `crosstalk message send` (which auto-links re:).
|
|
189
|
+
// If it truly did nothing, the asker's `crosstalk message replies` stays
|
|
190
|
+
// PENDING — visible, never silently lost.
|
|
191
|
+
log('dispatch_silent', { model: d.modelName, channel: d.channelUuid.slice(0, 8), msg: d.msg.relPath });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
writeReply({
|
|
196
|
+
transportRoot,
|
|
197
|
+
channelUuid: d.channelUuid,
|
|
198
|
+
fromModel: fromIdentity,
|
|
199
|
+
to: sender,
|
|
200
|
+
re: d.msg.relPath,
|
|
201
|
+
body: reply,
|
|
202
|
+
});
|
|
203
|
+
log('dispatch_done', { model: d.modelName, channel: d.channelUuid.slice(0, 8), msg: d.msg.relPath });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function dispatchTick(registry: ModelsRegistry, protocolPrompt: string): Promise<boolean> {
|
|
208
|
+
const claimed = registry.claimed;
|
|
209
|
+
writeHeartbeat(transportRoot, RUNTIME_VERSION, alias!);
|
|
210
|
+
|
|
211
|
+
const pullResult = gitPull(transportRoot);
|
|
212
|
+
if (!pullResult.ok) {
|
|
213
|
+
logError(transportRoot, `git pull failed: ${pullResult.error}`, 'warn');
|
|
214
|
+
log('git_pull_failed', { error: (pullResult.error ?? '').slice(0, 200) });
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const head = cursorBaseline(transportRoot);
|
|
219
|
+
if (!head) {
|
|
220
|
+
logError(transportRoot, 'git rev-parse failed for origin/HEAD and HEAD — skipping tick');
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let cursor = readCursor(transportRoot);
|
|
225
|
+
if (cursor === null) {
|
|
226
|
+
// First-boot seed to HEAD: pre-join history is ignored. Operators
|
|
227
|
+
// sending messages BEFORE this dispatcher first booted are not
|
|
228
|
+
// delivered — ephemeral-alias semantics: an alias only exists from
|
|
229
|
+
// its first boot onward (no committed per-host "join" marker).
|
|
230
|
+
writeCursor(transportRoot, head);
|
|
231
|
+
cursor = head;
|
|
232
|
+
log('cursor_seeded', { commit: head.slice(0, 12) });
|
|
233
|
+
}
|
|
234
|
+
const channels = discoverChannels(transportRoot);
|
|
235
|
+
const activationDue = cursor !== head;
|
|
236
|
+
|
|
237
|
+
let didWork = false;
|
|
238
|
+
if (activationDue) {
|
|
239
|
+
didWork = await runActivationPass(claimed, protocolPrompt, channels, cursor, head);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Workflow tick runs unconditionally — an open workflow may need to
|
|
243
|
+
// advance (e.g. compile a freshly-committed marker, or progress to the
|
|
244
|
+
// synthesize phase now that all fanout replies have landed) regardless
|
|
245
|
+
// of whether the activation pass had new messages this tick.
|
|
246
|
+
const workflowProgressed = await workflowTick({ transportRoot, alias: alias!, registry, claimed, log }, channels);
|
|
247
|
+
|
|
248
|
+
// Advance cursor regardless of per-batch success: at-least-once was
|
|
249
|
+
// attempted; failure replies are committed alongside successes. A
|
|
250
|
+
// crash mid-tick leaves the cursor where it was — next tick replays.
|
|
251
|
+
writeCursor(transportRoot, head);
|
|
252
|
+
|
|
253
|
+
const writeNeeded = didWork || workflowProgressed;
|
|
254
|
+
if (writeNeeded) {
|
|
255
|
+
const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): replies ${new Date().toISOString()}`);
|
|
256
|
+
if (!push.ok && push.error) {
|
|
257
|
+
// Commit failed is a real error (local state out of sync); push
|
|
258
|
+
// failed with the commit landing is a warning (push will retry).
|
|
259
|
+
logError(transportRoot, `${push.committed ? 'push' : 'commit'} failed: ${push.error}`, push.committed ? 'warn' : 'error');
|
|
260
|
+
log('git_push_failed', { committed: push.committed, error: push.error.slice(0, 200) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return writeNeeded;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function runActivationPass(
|
|
268
|
+
claimed: Map<string, ModelEntry>,
|
|
269
|
+
protocolPrompt: string,
|
|
270
|
+
channels: string[],
|
|
271
|
+
cursor: string,
|
|
272
|
+
head: string,
|
|
273
|
+
): Promise<boolean> {
|
|
274
|
+
const addedList = newFilesSince(transportRoot, cursor);
|
|
275
|
+
if (addedList === null) {
|
|
276
|
+
logError(transportRoot, `cursor ${cursor.slice(0, 12)} unknown — re-scanning all channels`, 'warn');
|
|
277
|
+
}
|
|
278
|
+
const added: Set<string> | null = addedList === null ? null : new Set(addedList);
|
|
279
|
+
|
|
280
|
+
void head; // head is the diff's upper bound implicitly (newFilesSince uses HEAD).
|
|
281
|
+
const pending: PendingDispatch[] = [];
|
|
282
|
+
|
|
283
|
+
for (const channelUuid of channels) {
|
|
284
|
+
const messages = listChannelMessages(transportRoot, channelUuid);
|
|
285
|
+
const senderByRelPath = new Map(messages.map((m) => [m.relPath, messageSender(m)]));
|
|
286
|
+
// Normalize asker identity the same way self-suppression does: strip
|
|
287
|
+
// the @machine suffix before comparing against actorName. Without
|
|
288
|
+
// this, an actor that sent a sub-message never wakes for its reply,
|
|
289
|
+
// because asker='alice@cachy' never matches actorName='alice'. The
|
|
290
|
+
// bug was masked by the workflow smoke test, which used direct
|
|
291
|
+
// stdout replies — fan-out + fan-in is the case that surfaces it.
|
|
292
|
+
const senderOf = (relPath: string) => {
|
|
293
|
+
const raw = senderByRelPath.get(relPath);
|
|
294
|
+
return raw === undefined ? undefined : extractActor(raw);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
let post: ChannelMessage[];
|
|
298
|
+
if (added === null) {
|
|
299
|
+
post = messages;
|
|
300
|
+
} else {
|
|
301
|
+
const prefix = `data/channels/${channelUuid}/`;
|
|
302
|
+
post = messages.filter((m) => added.has(prefix + m.relPath));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const msg of post) {
|
|
306
|
+
const recipientList = recipients(msg.data['to']);
|
|
307
|
+
for (const [modelName, model] of claimed) {
|
|
308
|
+
const decision = decideWake(
|
|
309
|
+
{
|
|
310
|
+
from: extractActor(messageSender(msg)), // self-suppression: model name, not model@alias
|
|
311
|
+
to: recipientList,
|
|
312
|
+
re: reList(msg.data['re']),
|
|
313
|
+
},
|
|
314
|
+
modelName,
|
|
315
|
+
alias!,
|
|
316
|
+
senderOf,
|
|
317
|
+
);
|
|
318
|
+
if (decision === 'wake') {
|
|
319
|
+
const invokeAs: string | undefined = typeof msg.data['as'] === 'string'
|
|
320
|
+
? (msg.data['as'] as string)
|
|
321
|
+
: undefined;
|
|
322
|
+
pending.push({ channelUuid, modelName, model, msg, invokeAs });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Fire all pending dispatches in parallel. Each is one model CLI call,
|
|
329
|
+
// captured by detached spawn with SIGKILL timeout (invoke.ts). N parallel
|
|
330
|
+
// claude/gemini/etc. invocations comfortably saturate one machine; if it
|
|
331
|
+
// ever becomes a problem, dispatch.ts can grow a per-model concurrency
|
|
332
|
+
// semaphore — for now KISS wins, every message gets its own dispatch.
|
|
333
|
+
const results = pending.length === 0
|
|
334
|
+
? []
|
|
335
|
+
: await Promise.all(pending.map((d) => dispatchOne(d, protocolPrompt)));
|
|
336
|
+
return results.some(Boolean);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function waitForWakeOrTimeout(ms: number): Promise<void> {
|
|
340
|
+
const dir = stateDir(transportRoot);
|
|
341
|
+
const ac = new AbortController();
|
|
342
|
+
const timer = setTimeout(() => ac.abort(), ms);
|
|
343
|
+
try {
|
|
344
|
+
const watcher = watch(dir, { signal: ac.signal });
|
|
345
|
+
for await (const ev of watcher) {
|
|
346
|
+
if (ev.filename === 'wake.signal') return;
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
/* abort = timeout */
|
|
350
|
+
} finally {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function main(): Promise<void> {
|
|
356
|
+
writePidfile(transportRoot);
|
|
357
|
+
// Regular exit: drop the pidfile only. Do NOT drop the registry entry —
|
|
358
|
+
// (a) `--once` mode exits cleanly every tick and would churn the file
|
|
359
|
+
// between tick runs, and (b) an uncommitted local delete breaks the
|
|
360
|
+
// next `git pull --rebase` until something else commits. Registry
|
|
361
|
+
// entries should live until explicit shutdown.
|
|
362
|
+
process.on('exit', () => removePidfile(transportRoot));
|
|
363
|
+
// Explicit shutdown signals: remove pidfile + registry entry, commit
|
|
364
|
+
// the deregistration so other machines stop routing to us. Hard crash
|
|
365
|
+
// skips this and leaves a stale entry — operator-cleanup concern,
|
|
366
|
+
// not runtime correctness.
|
|
367
|
+
let apiServer: HttpServer | null = null;
|
|
368
|
+
const cleanupShutdown = () => {
|
|
369
|
+
if (apiServer) {
|
|
370
|
+
try { apiServer.close(); } catch { /* best-effort */ }
|
|
371
|
+
}
|
|
372
|
+
removePidfile(transportRoot);
|
|
373
|
+
if (alias) removeRegistryEntry(transportRoot, alias);
|
|
374
|
+
const push = gitCommitAndPush(transportRoot, `dispatch(${alias}): deregister entry`);
|
|
375
|
+
if (!push.ok && push.error) {
|
|
376
|
+
logError(transportRoot, `registry deregister failed: ${push.error}`, 'warn');
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
process.on('SIGTERM', () => { cleanupShutdown(); process.exit(0); });
|
|
380
|
+
process.on('SIGINT', () => { cleanupShutdown(); process.exit(0); });
|
|
381
|
+
|
|
382
|
+
// Load the model registry. On parse failure (operator-edited
|
|
383
|
+
// crosstalk.yaml broken mid-flight, missing file, malformed YAML),
|
|
384
|
+
// log the error and continue with an empty claimed set rather than
|
|
385
|
+
// exiting. The container's restart: unless-stopped would otherwise
|
|
386
|
+
// crash-loop forever; this way the engine stays up, the API keeps
|
|
387
|
+
// serving /status, and the operator can `crosstalk status` to see
|
|
388
|
+
// the diagnostic without needing `crosstalk logs`.
|
|
389
|
+
//
|
|
390
|
+
// Note: alpha.11 made the parser skip-bad-provider — a single typo'd
|
|
391
|
+
// provider no longer throws; it logs a warning and the rest of the
|
|
392
|
+
// registry loads. This whole-registry catch now fires only on truly
|
|
393
|
+
// unrecoverable errors (missing file, top-level shape broken, etc.).
|
|
394
|
+
let registry: ReturnType<typeof loadRegistry>;
|
|
395
|
+
try {
|
|
396
|
+
registry = loadRegistry(transportRoot);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const msg = (err as Error).message;
|
|
399
|
+
logError(transportRoot, `crosstalk.yaml load failed — running with empty claimed set: ${msg}`);
|
|
400
|
+
console.error(`crosstalk daemon dispatch: ${msg}`);
|
|
401
|
+
console.error(`crosstalk daemon dispatch: continuing with EMPTY claimed-model set. Fix data/crosstalk.yaml, then 'crosstalk server restart' to reload.`);
|
|
402
|
+
registry = {
|
|
403
|
+
all: new Map(),
|
|
404
|
+
byBareName: new Map(),
|
|
405
|
+
claimed: new Map(),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const protocolPrompt = loadProtocolPrompt(transportRoot);
|
|
410
|
+
|
|
411
|
+
// Publish our registry entry so workflow.ts (running on this or any
|
|
412
|
+
// other machine) can scope fanout sub-primitives to specific dispatchers
|
|
413
|
+
// instead of broadcasting bare-recipient fanouts that every claimant
|
|
414
|
+
// races to duplicate. Commit+push immediately so cross-machine
|
|
415
|
+
// visibility doesn't wait for first reply traffic.
|
|
416
|
+
writeRegistryEntry(transportRoot, alias!, [...registry.claimed.keys()], RUNTIME_VERSION);
|
|
417
|
+
const registryPush = gitCommitAndPush(transportRoot, `dispatch(${alias}): register entry`);
|
|
418
|
+
if (!registryPush.ok && registryPush.error) {
|
|
419
|
+
logError(transportRoot, `registry publish failed: ${registryPush.error}`, 'warn');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
log('dispatch_start', {
|
|
423
|
+
transport: transportRoot,
|
|
424
|
+
alias,
|
|
425
|
+
version: RUNTIME_VERSION,
|
|
426
|
+
state_dir: stateDir(transportRoot),
|
|
427
|
+
claimed_models: [...registry.claimed.keys()],
|
|
428
|
+
total_models: registry.all.size,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Start the HTTP API on 127.0.0.1 so the host-side `crosstalk` client
|
|
432
|
+
// can talk to us. --once mode skips this — short-lived processes don't
|
|
433
|
+
// serve, and the test harnesses that invoke --once would conflict on
|
|
434
|
+
// the port. Operators get the API when running the continuous loop.
|
|
435
|
+
if (!onceMode) {
|
|
436
|
+
// v8 auth stores — gate the web UI behind a passphrase. JSON API
|
|
437
|
+
// stays open on loopback (matches v7 trust model).
|
|
438
|
+
// Storage lives under the transport state dir so it travels with
|
|
439
|
+
// the transport but stays out of the git repo (it's not committed).
|
|
440
|
+
const authDir = join(transportRoot, '..', 'crosstalk-state', 'auth');
|
|
441
|
+
const userStore = new FileUserStore(join(authDir, 'users.json'));
|
|
442
|
+
const tokenStore = new FileTokenStore(join(authDir, 'tokens.json'));
|
|
443
|
+
const setupState = new SetupState(join(authDir, 'setup-token'));
|
|
444
|
+
// First-run: mint setup token + log the URL so the operator can
|
|
445
|
+
// bootstrap from the browser. Subsequent boots: leave the
|
|
446
|
+
// existing token if present (operator may have stalled mid-setup).
|
|
447
|
+
if (await userStore.isEmpty() && !setupState.hasActiveToken) {
|
|
448
|
+
const rec = setupState.mint();
|
|
449
|
+
log('auth_first_run_setup_token', {
|
|
450
|
+
url: `http://127.0.0.1:<port>/setup?token=${rec.token}`,
|
|
451
|
+
note: 'Visit this URL in a browser to create the admin account. Replace <port> with the bound API port.',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
apiServer = startApi(
|
|
455
|
+
{
|
|
456
|
+
transportRoot,
|
|
457
|
+
alias: alias!,
|
|
458
|
+
version: RUNTIME_VERSION,
|
|
459
|
+
registry,
|
|
460
|
+
claimed: registry.claimed,
|
|
461
|
+
userStore,
|
|
462
|
+
tokenStore,
|
|
463
|
+
setupState,
|
|
464
|
+
},
|
|
465
|
+
{ log },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (onceMode) {
|
|
470
|
+
await dispatchTick(registry, protocolPrompt);
|
|
471
|
+
process.exit(0);
|
|
472
|
+
}
|
|
473
|
+
log('dispatch_running', { poll_s: pollSeconds });
|
|
474
|
+
|
|
475
|
+
while (true) {
|
|
476
|
+
try {
|
|
477
|
+
const didWork = await dispatchTick(registry, protocolPrompt);
|
|
478
|
+
if (didWork) {
|
|
479
|
+
await new Promise((res) => setTimeout(res, 1_000));
|
|
480
|
+
} else {
|
|
481
|
+
await waitForWakeOrTimeout(pollSeconds * 1_000);
|
|
482
|
+
}
|
|
483
|
+
} catch (err) {
|
|
484
|
+
const msg = (err as Error).message;
|
|
485
|
+
logError(transportRoot, `tick error: ${msg}`);
|
|
486
|
+
log('tick_error', { message: msg });
|
|
487
|
+
await new Promise((res) => setTimeout(res, pollSeconds * 1_000));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
main();
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// dispatchers.ts — the shared dispatcher registry.
|
|
2
|
+
//
|
|
3
|
+
// Each running dispatcher publishes its identity + claimed models to
|
|
4
|
+
// `data/dispatchers/<alias>.yaml` so workflow.ts can scope fanout
|
|
5
|
+
// sub-primitives across the actually-running dispatcher set instead of
|
|
6
|
+
// addressing every fanout to bare `<model>` and watching every claimant
|
|
7
|
+
// race for it.
|
|
8
|
+
//
|
|
9
|
+
// Lifecycle:
|
|
10
|
+
// - dispatcher startup → writeRegistryEntry (committed alongside other
|
|
11
|
+
// state changes by the next tick's gitCommitAndPush)
|
|
12
|
+
// - dispatcher SIGTERM/SIGINT → removeRegistryEntry (committed same way)
|
|
13
|
+
// - hard crash → entry stays; operator runs cleanup, or restarts
|
|
14
|
+
//
|
|
15
|
+
// Liveness is best-effort. v7.0.0-alpha.1 does NOT track per-tick
|
|
16
|
+
// heartbeats in the registry — the timestamp would force a commit every
|
|
17
|
+
// tick on every machine, which is far more push traffic than the system
|
|
18
|
+
// otherwise generates. Heartbeats live in the machine-local state dir
|
|
19
|
+
// (state.ts) for "is THIS dispatcher alive" checks; the registry answers
|
|
20
|
+
// "which dispatchers exist on the bus." A stale registry entry from a
|
|
21
|
+
// crashed dispatcher is an operator-cleanup concern, not a runtime concern.
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
26
|
+
|
|
27
|
+
export interface RegistryEntry {
|
|
28
|
+
alias: string;
|
|
29
|
+
claims: string[]; // model names this dispatcher has on PATH
|
|
30
|
+
version: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registryDir(transportRoot: string): string {
|
|
34
|
+
return join(transportRoot, 'data', 'dispatchers');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registryFile(transportRoot: string, alias: string): string {
|
|
38
|
+
return join(registryDir(transportRoot), `${alias}.yaml`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeRegistryEntry(
|
|
42
|
+
transportRoot: string,
|
|
43
|
+
alias: string,
|
|
44
|
+
claims: string[],
|
|
45
|
+
version: string,
|
|
46
|
+
): void {
|
|
47
|
+
mkdirSync(registryDir(transportRoot), { recursive: true });
|
|
48
|
+
const entry: RegistryEntry = { alias, claims, version };
|
|
49
|
+
writeFileSync(registryFile(transportRoot, alias), stringifyYaml(entry));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeRegistryEntry(transportRoot: string, alias: string): void {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(registryFile(transportRoot, alias));
|
|
55
|
+
} catch { /* already gone */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readAllRegistry(transportRoot: string): RegistryEntry[] {
|
|
59
|
+
const dir = registryDir(transportRoot);
|
|
60
|
+
if (!existsSync(dir)) return [];
|
|
61
|
+
const out: RegistryEntry[] = [];
|
|
62
|
+
for (const entry of readdirSync(dir)) {
|
|
63
|
+
if (!entry.endsWith('.yaml')) continue;
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(join(dir, entry), 'utf-8');
|
|
66
|
+
const parsed = parseYaml(raw) as Partial<RegistryEntry> | null;
|
|
67
|
+
if (
|
|
68
|
+
!parsed ||
|
|
69
|
+
typeof parsed.alias !== 'string' ||
|
|
70
|
+
!Array.isArray(parsed.claims) ||
|
|
71
|
+
typeof parsed.version !== 'string'
|
|
72
|
+
) continue;
|
|
73
|
+
out.push({
|
|
74
|
+
alias: parsed.alias,
|
|
75
|
+
claims: parsed.claims.map(String),
|
|
76
|
+
version: parsed.version,
|
|
77
|
+
});
|
|
78
|
+
} catch { /* skip malformed */ }
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Aliases of dispatchers currently claiming the given model, sorted for
|
|
84
|
+
// deterministic ordering — round-robin assignment becomes reproducible,
|
|
85
|
+
// which makes the test path predictable.
|
|
86
|
+
export function dispatchersForModel(transportRoot: string, modelName: string): string[] {
|
|
87
|
+
return readAllRegistry(transportRoot)
|
|
88
|
+
.filter((e) => e.claims.includes(modelName))
|
|
89
|
+
.map((e) => e.alias)
|
|
90
|
+
.sort();
|
|
91
|
+
}
|
package/src/filenames.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
|
|
3
|
+
export interface Timestamp {
|
|
4
|
+
iso: string;
|
|
5
|
+
pathDate: string;
|
|
6
|
+
fileTime: string;
|
|
7
|
+
hex: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function now(): Timestamp {
|
|
11
|
+
const d = new Date();
|
|
12
|
+
const iso = d.toISOString();
|
|
13
|
+
const yyyy = iso.slice(0, 4);
|
|
14
|
+
const mm = iso.slice(5, 7);
|
|
15
|
+
const dd = iso.slice(8, 10);
|
|
16
|
+
const time = iso.slice(11, 13) + iso.slice(14, 16) + iso.slice(17, 19);
|
|
17
|
+
const ms = iso.slice(20, 23);
|
|
18
|
+
return {
|
|
19
|
+
iso,
|
|
20
|
+
pathDate: `${yyyy}/${mm}/${dd}`,
|
|
21
|
+
fileTime: `${time}${ms}Z`,
|
|
22
|
+
hex: randomBytes(4).toString('hex'),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function messageFilename(ts: Timestamp = now()): string {
|
|
27
|
+
return `${ts.fileTime}-${ts.hex}.md`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
2
|
+
|
|
3
|
+
export interface ParsedDocument<T = Record<string, unknown>> {
|
|
4
|
+
data: T;
|
|
5
|
+
body: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseFrontmatter<T = Record<string, unknown>>(raw: string): ParsedDocument<T> {
|
|
9
|
+
if (!raw.startsWith('---')) return { data: {} as T, body: raw };
|
|
10
|
+
const lines = raw.split('\n');
|
|
11
|
+
let end = -1;
|
|
12
|
+
for (let i = 1; i < lines.length; i++) {
|
|
13
|
+
if (lines[i].trim() === '---') { end = i; break; }
|
|
14
|
+
}
|
|
15
|
+
if (end === -1) return { data: {} as T, body: raw };
|
|
16
|
+
const yamlBlock = lines.slice(1, end).join('\n');
|
|
17
|
+
const body = lines.slice(end + 1).join('\n').replace(/^\n/, '');
|
|
18
|
+
const data = (parseYaml(yamlBlock) as T) ?? ({} as T);
|
|
19
|
+
return { data, body };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeFrontmatter(data: Record<string, unknown>, body: string): string {
|
|
23
|
+
const yaml = stringifyYaml(data).trimEnd();
|
|
24
|
+
const trailing = body.endsWith('\n') ? '' : '\n';
|
|
25
|
+
return `---\n${yaml}\n---\n\n${body}${trailing}`;
|
|
26
|
+
}
|