@cordfuse/crosstalk 5.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.
Files changed (52) hide show
  1. package/bin/crosstalk.js +111 -0
  2. package/package.json +46 -0
  3. package/src/actor.ts +106 -0
  4. package/src/attach.ts +118 -0
  5. package/src/channel.ts +62 -0
  6. package/src/chat.ts +203 -0
  7. package/src/cursor.ts +48 -0
  8. package/src/dispatch.ts +519 -0
  9. package/src/dlq.ts +263 -0
  10. package/src/filenames.ts +28 -0
  11. package/src/frontmatter.ts +26 -0
  12. package/src/init.ts +157 -0
  13. package/src/open.ts +183 -0
  14. package/src/send.ts +80 -0
  15. package/src/status.ts +114 -0
  16. package/src/transport.ts +303 -0
  17. package/src/turnq.ts +59 -0
  18. package/src/upgrade.ts +213 -0
  19. package/src/wake.ts +8 -0
  20. package/template/.amazonq/rules/crosstalk.md +2 -0
  21. package/template/.continue/rules/crosstalk.md +7 -0
  22. package/template/.cursor/rules/crosstalk.mdc +7 -0
  23. package/template/.github/copilot-instructions.md +2 -0
  24. package/template/.windsurfrules +2 -0
  25. package/template/AGENTS.md +2 -0
  26. package/template/ANTIGRAVITY.md +2 -0
  27. package/template/CLAUDE.md +2 -0
  28. package/template/GEMINI.md +2 -0
  29. package/template/OPENCODE.md +2 -0
  30. package/template/QWEN.md +2 -0
  31. package/template/README.md +22 -0
  32. package/template/local/CROSSTALK.md +4 -0
  33. package/template/upstream/CROSSTALK-VERSION +1 -0
  34. package/template/upstream/CROSSTALK.md +589 -0
  35. package/template/upstream/JITTER.md +24 -0
  36. package/template/upstream/OPERATOR.md +60 -0
  37. package/template/upstream/PROTOCOL.md +180 -0
  38. package/template/upstream/actors/cloud-architect.md +83 -0
  39. package/template/upstream/actors/concierge.md +105 -0
  40. package/template/upstream/actors/devops-engineer.md +83 -0
  41. package/template/upstream/actors/documentation-engineer.md +107 -0
  42. package/template/upstream/actors/infrastructure-engineer.md +83 -0
  43. package/template/upstream/actors/junior-developer.md +83 -0
  44. package/template/upstream/actors/precise-generalist.md +48 -0
  45. package/template/upstream/actors/product-manager.md +83 -0
  46. package/template/upstream/actors/qa-engineer.md +83 -0
  47. package/template/upstream/actors/security-engineer.md +92 -0
  48. package/template/upstream/actors/senior-generalist-engineer.md +111 -0
  49. package/template/upstream/actors/senior-software-engineer.md +94 -0
  50. package/template/upstream/actors/skeptic.md +89 -0
  51. package/template/upstream/actors/technical-writer.md +89 -0
  52. package/template/upstream/actors/ux-designer.md +83 -0
