@cordfuse/crosstalk 7.0.0-alpha.8 → 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 -122
- package/commands/up.js +0 -135
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// resolve.ts — model reference resolution.
|
|
2
|
+
//
|
|
3
|
+
// Operator-facing references can be either:
|
|
4
|
+
// - qualified: "google-personal/gemini-direct" (provider/model form)
|
|
5
|
+
// - bare: "gemini-direct" (model name only)
|
|
6
|
+
//
|
|
7
|
+
// Qualified references are looked up against the FULL parsed registry
|
|
8
|
+
// — including models declared in data/crosstalk.yaml but NOT locally
|
|
9
|
+
// claimed by this dispatcher. This is the cross-machine routing surface:
|
|
10
|
+
// the sender's API resolves the reference to its canonical qualified
|
|
11
|
+
// form and writes a commit; whichever peer dispatcher claims the model
|
|
12
|
+
// picks it up at its next tick (per PROTOCOL.md addressing rules).
|
|
13
|
+
// Restricting qualified lookups to `claimed` blocked cross-machine
|
|
14
|
+
// asymmetric dispatch — F-CROSS, caught in the alpha.14 capstone gate.
|
|
15
|
+
//
|
|
16
|
+
// Bare references still resolve via auto-disambiguation against the
|
|
17
|
+
// CLAIMED registry. Cross-machine bare-name dispatch is intentionally
|
|
18
|
+
// out of scope for v7.0 — the byBareName index can't pick a global
|
|
19
|
+
// unique across providers, and the disambiguation pick-list UX is
|
|
20
|
+
// rooted in "what's actually claimed here". Operators wanting cross-
|
|
21
|
+
// machine routing should use the qualified form (or `bare@machine`,
|
|
22
|
+
// which currently still requires the bare name to resolve locally; an
|
|
23
|
+
// alpha.15+ enhancement could lift that).
|
|
24
|
+
//
|
|
25
|
+
// - qualified, in registry → ok
|
|
26
|
+
// - qualified, not in registry → unknown
|
|
27
|
+
// - bare, 1 claimed match → ok
|
|
28
|
+
// - bare, 2+ claimed matches → ambiguous (pick-list)
|
|
29
|
+
// - bare, 0 claimed matches → unknown
|
|
30
|
+
//
|
|
31
|
+
// Same rule applies in every place a model is referenced: --to flag,
|
|
32
|
+
// frontmatter `to:` field, workflow PLAN.json targets, activation
|
|
33
|
+
// matching. resolve.ts is the single source of truth so the semantics
|
|
34
|
+
// are uniform.
|
|
35
|
+
|
|
36
|
+
import type { ModelEntry, ModelsRegistry } from './models.js';
|
|
37
|
+
|
|
38
|
+
export type ResolutionResult =
|
|
39
|
+
| { kind: 'ok'; model: ModelEntry }
|
|
40
|
+
| { kind: 'unknown'; ref: string }
|
|
41
|
+
| { kind: 'ambiguous'; ref: string; candidates: string[] };
|
|
42
|
+
|
|
43
|
+
export function resolveModelRef(ref: string, registry: ModelsRegistry): ResolutionResult {
|
|
44
|
+
const trimmed = ref.trim();
|
|
45
|
+
if (trimmed === '') return { kind: 'unknown', ref };
|
|
46
|
+
if (trimmed.includes('/')) {
|
|
47
|
+
// F-CROSS (alpha.15): look in the full registry, not just claimed.
|
|
48
|
+
// Sender's API resolves cross-machine references too; the per-tick
|
|
49
|
+
// claim check on each peer dispatcher is the correct enforcement
|
|
50
|
+
// point for "who actually handles this".
|
|
51
|
+
const m = registry.all.get(trimmed);
|
|
52
|
+
return m ? { kind: 'ok', model: m } : { kind: 'unknown', ref: trimmed };
|
|
53
|
+
}
|
|
54
|
+
// Bare name: scan byBareName, filter to claimed entries.
|
|
55
|
+
const matches = (registry.byBareName.get(trimmed) ?? [])
|
|
56
|
+
.filter((m) => registry.claimed.has(m.qualified));
|
|
57
|
+
if (matches.length === 1) return { kind: 'ok', model: matches[0]! };
|
|
58
|
+
if (matches.length === 0) return { kind: 'unknown', ref: trimmed };
|
|
59
|
+
return {
|
|
60
|
+
kind: 'ambiguous',
|
|
61
|
+
ref: trimmed,
|
|
62
|
+
candidates: matches.map((m) => m.qualified).sort(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatResolutionError(r: Exclude<ResolutionResult, { kind: 'ok' }>): string {
|
|
67
|
+
if (r.kind === 'unknown') {
|
|
68
|
+
return `unknown model '${r.ref}'`;
|
|
69
|
+
}
|
|
70
|
+
return (
|
|
71
|
+
`'${r.ref}' is ambiguous (${r.candidates.length} matches). Specify one:\n ` +
|
|
72
|
+
r.candidates.join('\n ')
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Resolve a `to:` field value that may carry an `@host` suffix. The host
|
|
77
|
+
// suffix is split off, the actor part resolved, then re-attached. This
|
|
78
|
+
// lets workflow scoping (`model@cachy`) work uniformly with bare or
|
|
79
|
+
// qualified model references.
|
|
80
|
+
export interface ToResolution {
|
|
81
|
+
kind: 'ok' | 'unknown' | 'ambiguous' | 'reserved';
|
|
82
|
+
qualified?: string; // for 'ok' or 'reserved' — full `<provider>/<model>[@host]`
|
|
83
|
+
error?: string; // for 'unknown' | 'ambiguous'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Reserved recipient names that are not models — workflow markers, etc.
|
|
87
|
+
// These pass through unchanged.
|
|
88
|
+
const RESERVED_RECIPIENTS = new Set(['workflow', 'all']);
|
|
89
|
+
|
|
90
|
+
export function resolveToField(toRaw: string, registry: ModelsRegistry): ToResolution {
|
|
91
|
+
const at = toRaw.indexOf('@');
|
|
92
|
+
const actorPart = at === -1 ? toRaw : toRaw.slice(0, at);
|
|
93
|
+
const hostSuffix = at === -1 ? '' : toRaw.slice(at);
|
|
94
|
+
if (RESERVED_RECIPIENTS.has(actorPart)) {
|
|
95
|
+
return { kind: 'reserved', qualified: toRaw };
|
|
96
|
+
}
|
|
97
|
+
const r = resolveModelRef(actorPart, registry);
|
|
98
|
+
if (r.kind === 'ok') return { kind: 'ok', qualified: r.model.qualified + hostSuffix };
|
|
99
|
+
return { kind: r.kind, error: formatResolutionError(r) };
|
|
100
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Machine-local dispatcher state. NONE of this lives in the transport repo —
|
|
2
|
+
// the repo carries conversation; each machine carries its own progress
|
|
3
|
+
// through it. That separation is what makes the dispatcher's git operations
|
|
4
|
+
// conflict-free: its commits only ever contain data/ (messages).
|
|
5
|
+
//
|
|
6
|
+
// v7 layout under the state dir (four files, no subdirectories):
|
|
7
|
+
// dispatcher.pid — PID of the running dispatcher process
|
|
8
|
+
// cursor — last-scanned git commit hash (single global cursor)
|
|
9
|
+
// heartbeat — last tick timestamp + pid + version (JSON)
|
|
10
|
+
// wake.signal — touched to wake the dispatch loop
|
|
11
|
+
// errors.log — infra failures, JSONL, append-only (best-effort)
|
|
12
|
+
//
|
|
13
|
+
// Location: $CROSSTALK_STATE_DIR if set (exact dir — container-friendly),
|
|
14
|
+
// else ~/.config/crosstalk/state/<basename(transportRoot)>/. v6 used a
|
|
15
|
+
// hashed-origin-URL dir under ~/.local/state; v7 uses the working tree's
|
|
16
|
+
// directory name for operator readability. Operators with two clones of
|
|
17
|
+
// the same transport get two state dirs — acceptable; different working
|
|
18
|
+
// trees may have genuinely different progress through the conversation.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readFileSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
unlinkSync,
|
|
26
|
+
appendFileSync,
|
|
27
|
+
} from 'fs';
|
|
28
|
+
import { join, basename } from 'path';
|
|
29
|
+
import { homedir } from 'os';
|
|
30
|
+
|
|
31
|
+
const resolved = new Map<string, string>();
|
|
32
|
+
|
|
33
|
+
export function stateDir(transportRoot: string): string {
|
|
34
|
+
const cached = resolved.get(transportRoot);
|
|
35
|
+
if (cached) return cached;
|
|
36
|
+
let dir = process.env['CROSSTALK_STATE_DIR'];
|
|
37
|
+
if (!dir) {
|
|
38
|
+
dir = join(homedir(), '.config', 'crosstalk', 'state', basename(transportRoot));
|
|
39
|
+
}
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
resolved.set(transportRoot, dir);
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── cursor (single, machine-global) ──
|
|
46
|
+
//
|
|
47
|
+
// A cursor is the git commit hash the transport was last scanned at. NOT a
|
|
48
|
+
// message relPath: filenames order by sender timestamp, but messages reach
|
|
49
|
+
// origin in PUSH order — a message that loses a push race can land on
|
|
50
|
+
// origin with a timestamp earlier than one already processed, and a
|
|
51
|
+
// relPath cursor would skip it forever. Commit-based cursors can't.
|
|
52
|
+
//
|
|
53
|
+
// v6 kept one cursor per (actor × channel). v7 collapses to a single
|
|
54
|
+
// machine-global cursor: a dispatcher processes everything new since the
|
|
55
|
+
// cursor in one tick, regardless of channel or claimed model.
|
|
56
|
+
|
|
57
|
+
const VALID_CURSOR = /^[0-9a-f]{40}$/;
|
|
58
|
+
|
|
59
|
+
export function cursorPath(transportRoot: string): string {
|
|
60
|
+
return join(stateDir(transportRoot), 'cursor');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readCursor(transportRoot: string): string | null {
|
|
64
|
+
const p = cursorPath(transportRoot);
|
|
65
|
+
if (!existsSync(p)) return null;
|
|
66
|
+
let raw: string;
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(p, 'utf-8').trim();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logError(transportRoot, `cursor read failed: ${(err as Error).message}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (raw.length === 0) return null;
|
|
74
|
+
if (!VALID_CURSOR.test(raw)) {
|
|
75
|
+
logError(transportRoot, `invalid cursor '${raw.slice(0, 80)}' — re-scanning from origin`, 'warn');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return raw;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function writeCursor(transportRoot: string, commit: string): void {
|
|
82
|
+
writeFileSync(cursorPath(transportRoot), commit + '\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── pidfile ──
|
|
86
|
+
|
|
87
|
+
export function pidfilePath(transportRoot: string): string {
|
|
88
|
+
return join(stateDir(transportRoot), 'dispatcher.pid');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function writePidfile(transportRoot: string): void {
|
|
92
|
+
try {
|
|
93
|
+
writeFileSync(pidfilePath(transportRoot), `${process.pid}\n`);
|
|
94
|
+
} catch { /* best-effort */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function removePidfile(transportRoot: string): void {
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(pidfilePath(transportRoot));
|
|
100
|
+
} catch { /* already gone */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readPidfile(transportRoot: string): number | null {
|
|
104
|
+
try {
|
|
105
|
+
const raw = readFileSync(pidfilePath(transportRoot), 'utf-8').trim();
|
|
106
|
+
const n = parseInt(raw, 10);
|
|
107
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── heartbeat + wake ──
|
|
114
|
+
|
|
115
|
+
export function writeHeartbeat(transportRoot: string, version: string, alias?: string): void {
|
|
116
|
+
try {
|
|
117
|
+
const data: Record<string, unknown> = { ts: new Date().toISOString(), pid: process.pid, version };
|
|
118
|
+
if (alias) data['alias'] = alias;
|
|
119
|
+
writeFileSync(join(stateDir(transportRoot), 'heartbeat'), JSON.stringify(data) + '\n');
|
|
120
|
+
} catch { /* best-effort */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function readHeartbeat(
|
|
124
|
+
transportRoot: string,
|
|
125
|
+
): { ts: string; pid: number; version: string; alias?: string } | null {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(readFileSync(join(stateDir(transportRoot), 'heartbeat'), 'utf-8'));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function wakeSignalPath(transportRoot: string): string {
|
|
134
|
+
return join(stateDir(transportRoot), 'wake.signal');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function sendWakeSignal(transportRoot: string): void {
|
|
138
|
+
try {
|
|
139
|
+
writeFileSync(wakeSignalPath(transportRoot), `${Date.now()}\n`);
|
|
140
|
+
} catch { /* best-effort */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── error log — infra events, JSONL append, best-effort ──
|
|
144
|
+
//
|
|
145
|
+
// Two levels:
|
|
146
|
+
// error — real failures the operator needs to investigate
|
|
147
|
+
// (corrupted state, unrecoverable runtime errors)
|
|
148
|
+
// warn — degraded-but-functional conditions
|
|
149
|
+
// (git push failed because remote unreachable; cursor
|
|
150
|
+
// unknown but we recovered by re-scanning; etc.)
|
|
151
|
+
//
|
|
152
|
+
// Dashboard counts only level=error. Legacy entries (pre-2026-06-22)
|
|
153
|
+
// have no level field — they're treated as 'error' for backcompat
|
|
154
|
+
// since they were written by call sites that hadn't been classified
|
|
155
|
+
// yet. Once those are reclassified, old entries fade out of the
|
|
156
|
+
// rolling window.
|
|
157
|
+
|
|
158
|
+
export type LogErrorLevel = 'error' | 'warn';
|
|
159
|
+
|
|
160
|
+
export function logError(
|
|
161
|
+
transportRoot: string,
|
|
162
|
+
message: string,
|
|
163
|
+
level: LogErrorLevel = 'error',
|
|
164
|
+
): void {
|
|
165
|
+
try {
|
|
166
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), level, message: message.slice(0, 500) });
|
|
167
|
+
appendFileSync(join(stateDir(transportRoot), 'errors.log'), line + '\n');
|
|
168
|
+
} catch { /* best-effort */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function countErrors(transportRoot: string): number {
|
|
172
|
+
try {
|
|
173
|
+
const raw = readFileSync(join(stateDir(transportRoot), 'errors.log'), 'utf-8');
|
|
174
|
+
let n = 0;
|
|
175
|
+
for (const line of raw.split('\n')) {
|
|
176
|
+
const t = line.trim();
|
|
177
|
+
if (t.length === 0) continue;
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(t) as { level?: string };
|
|
180
|
+
// Legacy entries (no level field) count as errors.
|
|
181
|
+
if (parsed.level === undefined || parsed.level === 'error') n++;
|
|
182
|
+
} catch {
|
|
183
|
+
n++; // malformed JSON — count as error so it doesn't go silent
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return n;
|
|
187
|
+
} catch {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/stop.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// crosstalk daemon stop — send SIGTERM to the running dispatcher and wait for it to exit.
|
|
2
|
+
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
import { readPidfile, removePidfile } from './state.js';
|
|
6
|
+
|
|
7
|
+
const transportRoot = resolve(process.cwd());
|
|
8
|
+
|
|
9
|
+
function processRunning(pid: number): boolean {
|
|
10
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const pid = readPidfile(transportRoot);
|
|
14
|
+
if (pid === null) {
|
|
15
|
+
console.error('crosstalk daemon stop: no dispatcher.pid found — is dispatch running?');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!processRunning(pid)) {
|
|
20
|
+
console.error(`crosstalk daemon stop: pid ${pid} is not running — removing stale pidfile`);
|
|
21
|
+
removePidfile(transportRoot);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
process.kill(pid, 'SIGTERM');
|
|
26
|
+
|
|
27
|
+
const deadline = Date.now() + 5_000;
|
|
28
|
+
while (Date.now() < deadline) {
|
|
29
|
+
if (!processRunning(pid)) {
|
|
30
|
+
console.log(`crosstalk daemon stop: dispatcher (pid ${pid}) stopped`);
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
spawnSync('sleep', ['0.1']);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.error(`crosstalk daemon stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
|
|
37
|
+
process.exit(1);
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Git transport layer. The dispatcher's commits contain ONLY data/ —
|
|
2
|
+
// machine-local state lives in the state dir (state.ts), so there is
|
|
3
|
+
// nothing to exclude, untrack, or heal. Push rejection means another
|
|
4
|
+
// machine won the race: pull --rebase and retry at the call site.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { spawnSync } from 'child_process';
|
|
9
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
10
|
+
import { logError } from './state.js';
|
|
11
|
+
|
|
12
|
+
export interface ChannelMessage {
|
|
13
|
+
relPath: string;
|
|
14
|
+
fullPath: string;
|
|
15
|
+
data: Record<string, unknown>;
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GitResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GitPushResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
committed: boolean;
|
|
27
|
+
pushed: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function captureGit(cwd: string, args: string[]): { status: number; stdout: string; stderr: string } {
|
|
32
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
|
33
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Detect and abort an interrupted rebase/merge left by a killed process.
|
|
37
|
+
// Returns true if recovery was performed.
|
|
38
|
+
export function recoverInterruptedGit(transportRoot: string): boolean {
|
|
39
|
+
const halfStates: { dir: string; abortArgs: string[] }[] = [
|
|
40
|
+
{ dir: '.git/rebase-merge', abortArgs: ['rebase', '--abort'] },
|
|
41
|
+
{ dir: '.git/rebase-apply', abortArgs: ['rebase', '--abort'] },
|
|
42
|
+
{ dir: '.git/MERGE_HEAD', abortArgs: ['merge', '--abort'] },
|
|
43
|
+
{ dir: '.git/CHERRY_PICK_HEAD', abortArgs: ['cherry-pick', '--abort'] },
|
|
44
|
+
];
|
|
45
|
+
for (const { dir, abortArgs } of halfStates) {
|
|
46
|
+
if (existsSync(join(transportRoot, dir))) {
|
|
47
|
+
const r = captureGit(transportRoot, abortArgs);
|
|
48
|
+
logError(
|
|
49
|
+
transportRoot,
|
|
50
|
+
`recovered from interrupted git state at ${dir} via 'git ${abortArgs.join(' ')}' (exit=${r.status})`,
|
|
51
|
+
);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The commit cursors anchor to. Use local HEAD: gitPull (called first
|
|
59
|
+
// in dispatchTick) already rebased origin commits into HEAD, so HEAD
|
|
60
|
+
// includes both remote and local-unpushed work. Using origin/HEAD
|
|
61
|
+
// would have stranded the dispatcher whenever push broke (commits
|
|
62
|
+
// pile up on HEAD past stale origin/HEAD; cursor never advances past
|
|
63
|
+
// origin/HEAD; activation pass thinks nothing's new). Caught
|
|
64
|
+
// 2026-06-22 — push failed silently due to missing ssh key, workflows
|
|
65
|
+
// got stuck in FANOUT forever.
|
|
66
|
+
//
|
|
67
|
+
// origin/* fallback only kicks in when local HEAD doesn't resolve
|
|
68
|
+
// (truly empty repo, no branch checked out — defensive only).
|
|
69
|
+
export function cursorBaseline(transportRoot: string): string | null {
|
|
70
|
+
for (const ref of ['HEAD', 'origin/HEAD', 'origin/main']) {
|
|
71
|
+
const r = captureGit(transportRoot, ['rev-parse', ref]);
|
|
72
|
+
if (r.status === 0) return r.stdout.trim();
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Repo-relative paths of message files added between `sinceCommit` and
|
|
78
|
+
// HEAD. Returns null when the commit is unknown to this clone (state dir
|
|
79
|
+
// copied across transports, history rewritten) — caller falls back to a
|
|
80
|
+
// full channel scan.
|
|
81
|
+
export function newFilesSince(transportRoot: string, sinceCommit: string): string[] | null {
|
|
82
|
+
const r = captureGit(transportRoot, [
|
|
83
|
+
'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..HEAD`, '--', 'data/channels/',
|
|
84
|
+
]);
|
|
85
|
+
if (r.status !== 0) return null;
|
|
86
|
+
return r.stdout.split('\n').filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function gitPull(transportRoot: string): GitResult {
|
|
90
|
+
recoverInterruptedGit(transportRoot);
|
|
91
|
+
const fetch = captureGit(transportRoot, ['fetch', 'origin', '--quiet']);
|
|
92
|
+
if (fetch.status !== 0) {
|
|
93
|
+
// No origin remote configured → not an error. Single-machine and
|
|
94
|
+
// local-only transports are valid; the dispatcher just operates on
|
|
95
|
+
// local commits.
|
|
96
|
+
const stderr = fetch.stderr.trim();
|
|
97
|
+
if (stderr.includes("does not appear to be a git repository") ||
|
|
98
|
+
stderr.includes("Could not read from remote") ||
|
|
99
|
+
stderr.includes("origin") && stderr.includes("does not appear")) {
|
|
100
|
+
return { ok: true };
|
|
101
|
+
}
|
|
102
|
+
return { ok: false, error: (fetch.stderr || fetch.stdout).trim().slice(0, 500) };
|
|
103
|
+
}
|
|
104
|
+
const rebase = captureGit(transportRoot, ['rebase', 'origin/main']);
|
|
105
|
+
if (rebase.status !== 0) {
|
|
106
|
+
// No origin/main (single-machine, no remote) → not an error.
|
|
107
|
+
if (rebase.stderr.includes('unknown revision') || rebase.stderr.includes('not a valid object name')) {
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, error: (rebase.stderr || rebase.stdout).trim().slice(0, 500) };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Stage data/ only, commit, push. On push rejection, one pull --rebase +
|
|
116
|
+
// re-push — collision-free filenames make the rebase trivially clean.
|
|
117
|
+
// In single-machine transports with no remote, the push step is a no-op
|
|
118
|
+
// (gracefully — git push fails fast on "No configured push destination",
|
|
119
|
+
// which we treat as success since the commit is already local).
|
|
120
|
+
export function gitCommitAndPush(transportRoot: string, message: string): GitPushResult {
|
|
121
|
+
const status = captureGit(transportRoot, ['status', '--porcelain', '--', 'data/']);
|
|
122
|
+
if (status.status !== 0) {
|
|
123
|
+
return { ok: false, committed: false, pushed: false, error: status.stderr.trim().slice(0, 500) };
|
|
124
|
+
}
|
|
125
|
+
if (!status.stdout.trim()) {
|
|
126
|
+
return { ok: true, committed: false, pushed: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const add = captureGit(transportRoot, ['add', '--', 'data/']);
|
|
130
|
+
if (add.status !== 0) {
|
|
131
|
+
return { ok: false, committed: false, pushed: false, error: add.stderr.trim().slice(0, 500) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const commit = captureGit(transportRoot, ['commit', '-m', message, '--', 'data/']);
|
|
135
|
+
if (commit.status !== 0) {
|
|
136
|
+
const noop = commit.stdout.includes('nothing to commit') ||
|
|
137
|
+
commit.stderr.includes('nothing to commit');
|
|
138
|
+
if (noop) return { ok: true, committed: false, pushed: false };
|
|
139
|
+
return { ok: false, committed: false, pushed: false, error: commit.stderr.trim().slice(0, 500) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Push rejection is NORMAL under concurrent writers — git is the
|
|
143
|
+
// arbiter and collision-free filenames make every rebase clean. Retry
|
|
144
|
+
// with jitter; many writers racing one origin converge within a few
|
|
145
|
+
// rounds. v6 used turnq as an advisory lock to reduce churn; v7 dropped
|
|
146
|
+
// it — rebase-retry is the correctness mechanism, turnq was overhead.
|
|
147
|
+
let push = captureGit(transportRoot, ['push', '--quiet']);
|
|
148
|
+
if (push.status !== 0) {
|
|
149
|
+
// No remote configured → push fails with "No configured push destination".
|
|
150
|
+
// Treat as success: commits stay local, which is the intended state
|
|
151
|
+
// for single-machine transports.
|
|
152
|
+
if (push.stderr.includes('No configured push destination') ||
|
|
153
|
+
push.stderr.includes("does not appear to be a git repository")) {
|
|
154
|
+
return { ok: true, committed: true, pushed: false };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (let attempt = 0; push.status !== 0 && attempt < 5; attempt++) {
|
|
158
|
+
spawnSync('sleep', [(0.05 + Math.random() * 0.3 * (attempt + 1)).toFixed(2)]);
|
|
159
|
+
const pull = gitPull(transportRoot);
|
|
160
|
+
if (!pull.ok) continue;
|
|
161
|
+
push = captureGit(transportRoot, ['push', '--quiet']);
|
|
162
|
+
}
|
|
163
|
+
if (push.status !== 0) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
committed: true,
|
|
167
|
+
pushed: false,
|
|
168
|
+
error: (push.stderr || push.stdout).trim().slice(0, 500),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, committed: true, pushed: true };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function discoverChannels(transportRoot: string): string[] {
|
|
175
|
+
const channelsDir = join(transportRoot, 'data', 'channels');
|
|
176
|
+
if (!existsSync(channelsDir)) return [];
|
|
177
|
+
let entries: string[];
|
|
178
|
+
try {
|
|
179
|
+
entries = readdirSync(channelsDir);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
logError(transportRoot, `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`);
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
return entries.filter((name) => {
|
|
185
|
+
try {
|
|
186
|
+
return statSync(join(channelsDir, name)).isDirectory();
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// v7 frontmatter contract (see transport/CROSSTALK.md §Messages):
|
|
194
|
+
// required: from (string), to (string), timestamp (string)
|
|
195
|
+
// optional: re, as, type ('workflow' only), failed, error, child_channel
|
|
196
|
+
// v6 required `type: text` on every message; v7 omits `type:` on regular
|
|
197
|
+
// messages. Readers must ignore UNKNOWN fields, but known fields with
|
|
198
|
+
// invalid values are protocol violations and rejected at parse time.
|
|
199
|
+
// This catches LLM models that revert to v6 muscle memory (writing
|
|
200
|
+
// `type: text` in hand-crafted frontmatter) — a real failure mode
|
|
201
|
+
// surfaced by the S10 fan-out workflow test.
|
|
202
|
+
function isValidMessageFrontmatter(data: Record<string, unknown>): boolean {
|
|
203
|
+
if (typeof data['from'] !== 'string') return false;
|
|
204
|
+
if (typeof data['to'] !== 'string' && !Array.isArray(data['to'])) return false;
|
|
205
|
+
if (typeof data['timestamp'] !== 'string') return false;
|
|
206
|
+
// `type:` is optional; only 'workflow' is valid in v7. Reject v6's
|
|
207
|
+
// `type: text` and any other invalid value.
|
|
208
|
+
if (data['type'] !== undefined && data['type'] !== 'workflow') return false;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function listChannelMessages(transportRoot: string, channelUuid: string): ChannelMessage[] {
|
|
213
|
+
const channelDir = join(transportRoot, 'data', 'channels', channelUuid);
|
|
214
|
+
if (!existsSync(channelDir)) return [];
|
|
215
|
+
const results: ChannelMessage[] = [];
|
|
216
|
+
const walk = (dir: string, prefix: string): void => {
|
|
217
|
+
for (const entry of readdirSync(dir)) {
|
|
218
|
+
const full = join(dir, entry);
|
|
219
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
220
|
+
let stat;
|
|
221
|
+
try { stat = statSync(full); } catch { continue; }
|
|
222
|
+
if (stat.isDirectory()) {
|
|
223
|
+
walk(full, rel);
|
|
224
|
+
} else if (entry.endsWith('.md') && entry !== 'CHANNEL.md') {
|
|
225
|
+
const raw = readFileSync(full, 'utf-8');
|
|
226
|
+
let parsed;
|
|
227
|
+
try {
|
|
228
|
+
parsed = parseFrontmatter(raw);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
logError(transportRoot, `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`, 'warn');
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!isValidMessageFrontmatter(parsed.data)) {
|
|
234
|
+
logError(transportRoot, `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, timestamp)`, 'warn');
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
results.push({ relPath: rel, fullPath: full, data: parsed.data, body: parsed.body });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
walk(channelDir, '');
|
|
242
|
+
return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
243
|
+
}
|