@cordfuse/crosstalk 6.0.0-alpha.9 → 7.0.0-alpha.2
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/README.md +26 -0
- package/bin/crosstalk.js +60 -74
- package/commands/channel.js +69 -0
- package/commands/chat.js +174 -0
- package/commands/down.js +37 -0
- package/commands/init.js +105 -0
- package/commands/logs.js +39 -0
- package/commands/pull.js +24 -0
- package/commands/replies.js +39 -0
- package/commands/restart.js +24 -0
- package/commands/run.js +121 -0
- package/commands/status.js +52 -0
- package/commands/up.js +129 -0
- package/commands/version.js +30 -0
- package/lib/api-client.js +80 -0
- package/lib/argv.js +28 -0
- package/lib/errors.js +19 -0
- package/lib/transport.js +51 -0
- package/package.json +5 -21
- package/src/activation.ts +0 -104
- package/src/actor.ts +0 -131
- package/src/attach.ts +0 -118
- package/src/channel.ts +0 -49
- package/src/chat.ts +0 -142
- package/src/dispatch.ts +0 -531
- package/src/dlq.ts +0 -216
- package/src/filenames.ts +0 -28
- package/src/frontmatter.ts +0 -26
- package/src/init.ts +0 -138
- package/src/open.ts +0 -207
- package/src/replies.ts +0 -59
- package/src/send.ts +0 -122
- package/src/state.ts +0 -173
- package/src/status.ts +0 -75
- package/src/stop.ts +0 -37
- package/src/transport.ts +0 -213
- package/src/turnq.ts +0 -91
- package/src/upgrade.ts +0 -211
- package/src/wake.ts +0 -7
- package/template/CLAUDE.md +0 -12
- package/template/gitignore +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -298
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -80
- package/template/upstream/actors/concierge.md +0 -36
package/src/send.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
// crosstalk send — write a message into a channel and push it.
|
|
2
|
-
//
|
|
3
|
-
// `re:` is the whole activation story (CROSSTALK.md "Activation"):
|
|
4
|
-
// inside a dispatched turn, CROSSTALK_DISPATCH_RE carries the relPath(s)
|
|
5
|
-
// of the message(s) being answered (comma-separated for a batch), and
|
|
6
|
-
// send links them all automatically. `--new` suppresses that — the
|
|
7
|
-
// message becomes a fresh task and wakes its addressee unconditionally.
|
|
8
|
-
// Operators (no env) always send new tasks.
|
|
9
|
-
|
|
10
|
-
import { resolve, join } from 'path';
|
|
11
|
-
import { mkdirSync, writeFileSync, existsSync } from 'fs';
|
|
12
|
-
import { now, messageFilename } from './filenames.js';
|
|
13
|
-
import { serializeFrontmatter } from './frontmatter.js';
|
|
14
|
-
import { gitCommitAndPush, discoverChannels } from './transport.js';
|
|
15
|
-
import { withLock } from './turnq.js';
|
|
16
|
-
import { sendWakeSignal } from './state.js';
|
|
17
|
-
|
|
18
|
-
const transportRoot = resolve(process.cwd());
|
|
19
|
-
const argv = process.argv.slice(2);
|
|
20
|
-
|
|
21
|
-
function flag(name: string): string | undefined {
|
|
22
|
-
const i = argv.indexOf(name);
|
|
23
|
-
if (i === -1 || i === argv.length - 1) return undefined;
|
|
24
|
-
return argv[i + 1];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function main(): Promise<void> {
|
|
28
|
-
let channelUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
|
|
29
|
-
const to = flag('--to');
|
|
30
|
-
const from = flag('--from')
|
|
31
|
-
?? process.env['CROSSTALK_DISPATCH_ACTOR']
|
|
32
|
-
?? process.env['USER']
|
|
33
|
-
?? 'operator';
|
|
34
|
-
const tier = flag('--tier');
|
|
35
|
-
const isNew = argv.includes('--new');
|
|
36
|
-
const body = argv[argv.length - 1];
|
|
37
|
-
|
|
38
|
-
if (!to || !body || body.startsWith('--')) {
|
|
39
|
-
console.error(
|
|
40
|
-
'Usage: crosstalk send --to <actor[,actor...]> [--channel <uuid>] [--from <actor>] [--tier <name>] [--new] "<message body>"',
|
|
41
|
-
);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (!channelUuid) {
|
|
46
|
-
const channels = discoverChannels(transportRoot);
|
|
47
|
-
if (channels.length === 1) {
|
|
48
|
-
channelUuid = channels[0]!;
|
|
49
|
-
} else if (channels.length === 0) {
|
|
50
|
-
console.error('crosstalk send: no channels found. Create one with: crosstalk channel create <name>');
|
|
51
|
-
process.exit(1);
|
|
52
|
-
} else {
|
|
53
|
-
console.error(`crosstalk send: multiple channels found — specify one with --channel <uuid>`);
|
|
54
|
-
console.error(` Run 'crosstalk status' to list channel UUIDs.`);
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Warn when --to names an actor@host whose host file isn't in the transport
|
|
60
|
-
// yet. The dispatcher's high-water-mark seeds past any commit before the
|
|
61
|
-
// host file landed, so messages to an un-provisioned host are silently
|
|
62
|
-
// skipped on first boot. Surfacing this here turns the silent drop into an
|
|
63
|
-
// operator-visible error before the message is even written.
|
|
64
|
-
const recipients = to.split(',').map((s) => s.trim()).filter(Boolean);
|
|
65
|
-
for (const recipient of recipients) {
|
|
66
|
-
const at = recipient.indexOf('@');
|
|
67
|
-
if (at === -1) continue;
|
|
68
|
-
const host = recipient.slice(at + 1);
|
|
69
|
-
const hostPath = join(transportRoot, 'hosts', `${host}.md`);
|
|
70
|
-
if (!existsSync(hostPath)) {
|
|
71
|
-
console.error(
|
|
72
|
-
`crosstalk send: WARNING — no host file at hosts/${host}.md. ` +
|
|
73
|
-
`Messages addressed to '${recipient}' will be skipped by that host ` +
|
|
74
|
-
`until hosts/${host}.md is committed to the transport.`,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const reTargets = (isNew ? '' : process.env['CROSSTALK_DISPATCH_RE'] ?? '')
|
|
80
|
-
.split(',')
|
|
81
|
-
.map((s) => s.trim())
|
|
82
|
-
.filter(Boolean);
|
|
83
|
-
|
|
84
|
-
const ts = now();
|
|
85
|
-
const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
|
|
86
|
-
mkdirSync(dir, { recursive: true });
|
|
87
|
-
|
|
88
|
-
const frontmatter: Record<string, unknown> = {
|
|
89
|
-
from,
|
|
90
|
-
to: to.includes(',') ? to.split(',').map((s) => s.trim()).filter(Boolean) : to,
|
|
91
|
-
type: 'text',
|
|
92
|
-
timestamp: ts.iso,
|
|
93
|
-
};
|
|
94
|
-
if (reTargets.length === 1) frontmatter['re'] = reTargets[0];
|
|
95
|
-
else if (reTargets.length > 1) frontmatter['re'] = reTargets;
|
|
96
|
-
if (tier) frontmatter['tier'] = tier;
|
|
97
|
-
|
|
98
|
-
const filename = messageFilename(ts);
|
|
99
|
-
writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
|
|
100
|
-
|
|
101
|
-
// Lock only the commit+push — the narrow critical section. If the lock
|
|
102
|
-
// can't be had, git arbitrates anyway (see turnq.ts).
|
|
103
|
-
const pushResult = await withLock(transportRoot, 'git', async () =>
|
|
104
|
-
gitCommitAndPush(transportRoot, `send: ${from} -> ${to} in ${channelUuid.slice(0, 8)}`),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
sendWakeSignal(transportRoot);
|
|
108
|
-
|
|
109
|
-
if (!pushResult.ok && pushResult.error) {
|
|
110
|
-
console.error(`Wrote locally: ${join(ts.pathDate, filename)}`);
|
|
111
|
-
console.error(`but git ${pushResult.committed ? 'push' : 'commit'} FAILED:`);
|
|
112
|
-
console.error(` ${pushResult.error.slice(0, 300)}`);
|
|
113
|
-
console.error('\nYour message is in the local clone but not on origin. Recover with:');
|
|
114
|
-
console.error(' git fetch origin && git rebase origin/main && git push');
|
|
115
|
-
process.exit(3);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
console.log(`Sent: ${join(ts.pathDate, filename)}${reTargets.length ? ` (re: ${reTargets.join(', ')})` : ''}`);
|
|
119
|
-
process.exit(0);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
main();
|
package/src/state.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
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
|
-
// Layout under the state dir:
|
|
7
|
-
// cursors/<actor>/<channel-uuid> — last-scanned git commit hash
|
|
8
|
-
// dlq/<id>.md — failed-dispatch entries
|
|
9
|
-
// errors.log — infra failures, JSONL, append-only
|
|
10
|
-
// heartbeat — last tick timestamp + pid + version
|
|
11
|
-
// dispatcher.pid — PID of the running dispatcher process
|
|
12
|
-
// wake.signal — touched to wake the dispatch loop
|
|
13
|
-
//
|
|
14
|
-
// Location: $CROSSTALK_STATE_DIR if set (exact dir — container-friendly),
|
|
15
|
-
// else ~/.local/state/crosstalk/<transport-id> where <transport-id> is a
|
|
16
|
-
// hash of the git origin URL (or the repo path when there is no origin).
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
existsSync,
|
|
20
|
-
mkdirSync,
|
|
21
|
-
readFileSync,
|
|
22
|
-
writeFileSync,
|
|
23
|
-
unlinkSync,
|
|
24
|
-
appendFileSync,
|
|
25
|
-
} from 'fs';
|
|
26
|
-
import { join, dirname } from 'path';
|
|
27
|
-
import { homedir } from 'os';
|
|
28
|
-
import { createHash } from 'crypto';
|
|
29
|
-
import { spawnSync } from 'child_process';
|
|
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
|
-
const origin = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
|
|
39
|
-
cwd: transportRoot,
|
|
40
|
-
encoding: 'utf-8',
|
|
41
|
-
});
|
|
42
|
-
const identity = origin.status === 0 && origin.stdout.trim()
|
|
43
|
-
? origin.stdout.trim()
|
|
44
|
-
: transportRoot;
|
|
45
|
-
const id = createHash('sha256').update(identity).digest('hex').slice(0, 12);
|
|
46
|
-
dir = join(homedir(), '.local', 'state', 'crosstalk', id);
|
|
47
|
-
}
|
|
48
|
-
mkdirSync(dir, { recursive: true });
|
|
49
|
-
resolved.set(transportRoot, dir);
|
|
50
|
-
return dir;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── cursors ──
|
|
54
|
-
|
|
55
|
-
// A cursor is the git commit hash the channel was last scanned at. NOT a
|
|
56
|
-
// message relPath: filenames order by sender timestamp, but messages reach
|
|
57
|
-
// origin in PUSH order — a message that loses a push race can land on
|
|
58
|
-
// origin with a timestamp earlier than one already processed, and a
|
|
59
|
-
// relPath cursor would skip it forever. Commit-based cursors can't: a late
|
|
60
|
-
// arrival is always in a new commit. (Found by the Monte Carlo harness.)
|
|
61
|
-
const VALID_CURSOR = /^[0-9a-f]{40}$/;
|
|
62
|
-
|
|
63
|
-
export function cursorPath(transportRoot: string, actor: string, channelUuid: string): string {
|
|
64
|
-
return join(stateDir(transportRoot), 'cursors', actor, channelUuid);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function readCursor(transportRoot: string, actor: string, channelUuid: string): string | null {
|
|
68
|
-
const p = cursorPath(transportRoot, actor, channelUuid);
|
|
69
|
-
if (!existsSync(p)) return null;
|
|
70
|
-
let raw: string;
|
|
71
|
-
try {
|
|
72
|
-
raw = readFileSync(p, 'utf-8').trim();
|
|
73
|
-
} catch (err) {
|
|
74
|
-
logError(transportRoot, 'fs', `cursor read failed for ${actor}@${channelUuid}: ${(err as Error).message}`);
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
if (raw.length === 0) return null;
|
|
78
|
-
if (!VALID_CURSOR.test(raw)) {
|
|
79
|
-
logError(
|
|
80
|
-
transportRoot,
|
|
81
|
-
'parse',
|
|
82
|
-
`invalid cursor for ${actor}@${channelUuid}: '${raw.slice(0, 80)}' — re-scanning channel from start`,
|
|
83
|
-
);
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
return raw;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function writeCursor(
|
|
90
|
-
transportRoot: string,
|
|
91
|
-
actor: string,
|
|
92
|
-
channelUuid: string,
|
|
93
|
-
commit: string,
|
|
94
|
-
): void {
|
|
95
|
-
const p = cursorPath(transportRoot, actor, channelUuid);
|
|
96
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
97
|
-
writeFileSync(p, commit + '\n');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ── pidfile ──
|
|
101
|
-
|
|
102
|
-
export function pidfilePath(transportRoot: string): string {
|
|
103
|
-
return join(stateDir(transportRoot), 'dispatcher.pid');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function writePidfile(transportRoot: string): void {
|
|
107
|
-
try {
|
|
108
|
-
writeFileSync(pidfilePath(transportRoot), `${process.pid}\n`);
|
|
109
|
-
} catch { /* best-effort */ }
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function removePidfile(transportRoot: string): void {
|
|
113
|
-
try {
|
|
114
|
-
unlinkSync(pidfilePath(transportRoot));
|
|
115
|
-
} catch { /* already gone */ }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function readPidfile(transportRoot: string): number | null {
|
|
119
|
-
try {
|
|
120
|
-
const raw = readFileSync(pidfilePath(transportRoot), 'utf-8').trim();
|
|
121
|
-
const n = parseInt(raw, 10);
|
|
122
|
-
return Number.isFinite(n) && n > 0 ? n : null;
|
|
123
|
-
} catch {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── heartbeat + wake ──
|
|
129
|
-
|
|
130
|
-
export function writeHeartbeat(transportRoot: string, version: string): void {
|
|
131
|
-
try {
|
|
132
|
-
const data = { ts: new Date().toISOString(), pid: process.pid, version };
|
|
133
|
-
writeFileSync(join(stateDir(transportRoot), 'heartbeat'), JSON.stringify(data) + '\n');
|
|
134
|
-
} catch { /* best-effort */ }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function readHeartbeat(transportRoot: string): { ts: string; pid: number; version: string } | null {
|
|
138
|
-
try {
|
|
139
|
-
return JSON.parse(readFileSync(join(stateDir(transportRoot), 'heartbeat'), 'utf-8'));
|
|
140
|
-
} catch {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function wakeSignalPath(transportRoot: string): string {
|
|
146
|
-
return join(stateDir(transportRoot), 'wake.signal');
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export function sendWakeSignal(transportRoot: string): void {
|
|
150
|
-
try {
|
|
151
|
-
writeFileSync(wakeSignalPath(transportRoot), `${Date.now()}\n`);
|
|
152
|
-
} catch { /* best-effort */ }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ── error log — infra failures, JSONL append ──
|
|
156
|
-
|
|
157
|
-
export type ErrorKind = 'git_pull' | 'git_push' | 'git_commit' | 'fs' | 'parse' | 'turnq' | 'other';
|
|
158
|
-
|
|
159
|
-
export function logError(transportRoot: string, kind: ErrorKind, message: string): void {
|
|
160
|
-
try {
|
|
161
|
-
const line = JSON.stringify({ ts: new Date().toISOString(), kind, message: message.slice(0, 500) });
|
|
162
|
-
appendFileSync(join(stateDir(transportRoot), 'errors.log'), line + '\n');
|
|
163
|
-
} catch { /* best-effort */ }
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function countErrors(transportRoot: string): number {
|
|
167
|
-
try {
|
|
168
|
-
const raw = readFileSync(join(stateDir(transportRoot), 'errors.log'), 'utf-8');
|
|
169
|
-
return raw.split('\n').filter((l) => l.trim().length > 0).length;
|
|
170
|
-
} catch {
|
|
171
|
-
return 0;
|
|
172
|
-
}
|
|
173
|
-
}
|
package/src/status.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
// crosstalk status — transport + dispatcher health at a glance.
|
|
2
|
-
|
|
3
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
-
import { resolve, join } from 'path';
|
|
5
|
-
import { findHostFile } from './actor.js';
|
|
6
|
-
import { discoverChannels } from './transport.js';
|
|
7
|
-
import { stateDir, readCursor, readHeartbeat, countErrors } from './state.js';
|
|
8
|
-
import { countDlqEntries } from './dlq.js';
|
|
9
|
-
|
|
10
|
-
const transportRoot = resolve(process.cwd());
|
|
11
|
-
|
|
12
|
-
console.log(`Crosstalk transport: ${transportRoot}`);
|
|
13
|
-
console.log(`Machine state dir: ${stateDir(transportRoot)}`);
|
|
14
|
-
console.log('');
|
|
15
|
-
|
|
16
|
-
let actorNames: string[] = [];
|
|
17
|
-
try {
|
|
18
|
-
const host = findHostFile(transportRoot);
|
|
19
|
-
console.log(`Host file: hosts/${host.alias}.md`);
|
|
20
|
-
actorNames = Object.keys(host.actors);
|
|
21
|
-
for (const a of actorNames) {
|
|
22
|
-
const tierDescs = Object.entries(host.actors[a]!).map(([t, v]) => {
|
|
23
|
-
const count = typeof v === 'object' && v.count ? `x${v.count}` : '';
|
|
24
|
-
return `${t}${count}`;
|
|
25
|
-
});
|
|
26
|
-
console.log(` ${a}: ${tierDescs.join(', ')}`);
|
|
27
|
-
}
|
|
28
|
-
} catch (err) {
|
|
29
|
-
console.log(`Host file: ERROR — ${(err as Error).message}`);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
console.log('');
|
|
33
|
-
|
|
34
|
-
const hb = readHeartbeat(transportRoot);
|
|
35
|
-
if (hb) {
|
|
36
|
-
const ageS = Math.floor((Date.now() - new Date(hb.ts).getTime()) / 1000);
|
|
37
|
-
const label = ageS < 120 ? 'LIVE' : ageS > 300 ? 'STALE' : 'idle';
|
|
38
|
-
console.log(`dispatch heartbeat: ${label} (pid ${hb.pid}, last tick ${ageS}s ago, ${hb.version})`);
|
|
39
|
-
} else {
|
|
40
|
-
console.log('dispatch heartbeat: never (no dispatch has run on this machine)');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
console.log('');
|
|
44
|
-
|
|
45
|
-
const channels = discoverChannels(transportRoot);
|
|
46
|
-
console.log(`Channels: ${channels.length}`);
|
|
47
|
-
for (const ch of channels) {
|
|
48
|
-
const channelDir = join(transportRoot, 'data', 'channels', ch);
|
|
49
|
-
let name = '(no CHANNEL.md)';
|
|
50
|
-
const chPath = join(channelDir, 'CHANNEL.md');
|
|
51
|
-
if (existsSync(chPath)) {
|
|
52
|
-
const m = readFileSync(chPath, 'utf-8').match(/^name:\s*(.+)$/m);
|
|
53
|
-
if (m) name = m[1]!.trim();
|
|
54
|
-
}
|
|
55
|
-
let msgCount = 0;
|
|
56
|
-
const walk = (d: string): void => {
|
|
57
|
-
for (const e of readdirSync(d)) {
|
|
58
|
-
const p = join(d, e);
|
|
59
|
-
if (statSync(p).isDirectory()) walk(p);
|
|
60
|
-
else if (e.endsWith('.md') && e !== 'CHANNEL.md') msgCount++;
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
try { walk(channelDir); } catch { /* ignore */ }
|
|
64
|
-
console.log(` ${ch.slice(0, 8)}... — ${name} (${msgCount} msgs)`);
|
|
65
|
-
for (const actor of actorNames) {
|
|
66
|
-
const cursor = readCursor(transportRoot, actor, ch);
|
|
67
|
-
if (cursor) console.log(` cursor[${actor}] -> ${cursor}`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
console.log('');
|
|
72
|
-
|
|
73
|
-
const dlq = countDlqEntries(transportRoot);
|
|
74
|
-
console.log(`DLQ entries: ${dlq.total} (${dlq.quarantined} quarantined)`);
|
|
75
|
-
console.log(`Infrastructure errors logged: ${countErrors(transportRoot)}`);
|
package/src/stop.ts
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// crosstalk 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 stop: no dispatcher.pid found — is dispatch running?');
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (!processRunning(pid)) {
|
|
20
|
-
console.error(`crosstalk 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 stop: dispatcher (pid ${pid}) stopped`);
|
|
31
|
-
process.exit(0);
|
|
32
|
-
}
|
|
33
|
-
spawnSync('sleep', ['0.1']);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
console.error(`crosstalk stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
|
|
37
|
-
process.exit(1);
|
package/src/transport.ts
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
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
|
-
'git_pull',
|
|
51
|
-
`recovered from interrupted git state at ${dir} via 'git ${abortArgs.join(' ')}' (exit=${r.status})`,
|
|
52
|
-
);
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// The commit cursors anchor to. Prefer the origin tip: origin history is
|
|
60
|
-
// append-only, so a cursor pointing there can never be orphaned by a local
|
|
61
|
-
// `pull --rebase` rewriting unpushed commits. HEAD is the fallback for
|
|
62
|
-
// transports without a remote.
|
|
63
|
-
export function cursorBaseline(transportRoot: string): string | null {
|
|
64
|
-
for (const ref of ['origin/HEAD', 'origin/main', 'HEAD']) {
|
|
65
|
-
const r = captureGit(transportRoot, ['rev-parse', ref]);
|
|
66
|
-
if (r.status === 0) return r.stdout.trim();
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Find the commit that introduced this actor's host file. Used to seed
|
|
72
|
-
// the cursor on first boot: messages sent after the host file landed are
|
|
73
|
-
// deliverable (store-and-forward); pre-join history is ignored. Falls back
|
|
74
|
-
// to HEAD if git log fails (conservative: no history replay in that case).
|
|
75
|
-
export function hostFileCommit(transportRoot: string, hostname: string): string | null {
|
|
76
|
-
const hostPath = `hosts/${hostname}.md`;
|
|
77
|
-
const r = captureGit(transportRoot, ['log', '--format=%H', '--diff-filter=A', '--', hostPath]);
|
|
78
|
-
if (r.status !== 0 || !r.stdout.trim()) return null;
|
|
79
|
-
// `git log` lists newest first; the last line is the introducing commit.
|
|
80
|
-
const lines = r.stdout.trim().split('\n').filter(Boolean);
|
|
81
|
-
return lines[lines.length - 1] ?? null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Repo-relative paths of message files added between `sinceCommit` and
|
|
85
|
-
// HEAD. Returns null when the commit is unknown to this clone (state dir
|
|
86
|
-
// copied across transports, history rewritten) — caller falls back to a
|
|
87
|
-
// full channel scan.
|
|
88
|
-
export function newFilesSince(transportRoot: string, sinceCommit: string): string[] | null {
|
|
89
|
-
const r = captureGit(transportRoot, [
|
|
90
|
-
'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..HEAD`, '--', 'data/channels/',
|
|
91
|
-
]);
|
|
92
|
-
if (r.status !== 0) return null;
|
|
93
|
-
return r.stdout.split('\n').filter(Boolean);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function gitPull(transportRoot: string): GitResult {
|
|
97
|
-
recoverInterruptedGit(transportRoot);
|
|
98
|
-
const fetch = captureGit(transportRoot, ['fetch', 'origin', '--quiet']);
|
|
99
|
-
if (fetch.status !== 0) {
|
|
100
|
-
return { ok: false, error: (fetch.stderr || fetch.stdout).trim().slice(0, 500) };
|
|
101
|
-
}
|
|
102
|
-
const rebase = captureGit(transportRoot, ['rebase', 'origin/main']);
|
|
103
|
-
if (rebase.status !== 0) {
|
|
104
|
-
return { ok: false, error: (rebase.stderr || rebase.stdout).trim().slice(0, 500) };
|
|
105
|
-
}
|
|
106
|
-
return { ok: true };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Stage data/ only, commit, push. On push rejection, one pull --rebase +
|
|
110
|
-
// re-push — collision-free filenames make the rebase trivially clean.
|
|
111
|
-
export function gitCommitAndPush(transportRoot: string, message: string): GitPushResult {
|
|
112
|
-
const status = captureGit(transportRoot, ['status', '--porcelain', '--', 'data/']);
|
|
113
|
-
if (status.status !== 0) {
|
|
114
|
-
return { ok: false, committed: false, pushed: false, error: status.stderr.trim().slice(0, 500) };
|
|
115
|
-
}
|
|
116
|
-
if (!status.stdout.trim()) {
|
|
117
|
-
return { ok: true, committed: false, pushed: false };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const add = captureGit(transportRoot, ['add', '--', 'data/']);
|
|
121
|
-
if (add.status !== 0) {
|
|
122
|
-
return { ok: false, committed: false, pushed: false, error: add.stderr.trim().slice(0, 500) };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const commit = captureGit(transportRoot, ['commit', '-m', message, '--', 'data/']);
|
|
126
|
-
if (commit.status !== 0) {
|
|
127
|
-
const noop = commit.stdout.includes('nothing to commit') ||
|
|
128
|
-
commit.stderr.includes('nothing to commit');
|
|
129
|
-
if (noop) return { ok: true, committed: false, pushed: false };
|
|
130
|
-
return { ok: false, committed: false, pushed: false, error: commit.stderr.trim().slice(0, 500) };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Push rejection is NORMAL under concurrent writers — git is the
|
|
134
|
-
// arbiter and collision-free filenames make every rebase clean. Retry
|
|
135
|
-
// with jitter; many writers racing one origin converge within a few
|
|
136
|
-
// rounds (verified by the Monte Carlo harness).
|
|
137
|
-
let push = captureGit(transportRoot, ['push', '--quiet']);
|
|
138
|
-
for (let attempt = 0; push.status !== 0 && attempt < 5; attempt++) {
|
|
139
|
-
spawnSync('sleep', [(0.05 + Math.random() * 0.3 * (attempt + 1)).toFixed(2)]);
|
|
140
|
-
const pull = gitPull(transportRoot);
|
|
141
|
-
if (!pull.ok) continue;
|
|
142
|
-
push = captureGit(transportRoot, ['push', '--quiet']);
|
|
143
|
-
}
|
|
144
|
-
if (push.status !== 0) {
|
|
145
|
-
return {
|
|
146
|
-
ok: false,
|
|
147
|
-
committed: true,
|
|
148
|
-
pushed: false,
|
|
149
|
-
error: (push.stderr || push.stdout).trim().slice(0, 500),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
return { ok: true, committed: true, pushed: true };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export function discoverChannels(transportRoot: string): string[] {
|
|
156
|
-
const channelsDir = join(transportRoot, 'data', 'channels');
|
|
157
|
-
if (!existsSync(channelsDir)) return [];
|
|
158
|
-
let entries: string[];
|
|
159
|
-
try {
|
|
160
|
-
entries = readdirSync(channelsDir);
|
|
161
|
-
} catch (err) {
|
|
162
|
-
logError(transportRoot, 'fs', `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`);
|
|
163
|
-
return [];
|
|
164
|
-
}
|
|
165
|
-
return entries.filter((name) => {
|
|
166
|
-
try {
|
|
167
|
-
return statSync(join(channelsDir, name)).isDirectory();
|
|
168
|
-
} catch {
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function isValidMessageFrontmatter(data: Record<string, unknown>): boolean {
|
|
175
|
-
if (typeof data['from'] !== 'string') return false;
|
|
176
|
-
if (typeof data['to'] !== 'string' && !Array.isArray(data['to'])) return false;
|
|
177
|
-
if (typeof data['type'] !== 'string') return false;
|
|
178
|
-
if (typeof data['timestamp'] !== 'string') return false;
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function listChannelMessages(transportRoot: string, channelUuid: string): ChannelMessage[] {
|
|
183
|
-
const channelDir = join(transportRoot, 'data', 'channels', channelUuid);
|
|
184
|
-
if (!existsSync(channelDir)) return [];
|
|
185
|
-
const results: ChannelMessage[] = [];
|
|
186
|
-
const walk = (dir: string, prefix: string): void => {
|
|
187
|
-
for (const entry of readdirSync(dir)) {
|
|
188
|
-
const full = join(dir, entry);
|
|
189
|
-
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
190
|
-
let stat;
|
|
191
|
-
try { stat = statSync(full); } catch { continue; }
|
|
192
|
-
if (stat.isDirectory()) {
|
|
193
|
-
walk(full, rel);
|
|
194
|
-
} else if (entry.endsWith('.md') && entry !== 'CHANNEL.md') {
|
|
195
|
-
const raw = readFileSync(full, 'utf-8');
|
|
196
|
-
let parsed;
|
|
197
|
-
try {
|
|
198
|
-
parsed = parseFrontmatter(raw);
|
|
199
|
-
} catch (err) {
|
|
200
|
-
logError(transportRoot, 'parse', `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (!isValidMessageFrontmatter(parsed.data)) {
|
|
204
|
-
logError(transportRoot, 'parse', `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, type, timestamp)`);
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
results.push({ relPath: rel, fullPath: full, data: parsed.data, body: parsed.body });
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
walk(channelDir, '');
|
|
212
|
-
return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
213
|
-
}
|