package/src/send.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { resolve, join } from 'path';
2
+ import { mkdirSync, writeFileSync } from 'fs';
3
+ import { now, messageFilename } from './filenames.js';
4
+ import { serializeFrontmatter } from './frontmatter.js';
5
+ import { gitCommitAndPush, writeErrorLog } from './transport.js';
6
+ import { withLock } from './turnq.js';
7
+
8
+ const transportRoot = resolve(process.cwd());
9
+ const argv = process.argv.slice(2);
10
+
11
+ function flag(name: string): string | undefined {
12
+ const i = argv.indexOf(name);
13
+ if (i === -1 || i === argv.length - 1) return undefined;
14
+ return argv[i + 1];
15
+ }
16
+
17
+ async function main(): Promise<void> {
18
+ const channelUuid = flag('--channel');
19
+ const to = flag('--to');
20
+ const from = flag('--from') ?? 'steve';
21
+ const tier = flag('--tier');
22
+ const body = argv[argv.length - 1];
23
+
24
+ if (!channelUuid || !to || !body || body.startsWith('--')) {
25
+ console.error(
26
+ 'Usage: npx tsx runtime/src/send.ts --channel <uuid> --to <actor> [--from <actor>] [--tier <name>] "<message body>"',
27
+ );
28
+ process.exit(1);
29
+ }
30
+
31
+ await withLock('dispatch', async () => {
32
+ const ts = now();
33
+ const dir = join(transportRoot, 'data', 'channels', channelUuid, ts.pathDate);
34
+ mkdirSync(dir, { recursive: true });
35
+
36
+ const frontmatter: Record<string, unknown> = {
37
+ from,
38
+ to,
39
+ type: 'text',
40
+ timestamp: ts.iso,
41
+ };
42
+ if (tier) frontmatter.tier = tier;
43
+ const content = serializeFrontmatter(frontmatter, body);
44
+
45
+ const filename = messageFilename(ts);
46
+ writeFileSync(join(dir, filename), content);
47
+
48
+ const pushResult = gitCommitAndPush(
49
+ transportRoot,
50
+ `send: ${from} -> ${to} in ${channelUuid.slice(0, 8)}`,
51
+ );
52
+
53
+ // Poke dispatch to tick immediately (even if push failed — local cursor still advances).
54
+ try {
55
+ const wakeDir = join(transportRoot, '.turnq');
56
+ mkdirSync(wakeDir, { recursive: true });
57
+ writeFileSync(join(wakeDir, 'wake.signal'), `${Date.now()}\n`);
58
+ } catch {
59
+ /* wake is best-effort */
60
+ }
61
+
62
+ if (!pushResult.ok && pushResult.error) {
63
+ const kind = pushResult.committed ? 'git_push' : 'git_commit';
64
+ const errId = writeErrorLog(transportRoot, kind, pushResult.error);
65
+ console.error(`Wrote locally: ${join(ts.pathDate, filename)}`);
66
+ console.error(
67
+ `but git ${kind === 'git_push' ? 'push' : 'commit'} FAILED (errors/${errId}.md):`,
68
+ );
69
+ console.error(` ${pushResult.error.slice(0, 200)}`);
70
+ console.error(
71
+ ' Message is in your local clone but not on origin. Resolve the git issue and re-push manually.',
72
+ );
73
+ process.exit(3);
74
+ }
75
+
76
+ console.log(`Sent: ${join(ts.pathDate, filename)}`);
77
+ });
78
+ }
79
+
80
+ main();
package/src/status.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
2
+ import { resolve, join } from 'path';
3
+ import { findHostFile } from './actor.js';
4
+ import { discoverChannels, countErrorEntries } from './transport.js';
5
+ import { readCursor } from './cursor.js';
6
+ import { parseFrontmatter } from './frontmatter.js';
7
+ import type { DlqEntry } from './dlq.js';
8
+
9
+ const transportRoot = resolve(process.cwd());
10
+
11
+ console.log(`Crosstalk transport: ${transportRoot}`);
12
+ console.log('');
13
+
14
+ let actorNames: string[] = [];
15
+ try {
16
+ const host = findHostFile(transportRoot);
17
+ console.log(`Host file: hosts/${host.alias}.md`);
18
+ actorNames = Object.keys(host.actors);
19
+ for (const a of actorNames) {
20
+ const tiers = Object.entries(host.actors[a]);
21
+ const tierDescs = tiers.map(([t, v]) => {
22
+ const count = typeof v === 'object' && v.count ? `x${v.count}` : '';
23
+ return `${t}${count}`;
24
+ });
25
+ console.log(` ${a}: ${tierDescs.join(', ')}`);
26
+ }
27
+ } catch (err) {
28
+ console.log(`Host file: ERROR — ${(err as Error).message}`);
29
+ }
30
+
31
+ console.log('');
32
+
33
+ const lockPath = join(transportRoot, '.turnq', 'dispatch.lock');
34
+ if (existsSync(lockPath)) {
35
+ try {
36
+ const meta = JSON.parse(readFileSync(lockPath, 'utf-8')) as { pid: number; acquired: string };
37
+ const ageS = Math.floor((Date.now() - new Date(meta.acquired).getTime()) / 1000);
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
+ }
58
+ } else {
59
+ console.log('dispatch heartbeat: never (no dispatch has run on this clone)');
60
+ }
61
+
62
+ console.log('');
63
+
64
+ const channels = discoverChannels(transportRoot);
65
+ console.log(`Channels: ${channels.length}`);
66
+ for (const ch of channels) {
67
+ const channelDir = join(transportRoot, 'data', 'channels', ch);
68
+ let name = '(no CHANNEL.md)';
69
+ const chPath = join(channelDir, 'CHANNEL.md');
70
+ if (existsSync(chPath)) {
71
+ const m = readFileSync(chPath, 'utf-8').match(/^name:\s*(.+)$/m);
72
+ if (m) name = m[1].trim();
73
+ }
74
+ let msgCount = 0;
75
+ const walk = (d: string): void => {
76
+ for (const e of readdirSync(d)) {
77
+ const p = join(d, e);
78
+ if (statSync(p).isDirectory()) walk(p);
79
+ else if (e.endsWith('.md') && e !== 'CHANNEL.md') msgCount++;
80
+ }
81
+ };
82
+ try { walk(channelDir); } catch { /* ignore */ }
83
+ console.log(` ${ch.slice(0, 8)}... — ${name} (${msgCount} msgs)`);
84
+
85
+ for (const actor of actorNames) {
86
+ const cursor = readCursor(transportRoot, actor, ch);
87
+ if (cursor) console.log(` cursor[${actor}] → ${cursor}`);
88
+ }
89
+ }
90
+
91
+ console.log('');
92
+
93
+ const dlqDir = join(transportRoot, 'dlq');
94
+ let dlqTotal = 0;
95
+ let dlqQuarantined = 0;
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}`);
@@ -0,0 +1,303 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { spawnSync } from 'child_process';
4
+ import { parseFrontmatter, serializeFrontmatter } from './frontmatter.js';
5
+ import { now } from './filenames.js';
6
+
7
+ export interface ChannelMessage {
8
+ relPath: string;
9
+ fullPath: string;
10
+ data: Record<string, unknown>;
11
+ body: string;
12
+ }
13
+
14
+ export interface GitResult {
15
+ ok: boolean;
16
+ error?: string;
17
+ }
18
+
19
+ export interface GitPushResult {
20
+ ok: boolean;
21
+ committed: boolean;
22
+ pushed: boolean;
23
+ error?: string;
24
+ }
25
+
26
+ function captureGit(cwd: string, args: string[]): { status: number; stdout: string; stderr: string } {
27
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
28
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
29
+ }
30
+
31
+ // Detect and auto-recover from an interrupted rebase/merge in the working tree.
32
+ // Returns true if recovery was performed (caller should treat as a non-fatal
33
+ // infrastructure event). The error log surfaces the recovery in errors/.
34
+ export function recoverInterruptedGit(transportRoot: string): boolean {
35
+ const halfStates: { dir: string; abortArgs: string[] }[] = [
36
+ { dir: '.git/rebase-merge', abortArgs: ['rebase', '--abort'] },
37
+ { dir: '.git/rebase-apply', abortArgs: ['rebase', '--abort'] },
38
+ { dir: '.git/MERGE_HEAD', abortArgs: ['merge', '--abort'] },
39
+ { dir: '.git/CHERRY_PICK_HEAD', abortArgs: ['cherry-pick', '--abort'] },
40
+ ];
41
+ for (const { dir, abortArgs } of halfStates) {
42
+ if (existsSync(join(transportRoot, dir))) {
43
+ const r = captureGit(transportRoot, abortArgs);
44
+ writeErrorLog(
45
+ transportRoot,
46
+ 'git_pull',
47
+ `recovered from interrupted git state at ${dir} via 'git ${abortArgs.join(' ')}' (exit=${r.status})`,
48
+ );
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export function gitPull(transportRoot: string): GitResult {
56
+ recoverInterruptedGit(transportRoot);
57
+ const r = captureGit(transportRoot, ['pull', '--rebase', '--quiet']);
58
+ if (r.status !== 0) {
59
+ return { ok: false, error: (r.stderr || r.stdout).trim().slice(0, 500) };
60
+ }
61
+ return { ok: true };
62
+ }
63
+
64
+ export function gitCommitAndPush(transportRoot: string, message: string): GitPushResult {
65
+ const status = captureGit(transportRoot, ['status', '--porcelain']);
66
+ if (status.status !== 0) {
67
+ return { ok: false, committed: false, pushed: false, error: status.stderr.trim().slice(0, 500) };
68
+ }
69
+ if (!status.stdout.trim()) {
70
+ return { ok: true, committed: false, pushed: false };
71
+ }
72
+
73
+ const add = captureGit(transportRoot, ['add', '-A']);
74
+ if (add.status !== 0) {
75
+ return { ok: false, committed: false, pushed: false, error: add.stderr.trim().slice(0, 500) };
76
+ }
77
+
78
+ const commit = captureGit(transportRoot, ['commit', '-m', message]);
79
+ if (commit.status !== 0) {
80
+ return { ok: false, committed: false, pushed: false, error: commit.stderr.trim().slice(0, 500) };
81
+ }
82
+
83
+ const push = captureGit(transportRoot, ['push', '--quiet']);
84
+ if (push.status !== 0) {
85
+ return {
86
+ ok: false,
87
+ committed: true,
88
+ pushed: false,
89
+ error: (push.stderr || push.stdout).trim().slice(0, 500),
90
+ };
91
+ }
92
+ return { ok: true, committed: true, pushed: true };
93
+ }
94
+
95
+ export function discoverChannels(transportRoot: string): string[] {
96
+ const channelsDir = join(transportRoot, 'data', 'channels');
97
+ if (!existsSync(channelsDir)) return [];
98
+ let entries: string[];
99
+ try {
100
+ entries = readdirSync(channelsDir);
101
+ } catch (err) {
102
+ writeErrorLog(
103
+ transportRoot,
104
+ 'fs',
105
+ `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`,
106
+ );
107
+ return [];
108
+ }
109
+ return entries.filter((name) => {
110
+ const dir = join(channelsDir, name);
111
+ try {
112
+ return statSync(dir).isDirectory();
113
+ } catch (err) {
114
+ writeErrorLog(
115
+ transportRoot,
116
+ 'fs',
117
+ `discoverChannels stat failed on ${dir}: ${(err as Error).message}`,
118
+ );
119
+ return false;
120
+ }
121
+ });
122
+ }
123
+
124
+ function isValidMessageFrontmatter(data: Record<string, unknown>): boolean {
125
+ // Required: from (string), to (string or string[]), type (string), timestamp (string)
126
+ if (typeof data.from !== 'string') return false;
127
+ if (typeof data.to !== 'string' && !Array.isArray(data.to)) return false;
128
+ if (typeof data.type !== 'string') return false;
129
+ if (typeof data.timestamp !== 'string') return false;
130
+ return true;
131
+ }
132
+
133
+ export function listChannelMessages(transportRoot: string, channelUuid: string): ChannelMessage[] {
134
+ const channelDir = join(transportRoot, 'data', 'channels', channelUuid);
135
+ if (!existsSync(channelDir)) return [];
136
+ const results: ChannelMessage[] = [];
137
+ const walk = (dir: string, prefix: string): void => {
138
+ for (const entry of readdirSync(dir)) {
139
+ const full = join(dir, entry);
140
+ const rel = prefix ? `${prefix}/${entry}` : entry;
141
+ let stat;
142
+ try { stat = statSync(full); } catch { continue; }
143
+ if (stat.isDirectory()) {
144
+ walk(full, rel);
145
+ } else if (entry.endsWith('.md') && entry !== 'CHANNEL.md') {
146
+ const raw = readFileSync(full, 'utf-8');
147
+ let parsed;
148
+ try {
149
+ parsed = parseFrontmatter(raw);
150
+ } catch (err) {
151
+ writeErrorLog(
152
+ transportRoot,
153
+ 'parse',
154
+ `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`,
155
+ );
156
+ continue;
157
+ }
158
+ if (!isValidMessageFrontmatter(parsed.data)) {
159
+ writeErrorLog(
160
+ transportRoot,
161
+ 'parse',
162
+ `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, type, timestamp)`,
163
+ );
164
+ continue;
165
+ }
166
+ results.push({ relPath: rel, fullPath: full, data: parsed.data, body: parsed.body });
167
+ }
168
+ }
169
+ };
170
+ walk(channelDir, '');
171
+ return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
172
+ }
173
+
174
+ // ── errors/ log — infrastructure failures (git, fs, etc.) ──
175
+ // Deduped by (kind + signature). If a matching entry exists, increment count
176
+ // + update lastAt. Otherwise create a new entry.
177
+
178
+ export type ErrorKind = 'git_pull' | 'git_push' | 'git_commit' | 'fs' | 'parse' | 'other';
179
+
180
+ interface ErrorEntry {
181
+ id: string;
182
+ kind: ErrorKind;
183
+ signature: string;
184
+ count: number;
185
+ firstAt: string;
186
+ lastAt: string;
187
+ error: string;
188
+ }
189
+
190
+ function errorSignature(kind: ErrorKind, error: string): string {
191
+ const firstLine = error.split('\n')[0] ?? '';
192
+ return `${kind}::${firstLine.trim().slice(0, 120)}`;
193
+ }
194
+
195
+ export function writeErrorLog(transportRoot: string, kind: ErrorKind, error: string): string {
196
+ const dir = join(transportRoot, 'errors');
197
+ mkdirSync(dir, { recursive: true });
198
+ const sig = errorSignature(kind, error);
199
+
200
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')) : [];
201
+ for (const f of files) {
202
+ const path = join(dir, f);
203
+ try {
204
+ const { data } = parseFrontmatter<ErrorEntry>(readFileSync(path, 'utf-8'));
205
+ if (data.signature === sig) {
206
+ const updated: ErrorEntry = {
207
+ ...data,
208
+ count: (data.count ?? 1) + 1,
209
+ lastAt: new Date().toISOString(),
210
+ error: error.slice(0, 500),
211
+ };
212
+ writeFileSync(path, serializeFrontmatter(updated as unknown as Record<string, unknown>, error));
213
+ return data.id;
214
+ }
215
+ } catch { /* skip unparseable */ }
216
+ }
217
+
218
+ const ts = now();
219
+ const id = `${ts.fileTime}-${ts.hex}`;
220
+ const entry: ErrorEntry = {
221
+ id, kind, signature: sig,
222
+ count: 1,
223
+ firstAt: ts.iso, lastAt: ts.iso,
224
+ error: error.slice(0, 500),
225
+ };
226
+ writeFileSync(
227
+ join(dir, `${id}.md`),
228
+ serializeFrontmatter(entry as unknown as Record<string, unknown>, error),
229
+ );
230
+ return id;
231
+ }
232
+
233
+ export function countErrorEntries(transportRoot: string): number {
234
+ const dir = join(transportRoot, 'errors');
235
+ if (!existsSync(dir)) return 0;
236
+ return readdirSync(dir).filter((f) => f.endsWith('.md')).length;
237
+ }
238
+
239
+ // Sweep channels for read receipts older than `thresholdMs` that lack a
240
+ // corresponding `type: text` reply from the same actor referencing the same
241
+ // original message. Each stale receipt is logged once to errors/ kind=parse
242
+ // (we reuse 'parse' rather than introduce another kind for one weak signal).
243
+ // Returns the number of stale receipts surfaced.
244
+ export function sweepStaleReadReceipts(
245
+ transportRoot: string,
246
+ thresholdMs: number,
247
+ ): number {
248
+ const channelsDir = join(transportRoot, 'data', 'channels');
249
+ if (!existsSync(channelsDir)) return 0;
250
+ let channels: string[];
251
+ try {
252
+ channels = readdirSync(channelsDir).filter((name) => {
253
+ try { return statSync(join(channelsDir, name)).isDirectory(); }
254
+ catch { return false; }
255
+ });
256
+ } catch { return 0; }
257
+
258
+ const now = Date.now();
259
+ let surfaced = 0;
260
+
261
+ for (const channelUuid of channels) {
262
+ const messages = listChannelMessages(transportRoot, channelUuid);
263
+
264
+ // Build: for each (fromActor, refRelPath), did a text reply follow the read receipt?
265
+ const readReceipts: { actor: string; ref: string; ts: number; relPath: string }[] = [];
266
+ const replyKeys = new Set<string>();
267
+
268
+ for (const msg of messages) {
269
+ const from = typeof msg.data['from'] === 'string' ? msg.data['from'] : null;
270
+ const type = typeof msg.data['type'] === 'string' ? msg.data['type'] : null;
271
+ const timestamp = typeof msg.data['timestamp'] === 'string' ? msg.data['timestamp'] : null;
272
+ const ref = typeof msg.data['ref'] === 'string' ? msg.data['ref'] : null;
273
+ if (!from || !type || !timestamp) continue;
274
+
275
+ if (type === 'read' && ref) {
276
+ readReceipts.push({
277
+ actor: from,
278
+ ref,
279
+ ts: new Date(timestamp).getTime(),
280
+ relPath: msg.relPath,
281
+ });
282
+ } else if (type === 'text') {
283
+ // A text reply doesn't carry a ref field. We approximate "did this
284
+ // actor reply after the read receipt" by keying on actor only; if
285
+ // any later text exists from the same actor in this channel, treat
286
+ // earlier read receipts as resolved. Imperfect but cheap.
287
+ replyKeys.add(from);
288
+ }
289
+ }
290
+
291
+ for (const rr of readReceipts) {
292
+ if (replyKeys.has(rr.actor)) continue;
293
+ if (now - rr.ts <= thresholdMs) continue;
294
+ writeErrorLog(
295
+ transportRoot,
296
+ 'parse',
297
+ `stale read receipt ${channelUuid}/${rr.relPath}: actor=${rr.actor} claimed ref=${rr.ref} ${Math.floor((now - rr.ts) / 60000)}min ago, no reply yet`,
298
+ );
299
+ surfaced++;
300
+ }
301
+ }
302
+ return surfaced;
303
+ }
package/src/turnq.ts ADDED
@@ -0,0 +1,59 @@
1
+ // Thin wrapper around @cordfuse/turnq's coordinator.
2
+ //
3
+ // Two modes, picked automatically by the underlying coordinator:
4
+ //
5
+ // Local (default, no env vars set): file lock via flock(2). Safe across
6
+ // multiple processes on the same host; the OS releases the lock if the
7
+ // process dies, so stale locks are impossible.
8
+ //
9
+ // Distributed (env var TURNQ_URL set): HTTP coordinator running on a
10
+ // reachable turnq server. Serializes turns across multiple hosts. If
11
+ // the server is unreachable, falls back to local silently (default
12
+ // `fallback: true`).
13
+ //
14
+ // Env vars:
15
+ // TURNQ_URL — optional, e.g. http://turnq:3003 (unset → local mode)
16
+ // TURNQ_API_KEY — required when TURNQ_URL is set
17
+ // TURNQ_CHANNEL — optional namespace prefix (default: "crosstalk").
18
+ // Concatenated with the lock name to form the actual
19
+ // channel — e.g. "crosstalk/dispatch". Lets multiple
20
+ // transports share the same turnq server without
21
+ // colliding.
22
+
23
+ import { createCoordinator } from '@cordfuse/turnq/coordinator';
24
+
25
+ interface Coordinator {
26
+ withTurn<T>(channel: string, fn: () => Promise<T>): Promise<T>;
27
+ close(): void;
28
+ }
29
+
30
+ let coordinatorPromise: Promise<Coordinator> | null = null;
31
+
32
+ function getCoordinator(): Promise<Coordinator> {
33
+ if (!coordinatorPromise) {
34
+ const url = process.env.TURNQ_URL;
35
+ const apiKey = process.env.TURNQ_API_KEY;
36
+ coordinatorPromise = createCoordinator(
37
+ url ? { url, apiKey, fallback: true } : {},
38
+ );
39
+ }
40
+ return coordinatorPromise;
41
+ }
42
+
43
+ function channelFor(name: string): string {
44
+ const prefix = process.env.TURNQ_CHANNEL ?? 'crosstalk';
45
+ return `${prefix}/${name}`;
46
+ }
47
+
48
+ /**
49
+ * Run `fn` while holding the turn for the named lock. Queues if another
50
+ * caller holds it; runs in FIFO order. The lock auto-releases when `fn`
51
+ * resolves or rejects.
52
+ *
53
+ * In local mode this is a flock(2) on a temp file. In distributed mode
54
+ * it's an HTTP turn on a cordfuse/turnq server.
55
+ */
56
+ export async function withLock<T>(name: string, fn: () => Promise<T>): Promise<T> {
57
+ const coordinator = await getCoordinator();
58
+ return coordinator.withTurn(channelFor(name), fn);
59
+ }