@cordfuse/crosstalk 5.0.0-alpha.7 → 6.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/bin/crosstalk.js +34 -78
- package/package.json +4 -4
- package/src/activation.ts +104 -0
- package/src/attach.ts +1 -1
- package/src/channel.ts +8 -21
- package/src/chat.ts +52 -115
- package/src/dispatch.ts +265 -660
- package/src/dlq.ts +68 -136
- package/src/init.ts +17 -41
- package/src/open.ts +55 -31
- package/src/replies.ts +59 -0
- package/src/send.ts +48 -67
- package/src/state.ts +173 -0
- package/src/status.ts +18 -57
- package/src/stop.ts +37 -0
- package/src/transport.ts +68 -198
- package/src/turnq.ts +64 -32
- package/src/upgrade.ts +9 -11
- package/src/wake.ts +5 -6
- package/src/cursor.ts +0 -48
- package/template/.amazonq/rules/crosstalk.md +0 -2
- package/template/.continue/rules/crosstalk.md +0 -7
- package/template/.cursor/rules/crosstalk.mdc +0 -7
- package/template/.github/copilot-instructions.md +0 -2
- package/template/.windsurfrules +0 -2
- package/template/AGENTS.md +0 -2
- package/template/ANTIGRAVITY.md +0 -2
- package/template/CLAUDE.md +0 -2
- package/template/GEMINI.md +0 -2
- package/template/OPENCODE.md +0 -2
- package/template/QWEN.md +0 -2
- package/template/README.md +0 -22
- package/template/local/CROSSTALK.md +0 -4
- package/template/upstream/CROSSTALK-VERSION +0 -1
- package/template/upstream/CROSSTALK.md +0 -589
- package/template/upstream/JITTER.md +0 -24
- package/template/upstream/OPERATOR.md +0 -60
- package/template/upstream/PROTOCOL.md +0 -260
- package/template/upstream/actors/cloud-architect.md +0 -83
- package/template/upstream/actors/concierge.md +0 -130
- package/template/upstream/actors/devops-engineer.md +0 -83
- package/template/upstream/actors/documentation-engineer.md +0 -107
- package/template/upstream/actors/infrastructure-engineer.md +0 -83
- package/template/upstream/actors/junior-developer.md +0 -83
- package/template/upstream/actors/precise-generalist.md +0 -48
- package/template/upstream/actors/product-manager.md +0 -83
- package/template/upstream/actors/qa-engineer.md +0 -83
- package/template/upstream/actors/security-engineer.md +0 -92
- package/template/upstream/actors/senior-generalist-engineer.md +0 -111
- package/template/upstream/actors/senior-software-engineer.md +0 -94
- package/template/upstream/actors/skeptic.md +0 -89
- package/template/upstream/actors/technical-writer.md +0 -89
- package/template/upstream/actors/ux-designer.md +0 -83
package/src/replies.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// crosstalk replies — have my messages been answered yet?
|
|
2
|
+
//
|
|
3
|
+
// Checks each given relPath for a message whose `re:` points back at it.
|
|
4
|
+
// This replaces v5's read-receipt machinery: instead of recipients writing
|
|
5
|
+
// "I saw it" markers into the channel, the asker just looks for actual
|
|
6
|
+
// replies. Exit 0 when everything is answered, 2 while anything is pending
|
|
7
|
+
// — so agents can poll cheaply in a loop.
|
|
8
|
+
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
import { gitPull, discoverChannels, listChannelMessages } from './transport.js';
|
|
11
|
+
import { reList } from './activation.js';
|
|
12
|
+
|
|
13
|
+
const transportRoot = resolve(process.cwd());
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
|
|
16
|
+
function flag(name: string): string | undefined {
|
|
17
|
+
const i = argv.indexOf(name);
|
|
18
|
+
if (i === -1 || i === argv.length - 1) return undefined;
|
|
19
|
+
return argv[i + 1];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const reArg = flag('--re');
|
|
23
|
+
const channelArg = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
|
|
24
|
+
|
|
25
|
+
if (!reArg) {
|
|
26
|
+
console.error('Usage: crosstalk replies --re <relPath[,relPath...]> [--channel <uuid>]');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const targets = reArg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
31
|
+
|
|
32
|
+
gitPull(transportRoot); // best-effort freshness; a failed pull just means slightly stale answers
|
|
33
|
+
|
|
34
|
+
const channels = channelArg ? [channelArg] : discoverChannels(transportRoot);
|
|
35
|
+
|
|
36
|
+
// target relPath -> reply found
|
|
37
|
+
const found = new Map<string, { from: string; relPath: string }>();
|
|
38
|
+
for (const channel of channels) {
|
|
39
|
+
for (const msg of listChannelMessages(transportRoot, channel)) {
|
|
40
|
+
for (const entry of reList(msg.data['re'])) {
|
|
41
|
+
if (targets.includes(entry) && !found.has(entry)) {
|
|
42
|
+
found.set(entry, { from: String(msg.data['from']), relPath: msg.relPath });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let pending = 0;
|
|
49
|
+
for (const target of targets) {
|
|
50
|
+
const reply = found.get(target);
|
|
51
|
+
if (reply) {
|
|
52
|
+
console.log(`REPLIED ${target} <- ${reply.from} (${reply.relPath})`);
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`PENDING ${target}`);
|
|
55
|
+
pending++;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
process.exit(pending > 0 ? 2 : 0);
|
package/src/send.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
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
|
+
|
|
1
10
|
import { resolve, join } from 'path';
|
|
2
11
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
3
12
|
import { now, messageFilename } from './filenames.js';
|
|
4
13
|
import { serializeFrontmatter } from './frontmatter.js';
|
|
5
14
|
import { gitCommitAndPush } from './transport.js';
|
|
6
15
|
import { withLock } from './turnq.js';
|
|
16
|
+
import { sendWakeSignal } from './state.js';
|
|
7
17
|
|
|
8
18
|
const transportRoot = resolve(process.cwd());
|
|
9
19
|
const argv = process.argv.slice(2);
|
|
@@ -15,93 +25,64 @@ function flag(name: string): string | undefined {
|
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
async function main(): Promise<void> {
|
|
18
|
-
const channelUuid = flag('--channel');
|
|
28
|
+
const channelUuid = flag('--channel') ?? process.env['CROSSTALK_DISPATCH_CHANNEL'];
|
|
19
29
|
const to = flag('--to');
|
|
20
|
-
// Sender identity precedence:
|
|
21
|
-
// 1. --from on the command line (explicit operator/actor choice)
|
|
22
|
-
// 2. CROSSTALK_DISPATCH_ACTOR env var (set by dispatch.ts when it spawns
|
|
23
|
-
// an actor's CLI — so the actor's outbound messages route as itself,
|
|
24
|
-
// not as the operator). Fixes the alpha.5 finding where concierge's
|
|
25
|
-
// fan-out messages went out as `from=steve` because send.ts fell
|
|
26
|
-
// through to USER instead.
|
|
27
|
-
// 3. $USER (interactive operator default)
|
|
28
|
-
// 4. literal 'steve' as last resort
|
|
29
30
|
const from = flag('--from')
|
|
30
31
|
?? process.env['CROSSTALK_DISPATCH_ACTOR']
|
|
31
32
|
?? process.env['USER']
|
|
32
|
-
?? '
|
|
33
|
+
?? 'operator';
|
|
33
34
|
const tier = flag('--tier');
|
|
34
|
-
|
|
35
|
-
// wake on receipt. `result` — informational reply, wakes the recipient only
|
|
36
|
-
// if it previously asked the sender for work (reply causality). See
|
|
37
|
-
// PROTOCOL.md "Message kinds". Proactive sends default to `work`; the
|
|
38
|
-
// runtime's auto-reply path defaults to `result`.
|
|
39
|
-
const kind = flag('--kind') ?? 'work';
|
|
35
|
+
const isNew = argv.includes('--new');
|
|
40
36
|
const body = argv[argv.length - 1];
|
|
41
37
|
|
|
42
38
|
if (!channelUuid || !to || !body || body.startsWith('--')) {
|
|
43
39
|
console.error(
|
|
44
|
-
'Usage: crosstalk send --
|
|
40
|
+
'Usage: crosstalk send --to <actor[,actor...]> [--channel <uuid>] [--from <actor>] [--tier <name>] [--new] "<message body>"',
|
|
45
41
|
);
|
|
46
42
|
process.exit(1);
|
|
47
43
|
}
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
const reTargets = (isNew ? '' : process.env['CROSSTALK_DISPATCH_RE'] ?? '')
|
|
46
|
+
.split(',')
|
|
47
|
+
.map((s) => s.trim())
|
|
48
|
+
.filter(Boolean);
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const ts = now();
|
|
51
|
+
const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
54
|
+
const frontmatter: Record<string, unknown> = {
|
|
55
|
+
from,
|
|
56
|
+
to: to.includes(',') ? to.split(',').map((s) => s.trim()).filter(Boolean) : to,
|
|
57
|
+
type: 'text',
|
|
58
|
+
timestamp: ts.iso,
|
|
59
|
+
};
|
|
60
|
+
if (reTargets.length === 1) frontmatter['re'] = reTargets[0];
|
|
61
|
+
else if (reTargets.length > 1) frontmatter['re'] = reTargets;
|
|
62
|
+
if (tier) frontmatter['tier'] = tier;
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
const filename = messageFilename(ts);
|
|
65
|
+
writeFileSync(join(dir, filename), serializeFrontmatter(frontmatter, body));
|
|
71
66
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
67
|
+
// Lock only the commit+push — the narrow critical section. If the lock
|
|
68
|
+
// can't be had, git arbitrates anyway (see turnq.ts).
|
|
69
|
+
const pushResult = await withLock(transportRoot, 'git', async () =>
|
|
70
|
+
gitCommitAndPush(transportRoot, `send: ${from} -> ${to} in ${channelUuid.slice(0, 8)}`),
|
|
71
|
+
);
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
const wakeDir = join(transportRoot, '.turnq');
|
|
80
|
-
mkdirSync(wakeDir, { recursive: true });
|
|
81
|
-
writeFileSync(join(wakeDir, 'wake.signal'), `${Date.now()}\n`);
|
|
82
|
-
} catch {
|
|
83
|
-
/* wake is best-effort */
|
|
84
|
-
}
|
|
73
|
+
sendWakeSignal(transportRoot);
|
|
85
74
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
console.error(` ${pushResult.error.slice(0, 300)}`);
|
|
95
|
-
console.error('');
|
|
96
|
-
console.error('Your message is in your local clone but not on origin.');
|
|
97
|
-
console.error('Recover with:');
|
|
98
|
-
console.error(' git pull --rebase');
|
|
99
|
-
console.error(' git push');
|
|
100
|
-
process.exit(3);
|
|
101
|
-
}
|
|
75
|
+
if (!pushResult.ok && pushResult.error) {
|
|
76
|
+
console.error(`Wrote locally: ${join(ts.pathDate, filename)}`);
|
|
77
|
+
console.error(`but git ${pushResult.committed ? 'push' : 'commit'} FAILED:`);
|
|
78
|
+
console.error(` ${pushResult.error.slice(0, 300)}`);
|
|
79
|
+
console.error('\nYour message is in the local clone but not on origin. Recover with:');
|
|
80
|
+
console.error(' git fetch origin && git rebase origin/main && git push');
|
|
81
|
+
process.exit(3);
|
|
82
|
+
}
|
|
102
83
|
|
|
103
|
-
|
|
104
|
-
|
|
84
|
+
console.log(`Sent: ${join(ts.pathDate, filename)}${reTargets.length ? ` (re: ${reTargets.join(', ')})` : ''}`);
|
|
85
|
+
process.exit(0);
|
|
105
86
|
}
|
|
106
87
|
|
|
107
88
|
main();
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
// crosstalk status — transport + dispatcher health at a glance.
|
|
2
|
+
|
|
1
3
|
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
2
4
|
import { resolve, join } from 'path';
|
|
3
5
|
import { findHostFile } from './actor.js';
|
|
4
|
-
import { discoverChannels
|
|
5
|
-
import { readCursor } from './
|
|
6
|
-
import {
|
|
7
|
-
import type { DlqEntry } from './dlq.js';
|
|
6
|
+
import { discoverChannels } from './transport.js';
|
|
7
|
+
import { stateDir, readCursor, readHeartbeat, countErrors } from './state.js';
|
|
8
|
+
import { countDlqEntries } from './dlq.js';
|
|
8
9
|
|
|
9
10
|
const transportRoot = resolve(process.cwd());
|
|
10
11
|
|
|
11
12
|
console.log(`Crosstalk transport: ${transportRoot}`);
|
|
13
|
+
console.log(`Machine state dir: ${stateDir(transportRoot)}`);
|
|
12
14
|
console.log('');
|
|
13
15
|
|
|
14
16
|
let actorNames: string[] = [];
|
|
@@ -17,8 +19,7 @@ try {
|
|
|
17
19
|
console.log(`Host file: hosts/${host.alias}.md`);
|
|
18
20
|
actorNames = Object.keys(host.actors);
|
|
19
21
|
for (const a of actorNames) {
|
|
20
|
-
const
|
|
21
|
-
const tierDescs = tiers.map(([t, v]) => {
|
|
22
|
+
const tierDescs = Object.entries(host.actors[a]!).map(([t, v]) => {
|
|
22
23
|
const count = typeof v === 'object' && v.count ? `x${v.count}` : '';
|
|
23
24
|
return `${t}${count}`;
|
|
24
25
|
});
|
|
@@ -30,33 +31,13 @@ try {
|
|
|
30
31
|
|
|
31
32
|
console.log('');
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
console.log(`turnq lock: HELD by pid ${meta.pid} for ${ageS}s (since ${meta.acquired})`);
|
|
39
|
-
} catch {
|
|
40
|
-
console.log('turnq lock: HELD (unparseable metadata)');
|
|
41
|
-
}
|
|
42
|
-
} else {
|
|
43
|
-
console.log('turnq lock: free');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const heartbeatPath = join(transportRoot, '.turnq', 'heartbeat');
|
|
47
|
-
if (existsSync(heartbeatPath)) {
|
|
48
|
-
try {
|
|
49
|
-
const hb = JSON.parse(readFileSync(heartbeatPath, 'utf-8')) as { ts: string; pid: number; version?: string };
|
|
50
|
-
const ageS = Math.floor((Date.now() - new Date(hb.ts).getTime()) / 1000);
|
|
51
|
-
const fresh = ageS < 120;
|
|
52
|
-
const stale = ageS > 300;
|
|
53
|
-
const label = fresh ? 'LIVE' : stale ? 'STALE' : 'idle';
|
|
54
|
-
console.log(`dispatch heartbeat: ${label} (pid ${hb.pid}, last tick ${ageS}s ago${hb.version ? `, ${hb.version}` : ''})`);
|
|
55
|
-
} catch {
|
|
56
|
-
console.log('dispatch heartbeat: (unparseable)');
|
|
57
|
-
}
|
|
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})`);
|
|
58
39
|
} else {
|
|
59
|
-
console.log('dispatch heartbeat: never (no dispatch has run on this
|
|
40
|
+
console.log('dispatch heartbeat: never (no dispatch has run on this machine)');
|
|
60
41
|
}
|
|
61
42
|
|
|
62
43
|
console.log('');
|
|
@@ -69,7 +50,7 @@ for (const ch of channels) {
|
|
|
69
50
|
const chPath = join(channelDir, 'CHANNEL.md');
|
|
70
51
|
if (existsSync(chPath)) {
|
|
71
52
|
const m = readFileSync(chPath, 'utf-8').match(/^name:\s*(.+)$/m);
|
|
72
|
-
if (m) name = m[1]
|
|
53
|
+
if (m) name = m[1]!.trim();
|
|
73
54
|
}
|
|
74
55
|
let msgCount = 0;
|
|
75
56
|
const walk = (d: string): void => {
|
|
@@ -81,34 +62,14 @@ for (const ch of channels) {
|
|
|
81
62
|
};
|
|
82
63
|
try { walk(channelDir); } catch { /* ignore */ }
|
|
83
64
|
console.log(` ${ch.slice(0, 8)}... — ${name} (${msgCount} msgs)`);
|
|
84
|
-
|
|
85
65
|
for (const actor of actorNames) {
|
|
86
66
|
const cursor = readCursor(transportRoot, actor, ch);
|
|
87
|
-
if (cursor) console.log(` cursor[${actor}]
|
|
67
|
+
if (cursor) console.log(` cursor[${actor}] -> ${cursor}`);
|
|
88
68
|
}
|
|
89
69
|
}
|
|
90
70
|
|
|
91
71
|
console.log('');
|
|
92
72
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
let dlqDispatch = 0;
|
|
97
|
-
let dlqConfig = 0;
|
|
98
|
-
if (existsSync(dlqDir)) {
|
|
99
|
-
for (const f of readdirSync(dlqDir).filter((x) => x.endsWith('.md'))) {
|
|
100
|
-
try {
|
|
101
|
-
const { data } = parseFrontmatter<DlqEntry>(readFileSync(join(dlqDir, f), 'utf-8'));
|
|
102
|
-
dlqTotal++;
|
|
103
|
-
if (data.quarantined) dlqQuarantined++;
|
|
104
|
-
if (data.kind === 'dispatch') dlqDispatch++;
|
|
105
|
-
if (data.kind === 'config') dlqConfig++;
|
|
106
|
-
} catch { /* skip unparseable */ }
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
console.log(
|
|
110
|
-
`DLQ entries: ${dlqTotal} (${dlqDispatch} dispatch, ${dlqConfig} config, ${dlqQuarantined} quarantined)`,
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
const errorCount = countErrorEntries(transportRoot);
|
|
114
|
-
console.log(`Infrastructure errors logged: ${errorCount}`);
|
|
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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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);
|