spidersan 0.6.0 → 0.9.0
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/CHANGELOG.md +42 -52
- package/README.md +238 -3
- package/dist/bin/spidersan.d.ts.map +1 -1
- package/dist/bin/spidersan.js +13 -1
- package/dist/bin/spidersan.js.map +1 -1
- package/dist/commands/abandon.d.ts.map +1 -1
- package/dist/commands/abandon.js +1 -9
- package/dist/commands/abandon.js.map +1 -1
- package/dist/commands/ai.d.ts +15 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +498 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/auto.d.ts.map +1 -1
- package/dist/commands/auto.js +2 -1
- package/dist/commands/auto.js.map +1 -1
- package/dist/commands/bot.d.ts +16 -0
- package/dist/commands/bot.d.ts.map +1 -0
- package/dist/commands/bot.js +398 -0
- package/dist/commands/bot.js.map +1 -0
- package/dist/commands/conflicts.d.ts.map +1 -1
- package/dist/commands/conflicts.js +260 -277
- package/dist/commands/conflicts.js.map +1 -1
- package/dist/commands/context.d.ts +8 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +104 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/cross-conflicts.d.ts.map +1 -1
- package/dist/commands/cross-conflicts.js +1 -41
- package/dist/commands/cross-conflicts.js.map +1 -1
- package/dist/commands/depends.d.ts.map +1 -1
- package/dist/commands/depends.js +1 -9
- package/dist/commands/depends.js.map +1 -1
- package/dist/commands/fleet-status.d.ts +14 -0
- package/dist/commands/fleet-status.d.ts.map +1 -0
- package/dist/commands/fleet-status.js +127 -0
- package/dist/commands/fleet-status.js.map +1 -0
- package/dist/commands/git-watch.d.ts +24 -0
- package/dist/commands/git-watch.d.ts.map +1 -0
- package/dist/commands/git-watch.js +84 -0
- package/dist/commands/git-watch.js.map +1 -0
- package/dist/commands/index.d.ts +5 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +7 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/merge-order.d.ts.map +1 -1
- package/dist/commands/merge-order.js +18 -67
- package/dist/commands/merge-order.js.map +1 -1
- package/dist/commands/merged.d.ts.map +1 -1
- package/dist/commands/merged.js +1 -9
- package/dist/commands/merged.js.map +1 -1
- package/dist/commands/pulse.d.ts.map +1 -1
- package/dist/commands/pulse.js +134 -63
- package/dist/commands/pulse.js.map +1 -1
- package/dist/commands/queen.d.ts.map +1 -1
- package/dist/commands/queen.js +11 -7
- package/dist/commands/queen.js.map +1 -1
- package/dist/commands/ready-check.d.ts +2 -1
- package/dist/commands/ready-check.d.ts.map +1 -1
- package/dist/commands/ready-check.js +6 -30
- package/dist/commands/ready-check.js.map +1 -1
- package/dist/commands/register.d.ts.map +1 -1
- package/dist/commands/register.js +7 -29
- package/dist/commands/register.js.map +1 -1
- package/dist/commands/torrent.d.ts.map +1 -1
- package/dist/commands/torrent.js +29 -18
- package/dist/commands/torrent.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +13 -34
- package/dist/commands/watch.js.map +1 -1
- package/dist/lib/ai/context-builder.d.ts +16 -0
- package/dist/lib/ai/context-builder.d.ts.map +1 -0
- package/dist/lib/ai/context-builder.js +216 -0
- package/dist/lib/ai/context-builder.js.map +1 -0
- package/dist/lib/ai/event-handler.d.ts +21 -0
- package/dist/lib/ai/event-handler.d.ts.map +1 -0
- package/dist/lib/ai/event-handler.js +98 -0
- package/dist/lib/ai/event-handler.js.map +1 -0
- package/dist/lib/ai/index.d.ts +13 -0
- package/dist/lib/ai/index.d.ts.map +1 -0
- package/dist/lib/ai/index.js +11 -0
- package/dist/lib/ai/index.js.map +1 -0
- package/dist/lib/ai/llm-client.d.ts +37 -0
- package/dist/lib/ai/llm-client.d.ts.map +1 -0
- package/dist/lib/ai/llm-client.js +225 -0
- package/dist/lib/ai/llm-client.js.map +1 -0
- package/dist/lib/ai/reasoner.d.ts +11 -0
- package/dist/lib/ai/reasoner.d.ts.map +1 -0
- package/dist/lib/ai/reasoner.js +246 -0
- package/dist/lib/ai/reasoner.js.map +1 -0
- package/dist/lib/ai/setup.d.ts +40 -0
- package/dist/lib/ai/setup.d.ts.map +1 -0
- package/dist/lib/ai/setup.js +154 -0
- package/dist/lib/ai/setup.js.map +1 -0
- package/dist/lib/ai/types.d.ts +135 -0
- package/dist/lib/ai/types.d.ts.map +1 -0
- package/dist/lib/ai/types.js +39 -0
- package/dist/lib/ai/types.js.map +1 -0
- package/dist/lib/colony-subscriber.d.ts +15 -12
- package/dist/lib/colony-subscriber.d.ts.map +1 -1
- package/dist/lib/colony-subscriber.js +146 -65
- package/dist/lib/colony-subscriber.js.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +18 -4
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/conflict-analyzer.d.ts +33 -0
- package/dist/lib/conflict-analyzer.d.ts.map +1 -0
- package/dist/lib/conflict-analyzer.js +114 -0
- package/dist/lib/conflict-analyzer.js.map +1 -0
- package/dist/lib/conflict-renderer.d.ts +7 -0
- package/dist/lib/conflict-renderer.d.ts.map +1 -0
- package/dist/lib/conflict-renderer.js +162 -0
- package/dist/lib/conflict-renderer.js.map +1 -0
- package/dist/lib/conflict-tier.d.ts +20 -0
- package/dist/lib/conflict-tier.d.ts.map +1 -0
- package/dist/lib/conflict-tier.js +49 -0
- package/dist/lib/conflict-tier.js.map +1 -0
- package/dist/lib/crypto.js +1 -1
- package/dist/lib/crypto.js.map +1 -1
- package/dist/lib/git-events-subscriber.d.ts +59 -0
- package/dist/lib/git-events-subscriber.d.ts.map +1 -0
- package/dist/lib/git-events-subscriber.js +779 -0
- package/dist/lib/git-events-subscriber.js.map +1 -0
- package/dist/lib/git.d.ts +15 -0
- package/dist/lib/git.d.ts.map +1 -0
- package/dist/lib/git.js +180 -0
- package/dist/lib/git.js.map +1 -0
- package/dist/lib/github.d.ts.map +1 -1
- package/dist/lib/github.js +14 -9
- package/dist/lib/github.js.map +1 -1
- package/dist/lib/graph.d.ts +23 -0
- package/dist/lib/graph.d.ts.map +1 -0
- package/dist/lib/graph.js +134 -0
- package/dist/lib/graph.js.map +1 -0
- package/dist/lib/hub.d.ts +31 -0
- package/dist/lib/hub.d.ts.map +1 -0
- package/dist/lib/hub.js +92 -0
- package/dist/lib/hub.js.map +1 -0
- package/dist/lib/remote-drift.d.ts +60 -0
- package/dist/lib/remote-drift.d.ts.map +1 -0
- package/dist/lib/remote-drift.js +225 -0
- package/dist/lib/remote-drift.js.map +1 -0
- package/dist/lib/salvage-analyzer.d.ts.map +1 -1
- package/dist/lib/salvage-analyzer.js +2 -3
- package/dist/lib/salvage-analyzer.js.map +1 -1
- package/dist/lib/security.d.ts +11 -0
- package/dist/lib/security.d.ts.map +1 -1
- package/dist/lib/security.js +24 -1
- package/dist/lib/security.js.map +1 -1
- package/dist/lib/session-logger.d.ts +54 -0
- package/dist/lib/session-logger.d.ts.map +1 -0
- package/dist/lib/session-logger.js +136 -0
- package/dist/lib/session-logger.js.map +1 -0
- package/dist/storage/adapter.d.ts +4 -0
- package/dist/storage/adapter.d.ts.map +1 -1
- package/dist/storage/branch-registry-store.d.ts +13 -0
- package/dist/storage/branch-registry-store.d.ts.map +1 -0
- package/dist/storage/branch-registry-store.js +2 -0
- package/dist/storage/branch-registry-store.js.map +1 -0
- package/dist/storage/factory.d.ts +4 -0
- package/dist/storage/factory.d.ts.map +1 -1
- package/dist/storage/factory.js +25 -9
- package/dist/storage/factory.js.map +1 -1
- package/dist/storage/git-messages.d.ts +53 -0
- package/dist/storage/git-messages.d.ts.map +1 -0
- package/dist/storage/git-messages.js +376 -0
- package/dist/storage/git-messages.js.map +1 -0
- package/dist/storage/index.d.ts +5 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +5 -0
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/json-branch-registry-store.d.ts +19 -0
- package/dist/storage/json-branch-registry-store.d.ts.map +1 -0
- package/dist/storage/json-branch-registry-store.js +112 -0
- package/dist/storage/json-branch-registry-store.js.map +1 -0
- package/dist/storage/local-messages.d.ts +37 -0
- package/dist/storage/local-messages.d.ts.map +1 -0
- package/dist/storage/local-messages.js +151 -0
- package/dist/storage/local-messages.js.map +1 -0
- package/dist/storage/local.d.ts +2 -6
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +13 -76
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/memory-branch-registry-store.d.ts +16 -0
- package/dist/storage/memory-branch-registry-store.d.ts.map +1 -0
- package/dist/storage/memory-branch-registry-store.js +65 -0
- package/dist/storage/memory-branch-registry-store.js.map +1 -0
- package/dist/storage/message-adapter.d.ts +86 -0
- package/dist/storage/message-adapter.d.ts.map +1 -0
- package/dist/storage/message-adapter.js +28 -0
- package/dist/storage/message-adapter.js.map +1 -0
- package/dist/storage/message-factory.d.ts +36 -0
- package/dist/storage/message-factory.d.ts.map +1 -0
- package/dist/storage/message-factory.js +99 -0
- package/dist/storage/message-factory.js.map +1 -0
- package/dist/storage/mycmail-adapter.d.ts +38 -0
- package/dist/storage/mycmail-adapter.d.ts.map +1 -0
- package/dist/storage/mycmail-adapter.js +300 -0
- package/dist/storage/mycmail-adapter.js.map +1 -0
- package/dist/storage/supabase-registry-sync-client-impl.d.ts +46 -0
- package/dist/storage/supabase-registry-sync-client-impl.d.ts.map +1 -0
- package/dist/storage/supabase-registry-sync-client-impl.js +322 -0
- package/dist/storage/supabase-registry-sync-client-impl.js.map +1 -0
- package/dist/storage/supabase-registry-sync-client.d.ts +9 -0
- package/dist/storage/supabase-registry-sync-client.d.ts.map +1 -0
- package/dist/storage/supabase-registry-sync-client.js +2 -0
- package/dist/storage/supabase-registry-sync-client.js.map +1 -0
- package/dist/storage/supabase.d.ts +8 -46
- package/dist/storage/supabase.d.ts.map +1 -1
- package/dist/storage/supabase.js +30 -342
- package/dist/storage/supabase.js.map +1 -1
- package/dist/tui/screen.d.ts.map +1 -1
- package/dist/tui/screen.js +5 -3
- package/dist/tui/screen.js.map +1 -1
- package/package.json +92 -90
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-events-subscriber
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to spidersan_git_events via Supabase Realtime for low-latency
|
|
5
|
+
* git-change notifications, with a catch-up poll on startup/reconnect so no
|
|
6
|
+
* events are missed while a machine was offline.
|
|
7
|
+
*
|
|
8
|
+
* Delivery model:
|
|
9
|
+
* Realtime INSERT — instant fan-out (< 1s when connected)
|
|
10
|
+
* Catch-up query — WHERE seq > last_seen_seq on startup/reconnect
|
|
11
|
+
*
|
|
12
|
+
* Actions per event type (P1 — notify, not auto-execute):
|
|
13
|
+
* push → warn agent, log to activity + pending files
|
|
14
|
+
* pull_request → log only
|
|
15
|
+
* create → log only
|
|
16
|
+
* delete → mark registry branch abandoned, archive entry
|
|
17
|
+
*
|
|
18
|
+
* AI layer (P2): pending events will be routed through spidersan-smol/gemma4
|
|
19
|
+
* for ecosystem-aware severity decisions. The pending log is the handoff point.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync, execFile } from 'child_process';
|
|
22
|
+
import { promisify } from 'util';
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, statSync, renameSync } from 'fs';
|
|
24
|
+
import { join, resolve } from 'path';
|
|
25
|
+
import { homedir } from 'os';
|
|
26
|
+
import { getStorage } from '../storage/index.js';
|
|
27
|
+
import { handleEvent } from './ai/event-handler.js';
|
|
28
|
+
import { logActivity } from './activity.js';
|
|
29
|
+
const execFileAsync = promisify(execFile);
|
|
30
|
+
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
31
|
+
const SPIDERSAN_DIR = join(homedir(), '.spidersan');
|
|
32
|
+
const CURSOR_FILE = join(SPIDERSAN_DIR, 'git-watch-cursor.json');
|
|
33
|
+
const ACTIVITY_LOG = join(SPIDERSAN_DIR, 'activity.jsonl');
|
|
34
|
+
const PENDING_LOG = join(SPIDERSAN_DIR, 'git-events-pending.jsonl');
|
|
35
|
+
const PENDING_CONSUMED_LOG = join(SPIDERSAN_DIR, 'git-events-consumed.jsonl');
|
|
36
|
+
const PENDING_TEMP = join(SPIDERSAN_DIR, 'git-events-pending.jsonl.tmp');
|
|
37
|
+
const ARCHIVE_LOG = join(SPIDERSAN_DIR, 'archive.jsonl');
|
|
38
|
+
// ─── Cursor persistence ───────────────────────────────────────────────────────
|
|
39
|
+
function readCursor() {
|
|
40
|
+
try {
|
|
41
|
+
if (!existsSync(CURSOR_FILE))
|
|
42
|
+
return 0;
|
|
43
|
+
const data = JSON.parse(readFileSync(CURSOR_FILE, 'utf8'));
|
|
44
|
+
return typeof data.seq === 'number' ? data.seq : 0;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function writeCursor(seq) {
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(CURSOR_FILE, JSON.stringify({ seq, updated_at: new Date().toISOString() }));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// non-fatal
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ─── Activity / pending logging ───────────────────────────────────────────────
|
|
59
|
+
function appendLog(file, entry) {
|
|
60
|
+
try {
|
|
61
|
+
appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// non-fatal
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ─── Remote URL → local path cache ───────────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* Build a map from "owner/repo" to local clone path(s).
|
|
70
|
+
* Scans provided repoPaths first; if empty, scans all registered repos
|
|
71
|
+
* by reading their .spidersan/registry.json locations from ~/.spidersan.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeRemoteUrl(url) {
|
|
74
|
+
// Normalize ssh and https to "owner/repo"
|
|
75
|
+
// git@github.com:treebird7/Envoak.git → treebird7/Envoak
|
|
76
|
+
// https://github.com/treebird7/Envoak.git → treebird7/Envoak
|
|
77
|
+
return url
|
|
78
|
+
.replace(/^git@github\.com:/, '')
|
|
79
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
80
|
+
.replace(/\.git$/, '');
|
|
81
|
+
}
|
|
82
|
+
function getRemoteForPath(repoPath) {
|
|
83
|
+
try {
|
|
84
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
85
|
+
cwd: repoPath,
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
timeout: 5000,
|
|
88
|
+
}).trim();
|
|
89
|
+
return normalizeRemoteUrl(url);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function buildRepoMap(explicitPaths) {
|
|
96
|
+
const map = new Map(); // owner/repo → [localPath, ...]
|
|
97
|
+
const pathsToScan = explicitPaths.length > 0
|
|
98
|
+
? explicitPaths
|
|
99
|
+
: discoverRegisteredRepoPaths();
|
|
100
|
+
for (const p of pathsToScan) {
|
|
101
|
+
const absPath = resolve(p);
|
|
102
|
+
const remote = getRemoteForPath(absPath);
|
|
103
|
+
if (!remote)
|
|
104
|
+
continue;
|
|
105
|
+
const existing = map.get(remote) ?? [];
|
|
106
|
+
existing.push(absPath);
|
|
107
|
+
map.set(remote, existing);
|
|
108
|
+
}
|
|
109
|
+
return map;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Discover repo paths from all .spidersan registries visible from ~/.spidersan.
|
|
113
|
+
* Falls back to scanning ~/Dev/* for .spidersan directories.
|
|
114
|
+
*/
|
|
115
|
+
function discoverRegisteredRepoPaths() {
|
|
116
|
+
const devDir = join(homedir(), 'Dev');
|
|
117
|
+
const paths = [];
|
|
118
|
+
try {
|
|
119
|
+
const entries = readdirSync(devDir);
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const full = join(devDir, entry);
|
|
122
|
+
try {
|
|
123
|
+
if (statSync(full).isDirectory() && existsSync(join(full, '.spidersan', 'registry.json'))) {
|
|
124
|
+
paths.push(full);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// skip
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// devDir doesn't exist or can't be read
|
|
134
|
+
}
|
|
135
|
+
return paths;
|
|
136
|
+
}
|
|
137
|
+
// ─── Per-repo worker queue ────────────────────────────────────────────────────
|
|
138
|
+
const queues = new Map(); // repoPath → last task
|
|
139
|
+
function enqueue(repoPath, task) {
|
|
140
|
+
const last = queues.get(repoPath) ?? Promise.resolve();
|
|
141
|
+
const next = last.then(task).catch(() => undefined);
|
|
142
|
+
queues.set(repoPath, next);
|
|
143
|
+
}
|
|
144
|
+
// ─── Tree-pair detection ──────────────────────────────────────────────────────
|
|
145
|
+
const TREE_PAIR_RE = /^(sql-tree|ts-tree)\/pairs\/[^/]+\.json$/;
|
|
146
|
+
const TREE_ACTION_HINTS = {
|
|
147
|
+
'sql-tree': 'mycsan /sql-review validate',
|
|
148
|
+
'ts-tree': 'ts-review validate',
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* When a push lands on treebird7/treebird, compare before..after via GitHub API
|
|
152
|
+
* and detect newly added gold-pair JSON files. Emits a targeted pending entry
|
|
153
|
+
* per tree so the validation cue is specific and actionable.
|
|
154
|
+
*/
|
|
155
|
+
async function detectTreePairs(event, log) {
|
|
156
|
+
if (!event.before_sha || !event.after_sha)
|
|
157
|
+
return;
|
|
158
|
+
const url = `https://api.github.com/repos/${event.repo}/compare/${event.before_sha}...${event.after_sha}`;
|
|
159
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
160
|
+
const headers = {
|
|
161
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
162
|
+
'User-Agent': 'spidersan-git-watch',
|
|
163
|
+
};
|
|
164
|
+
if (githubToken)
|
|
165
|
+
headers['Authorization'] = `Bearer ${githubToken}`;
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch(url, { headers });
|
|
168
|
+
if (!res.ok)
|
|
169
|
+
return;
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
const newPairs = (data.files ?? []).filter(f => f.status === 'added' && TREE_PAIR_RE.test(f.filename));
|
|
172
|
+
if (!newPairs.length)
|
|
173
|
+
return;
|
|
174
|
+
// Group by tree (sql-tree / ts-tree)
|
|
175
|
+
const grouped = {};
|
|
176
|
+
for (const f of newPairs) {
|
|
177
|
+
const tree = f.filename.split('/')[0];
|
|
178
|
+
(grouped[tree] ??= []).push(f.filename);
|
|
179
|
+
}
|
|
180
|
+
for (const [tree, files] of Object.entries(grouped)) {
|
|
181
|
+
const hint = TREE_ACTION_HINTS[tree] ?? `review ${tree}/pairs/`;
|
|
182
|
+
log(`🌱 ${files.length} new pair(s) in ${tree} — validate: \`${hint}\``);
|
|
183
|
+
appendLog(PENDING_LOG, {
|
|
184
|
+
type: 'tree_pairs_ready',
|
|
185
|
+
tree,
|
|
186
|
+
files,
|
|
187
|
+
count: files.length,
|
|
188
|
+
repo: event.repo,
|
|
189
|
+
branch: event.branch,
|
|
190
|
+
after_sha: event.after_sha?.slice(0, 7),
|
|
191
|
+
received_at: event.received_at,
|
|
192
|
+
ts: new Date().toISOString(),
|
|
193
|
+
action_hint: hint,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// non-fatal — GitHub API might be rate-limited or unreachable
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// ─── Pending tree-pairs consumer ──────────────────────────────────────────────
|
|
202
|
+
/** Deterministic ID for a tree_pairs_ready entry — used for idempotent consumption. */
|
|
203
|
+
function makeEntryId(entry) {
|
|
204
|
+
const tree = typeof entry.tree === 'string' ? entry.tree : 'unknown';
|
|
205
|
+
const sha = typeof entry.after_sha === 'string' ? entry.after_sha : 'unknown';
|
|
206
|
+
const repo = typeof entry.repo === 'string' ? entry.repo : 'unknown';
|
|
207
|
+
const files = Array.isArray(entry.files) ? entry.files.join('|') : '';
|
|
208
|
+
return `${repo}:${sha}:${tree}:${files}`;
|
|
209
|
+
}
|
|
210
|
+
/** Read already-consumed entry IDs from the consumed log. */
|
|
211
|
+
function readConsumedIds() {
|
|
212
|
+
try {
|
|
213
|
+
if (!existsSync(PENDING_CONSUMED_LOG))
|
|
214
|
+
return new Set();
|
|
215
|
+
const lines = readFileSync(PENDING_CONSUMED_LOG, 'utf8').split('\n').filter(Boolean);
|
|
216
|
+
const ids = new Set();
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
try {
|
|
219
|
+
const entry = JSON.parse(line);
|
|
220
|
+
if (typeof entry._id === 'string')
|
|
221
|
+
ids.add(entry._id);
|
|
222
|
+
}
|
|
223
|
+
catch { /* skip malformed */ }
|
|
224
|
+
}
|
|
225
|
+
return ids;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return new Set();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
let isConsuming = false; // single-flight guard
|
|
232
|
+
/**
|
|
233
|
+
* Read pending.jsonl, emit a hive handoff signal for each unconsumed
|
|
234
|
+
* tree_pairs_ready entry, then atomically rewrite the file without them.
|
|
235
|
+
*
|
|
236
|
+
* Only successfully dispatched entries are removed — on failure the entry
|
|
237
|
+
* stays in pending for the next poll cycle. Uses a deterministic ID so
|
|
238
|
+
* restarts are idempotent.
|
|
239
|
+
*/
|
|
240
|
+
async function consumePendingTreePairs(log) {
|
|
241
|
+
if (isConsuming)
|
|
242
|
+
return;
|
|
243
|
+
isConsuming = true;
|
|
244
|
+
try {
|
|
245
|
+
if (!existsSync(PENDING_LOG))
|
|
246
|
+
return;
|
|
247
|
+
let rawLines;
|
|
248
|
+
try {
|
|
249
|
+
rawLines = readFileSync(PENDING_LOG, 'utf8').split('\n').filter(Boolean);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const consumedIds = readConsumedIds();
|
|
255
|
+
const remaining = []; // non-tree_pairs_ready lines → kept
|
|
256
|
+
const toConsume = [];
|
|
257
|
+
for (const line of rawLines) {
|
|
258
|
+
try {
|
|
259
|
+
const entry = JSON.parse(line);
|
|
260
|
+
if (entry.type === 'tree_pairs_ready') {
|
|
261
|
+
const id = makeEntryId(entry);
|
|
262
|
+
if (!consumedIds.has(id)) {
|
|
263
|
+
toConsume.push({ entry, id, raw: line });
|
|
264
|
+
}
|
|
265
|
+
// Already-consumed entries are dropped from pending silently
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
remaining.push(line);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
remaining.push(line); // preserve malformed lines verbatim
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (toConsume.length === 0)
|
|
276
|
+
return;
|
|
277
|
+
log(`🔁 consuming ${toConsume.length} pending tree_pairs_ready event(s)`);
|
|
278
|
+
const succeeded = [];
|
|
279
|
+
const failed = []; // raw lines to keep in pending
|
|
280
|
+
for (const { entry, id, raw } of toConsume) {
|
|
281
|
+
const task = typeof entry.action_hint === 'string' ? entry.action_hint : `review ${entry.tree}/pairs/`;
|
|
282
|
+
const files = Array.isArray(entry.files) ? entry.files : [];
|
|
283
|
+
const summary = `spidersan auto-dispatch: ${entry.tree} pair(s) ready — ${entry.repo}@${entry.after_sha} — ${task}`;
|
|
284
|
+
const args = [
|
|
285
|
+
'hive', 'signal',
|
|
286
|
+
'--status', 'awaiting-review',
|
|
287
|
+
'--task', task,
|
|
288
|
+
'--handoff', 'review',
|
|
289
|
+
'--summary', summary,
|
|
290
|
+
];
|
|
291
|
+
if (files.length > 0)
|
|
292
|
+
args.push('--files', files.join(','));
|
|
293
|
+
log(`📡 hive handoff → ${task} (${files.length} file(s))`);
|
|
294
|
+
try {
|
|
295
|
+
await execFileAsync('envoak', args, { timeout: 15000 });
|
|
296
|
+
succeeded.push({ entry, id });
|
|
297
|
+
log(`✅ dispatched ${id}`);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
301
|
+
log(`⚠ hive signal failed for ${id}: ${msg} — retaining in pending`);
|
|
302
|
+
failed.push(raw);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (succeeded.length === 0)
|
|
306
|
+
return;
|
|
307
|
+
// Record successful dispatches in consumed log
|
|
308
|
+
for (const { entry, id } of succeeded) {
|
|
309
|
+
appendLog(PENDING_CONSUMED_LOG, { ...entry, _id: id, consumed_at: new Date().toISOString() });
|
|
310
|
+
}
|
|
311
|
+
// Atomic rewrite: remaining non-consumed lines + any failed tree_pairs lines
|
|
312
|
+
const finalLines = [...remaining, ...failed];
|
|
313
|
+
try {
|
|
314
|
+
writeFileSync(PENDING_TEMP, finalLines.length > 0 ? finalLines.join('\n') + '\n' : '');
|
|
315
|
+
renameSync(PENDING_TEMP, PENDING_LOG);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
319
|
+
log(`⚠ failed to rewrite pending log: ${msg}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
isConsuming = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const NULL_SHA = '0000000000000000000000000000000000000000';
|
|
327
|
+
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
328
|
+
async function enrichFilesFromGit(repoPath, beforeSha, afterSha) {
|
|
329
|
+
if (!beforeSha || !afterSha || beforeSha === NULL_SHA)
|
|
330
|
+
return [];
|
|
331
|
+
if (!SHA_RE.test(beforeSha) || !SHA_RE.test(afterSha))
|
|
332
|
+
return [];
|
|
333
|
+
try {
|
|
334
|
+
const { stdout } = await execFileAsync('git', ['diff', '--name-only', `${beforeSha}...${afterSha}`], {
|
|
335
|
+
cwd: repoPath,
|
|
336
|
+
timeout: 10000,
|
|
337
|
+
});
|
|
338
|
+
return stdout.trim().split('\n').filter(Boolean);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// ─── Event handlers ───────────────────────────────────────────────────────────
|
|
345
|
+
async function handlePush(event, localPaths, log) {
|
|
346
|
+
const shortSha = event.after_sha?.slice(0, 7) ?? 'unknown';
|
|
347
|
+
const who = event.sender_login ?? 'unknown';
|
|
348
|
+
const branch = event.branch ?? event.ref ?? 'unknown';
|
|
349
|
+
log(`⚠ git push: ${event.repo} ${branch} by ${who} (${shortSha}) — run \`spidersan pulse\` to check conflicts`);
|
|
350
|
+
// Notify fleet when main is updated — all machines see this in hive status / pulse
|
|
351
|
+
const isDefaultBranch = branch === 'main' || branch === 'master';
|
|
352
|
+
if (isDefaultBranch) {
|
|
353
|
+
const commitCount = event.commits?.length ?? 1;
|
|
354
|
+
const summary = `${commitCount} commit${commitCount !== 1 ? 's' : ''} pushed to ${event.repo}/${branch} by ${who} (${shortSha})`;
|
|
355
|
+
execFile('envoak', [
|
|
356
|
+
'hive', 'signal',
|
|
357
|
+
'--status', 'idle',
|
|
358
|
+
'--task', `main updated: ${event.repo}`,
|
|
359
|
+
'--summary', summary,
|
|
360
|
+
], { timeout: 10000 }, () => { });
|
|
361
|
+
log(`📡 hive signal emitted: ${summary}`);
|
|
362
|
+
}
|
|
363
|
+
const entry = {
|
|
364
|
+
type: 'git_push',
|
|
365
|
+
repo: event.repo,
|
|
366
|
+
branch,
|
|
367
|
+
sender: who,
|
|
368
|
+
after_sha: shortSha,
|
|
369
|
+
received_at: event.received_at,
|
|
370
|
+
ts: new Date().toISOString(),
|
|
371
|
+
action_hint: 'spidersan pulse',
|
|
372
|
+
};
|
|
373
|
+
appendLog(ACTIVITY_LOG, { ...entry, source: 'git-watch' });
|
|
374
|
+
appendLog(PENDING_LOG, entry);
|
|
375
|
+
// Detect newly committed gold pairs in sql-tree / ts-tree
|
|
376
|
+
if (event.repo === 'treebird7/treebird') {
|
|
377
|
+
await detectTreePairs(event, log);
|
|
378
|
+
}
|
|
379
|
+
// Phase C: AI advice — computed once per event (not per local path)
|
|
380
|
+
const aiRepoPath = localPaths[0];
|
|
381
|
+
const files = aiRepoPath
|
|
382
|
+
? await enrichFilesFromGit(aiRepoPath, event.before_sha, event.after_sha)
|
|
383
|
+
: [];
|
|
384
|
+
const payload = {
|
|
385
|
+
type: 'push',
|
|
386
|
+
repo: event.repo,
|
|
387
|
+
branch,
|
|
388
|
+
files,
|
|
389
|
+
metadata: { after_sha: event.after_sha, sender: who },
|
|
390
|
+
};
|
|
391
|
+
try {
|
|
392
|
+
const advice = await handleEvent(payload, { repoRoot: aiRepoPath, localOnly: true });
|
|
393
|
+
if (advice.tier >= 2) {
|
|
394
|
+
logActivity({
|
|
395
|
+
repo: event.repo,
|
|
396
|
+
branch,
|
|
397
|
+
agent: who !== 'unknown' ? who : undefined,
|
|
398
|
+
event: 'conflict_detected',
|
|
399
|
+
details: {
|
|
400
|
+
source: 'git-watch-ai',
|
|
401
|
+
tier: advice.tier,
|
|
402
|
+
action: advice.action,
|
|
403
|
+
message: advice.message,
|
|
404
|
+
commands: advice.commands,
|
|
405
|
+
after_sha: event.after_sha,
|
|
406
|
+
files_checked: files.length,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
log(`🕷 AI [T${advice.tier}/${advice.action}] ${event.repo}/${branch}: ${advice.message.slice(0, 120)}`);
|
|
410
|
+
// Best-effort hive signal — async, non-blocking
|
|
411
|
+
execFile('envoak', [
|
|
412
|
+
'hive', 'signal',
|
|
413
|
+
'--status', 'needs-context',
|
|
414
|
+
'--task', `TIER ${advice.tier} conflict: ${event.repo}/${branch} — ${advice.message.slice(0, 120)}`,
|
|
415
|
+
], { timeout: 10000 }, () => { });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
// AI layer failure is non-fatal — daemon continues
|
|
420
|
+
}
|
|
421
|
+
// Per-path queue: reserved for Phase C2 per-path operations
|
|
422
|
+
for (const p of localPaths) {
|
|
423
|
+
enqueue(p, async () => { void p; });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async function handlePR(event, _localPaths, log) {
|
|
427
|
+
const action = event.action ?? 'event';
|
|
428
|
+
const branch = event.branch ?? 'unknown';
|
|
429
|
+
const who = event.sender_login ?? 'unknown';
|
|
430
|
+
log(`ℹ PR ${action}: ${event.repo} ${branch} by ${who}`);
|
|
431
|
+
appendLog(ACTIVITY_LOG, { type: 'pull_request', action, repo: event.repo, branch, sender: who, received_at: event.received_at, ts: new Date().toISOString(), source: 'git-watch' });
|
|
432
|
+
// Phase C2: AI merge-readiness advice
|
|
433
|
+
const payload = {
|
|
434
|
+
type: 'pull_request',
|
|
435
|
+
repo: event.repo,
|
|
436
|
+
branch,
|
|
437
|
+
metadata: { action, sender: who },
|
|
438
|
+
};
|
|
439
|
+
try {
|
|
440
|
+
const advice = await handleEvent(payload, { localOnly: true });
|
|
441
|
+
if (advice.tier >= 2) {
|
|
442
|
+
logActivity({
|
|
443
|
+
repo: event.repo,
|
|
444
|
+
branch,
|
|
445
|
+
agent: who !== 'unknown' ? who : undefined,
|
|
446
|
+
event: 'conflict_detected',
|
|
447
|
+
details: {
|
|
448
|
+
source: 'git-watch-ai',
|
|
449
|
+
trigger: 'pull_request',
|
|
450
|
+
pr_action: action,
|
|
451
|
+
tier: advice.tier,
|
|
452
|
+
action: advice.action,
|
|
453
|
+
message: advice.message,
|
|
454
|
+
commands: advice.commands,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
log(`🕷 AI [T${advice.tier}/${advice.action}] PR ${event.repo}/${branch}: ${advice.message.slice(0, 120)}`);
|
|
458
|
+
execFile('envoak', [
|
|
459
|
+
'hive', 'signal',
|
|
460
|
+
'--status', 'needs-context',
|
|
461
|
+
'--task', `TIER ${advice.tier} PR conflict: ${event.repo}/${branch} — ${advice.message.slice(0, 120)}`,
|
|
462
|
+
], { timeout: 10000 }, () => { });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// AI layer failure is non-fatal
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async function handleCreate(event, _localPaths, log) {
|
|
470
|
+
if (event.ref_type !== 'branch')
|
|
471
|
+
return;
|
|
472
|
+
const branch = event.branch ?? 'unknown';
|
|
473
|
+
const who = event.sender_login ?? 'unknown';
|
|
474
|
+
log(`ℹ branch created: ${event.repo}/${branch} by ${who}`);
|
|
475
|
+
appendLog(ACTIVITY_LOG, { type: 'branch_created', repo: event.repo, branch, sender: who, received_at: event.received_at, ts: new Date().toISOString(), source: 'git-watch' });
|
|
476
|
+
// Phase C2: register branch stub so AI conflict detection knows it exists
|
|
477
|
+
// before any files are pushed. Files start empty; enriched on first push.
|
|
478
|
+
try {
|
|
479
|
+
const storage = await getStorage();
|
|
480
|
+
const existing = await storage.get(branch);
|
|
481
|
+
if (!existing) {
|
|
482
|
+
await storage.register({
|
|
483
|
+
name: branch,
|
|
484
|
+
files: [],
|
|
485
|
+
status: 'active',
|
|
486
|
+
agent: who !== 'unknown' ? who : undefined,
|
|
487
|
+
description: `auto-registered by git-watch on remote create (${event.repo})`,
|
|
488
|
+
});
|
|
489
|
+
log(` registered branch stub: ${branch}`);
|
|
490
|
+
logActivity({
|
|
491
|
+
repo: event.repo,
|
|
492
|
+
branch,
|
|
493
|
+
agent: who !== 'unknown' ? who : undefined,
|
|
494
|
+
event: 'register',
|
|
495
|
+
details: { source: 'git-watch-auto', trigger: 'remote_branch_create' },
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// Storage write is best-effort — log failure is non-fatal
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function handleDelete(event, localPaths, log) {
|
|
504
|
+
if (event.ref_type !== 'branch')
|
|
505
|
+
return;
|
|
506
|
+
const branch = event.branch ?? 'unknown';
|
|
507
|
+
const who = event.sender_login ?? 'unknown';
|
|
508
|
+
log(`🗑 branch deleted remotely: ${event.repo}/${branch} by ${who} — archiving in local registries`);
|
|
509
|
+
// Only fire the conflict notification once even if multiple local paths exist
|
|
510
|
+
let notified = false;
|
|
511
|
+
for (const p of localPaths) {
|
|
512
|
+
enqueue(p, async () => {
|
|
513
|
+
try {
|
|
514
|
+
const storage = await getStorage();
|
|
515
|
+
const allBranches = await storage.list();
|
|
516
|
+
const entry = allBranches.find(b => b.name === branch);
|
|
517
|
+
if (!entry)
|
|
518
|
+
return;
|
|
519
|
+
// Notify if the deleted branch had active file registrations — means
|
|
520
|
+
// conflict detection was tracking it and other branches may share files.
|
|
521
|
+
if (!notified && entry.status === 'active' && entry.files && entry.files.length > 0) {
|
|
522
|
+
notified = true;
|
|
523
|
+
log(`⚠ deleted branch ${branch} had ${entry.files.length} registered file(s) — potential conflict impact`);
|
|
524
|
+
logActivity({
|
|
525
|
+
repo: event.repo,
|
|
526
|
+
branch,
|
|
527
|
+
agent: who !== 'unknown' ? who : undefined,
|
|
528
|
+
event: 'conflict_detected',
|
|
529
|
+
details: {
|
|
530
|
+
trigger: 'branch_deleted_with_active_registrations',
|
|
531
|
+
files: entry.files,
|
|
532
|
+
deleted_by: who,
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
execFile('envoak', [
|
|
536
|
+
'hive', 'signal',
|
|
537
|
+
'--status', 'awaiting-review',
|
|
538
|
+
'--task', `deleted branch ${branch} had active conflict registrations (${entry.files.length} files) in ${event.repo}`,
|
|
539
|
+
'--files', entry.files.slice(0, 5).join(','),
|
|
540
|
+
], { timeout: 10000 }, () => { });
|
|
541
|
+
}
|
|
542
|
+
// Archive first, then remove from active registry
|
|
543
|
+
appendLog(ARCHIVE_LOG, {
|
|
544
|
+
...entry,
|
|
545
|
+
archived_at: new Date().toISOString(),
|
|
546
|
+
archived_reason: 'remote-delete',
|
|
547
|
+
archived_by: who,
|
|
548
|
+
repo_path: p,
|
|
549
|
+
});
|
|
550
|
+
await storage.update(branch, { status: 'abandoned' });
|
|
551
|
+
log(` archived ${branch} from ${p}`);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
// non-fatal
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
appendLog(ACTIVITY_LOG, { type: 'branch_deleted', repo: event.repo, branch, sender: who, received_at: event.received_at, ts: new Date().toISOString(), source: 'git-watch' });
|
|
559
|
+
}
|
|
560
|
+
async function dispatchEvent(event, repoMap, opts) {
|
|
561
|
+
const log = opts.logger ?? (opts.quiet ? () => { } : (m) => console.log(`[git-watch] ${m}`));
|
|
562
|
+
const localPaths = repoMap.get(event.repo) ?? [];
|
|
563
|
+
opts.onEvent?.(event);
|
|
564
|
+
switch (event.event_type) {
|
|
565
|
+
case 'push': return handlePush(event, localPaths, log);
|
|
566
|
+
case 'pull_request': return handlePR(event, localPaths, log);
|
|
567
|
+
case 'create': return handleCreate(event, localPaths, log);
|
|
568
|
+
case 'delete': return handleDelete(event, localPaths, log);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function getConfig() {
|
|
572
|
+
const url = process.env.SUPABASE_URL;
|
|
573
|
+
if (!url)
|
|
574
|
+
return null;
|
|
575
|
+
const jwt = process.env.COLONY_SESSION_JWT;
|
|
576
|
+
const key = process.env.SUPABASE_KEY;
|
|
577
|
+
if (!jwt && !key)
|
|
578
|
+
return null;
|
|
579
|
+
return { url, key: key ?? '', jwt };
|
|
580
|
+
}
|
|
581
|
+
async function supabaseFetch(cfg, path, params = {}) {
|
|
582
|
+
const qs = new URLSearchParams(params).toString();
|
|
583
|
+
const endpoint = `${cfg.url}/rest/v1/${path}${qs ? '?' + qs : ''}`;
|
|
584
|
+
const headers = {
|
|
585
|
+
'apikey': cfg.key,
|
|
586
|
+
'Content-Type': 'application/json',
|
|
587
|
+
};
|
|
588
|
+
headers['Authorization'] = cfg.jwt ? `Bearer ${cfg.jwt}` : `Bearer ${cfg.key}`;
|
|
589
|
+
try {
|
|
590
|
+
const res = await fetch(endpoint, { headers });
|
|
591
|
+
if (!res.ok)
|
|
592
|
+
return null;
|
|
593
|
+
return res.json();
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// ─── Catch-up query ───────────────────────────────────────────────────────────
|
|
600
|
+
async function catchUp(cfg, repoMap, opts) {
|
|
601
|
+
const cursor = readCursor();
|
|
602
|
+
const data = await supabaseFetch(cfg, 'spidersan_git_events', {
|
|
603
|
+
'seq': `gt.${cursor}`,
|
|
604
|
+
'order': 'seq.asc',
|
|
605
|
+
'limit': '200',
|
|
606
|
+
'select': '*',
|
|
607
|
+
});
|
|
608
|
+
if (!data?.length)
|
|
609
|
+
return;
|
|
610
|
+
for (const row of data) {
|
|
611
|
+
await dispatchEvent(row, repoMap, opts);
|
|
612
|
+
}
|
|
613
|
+
writeCursor(data[data.length - 1].seq);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Opens a Supabase Realtime WebSocket subscription to spidersan_git_events.
|
|
617
|
+
* Uses Node 24's native globalThis.WebSocket — no extra dependencies.
|
|
618
|
+
* Returns null if WebSocket is unavailable (graceful downgrade to polling).
|
|
619
|
+
*/
|
|
620
|
+
function startRealtimeSubscription(cfg, repoMap, opts, highWaterMark, log) {
|
|
621
|
+
// Feature-detect native WebSocket (Node 21.3+)
|
|
622
|
+
const WS = globalThis.WebSocket;
|
|
623
|
+
if (!WS)
|
|
624
|
+
return null;
|
|
625
|
+
// Rebind so TypeScript keeps it non-nullable inside nested closures
|
|
626
|
+
const WSClass = WS;
|
|
627
|
+
const project = cfg.url.replace('https://', '').split('.')[0];
|
|
628
|
+
const wsUrl = `wss://${project}.supabase.co/realtime/v1/websocket?apikey=${cfg.key}&vsn=1.0.0`;
|
|
629
|
+
let ws = null;
|
|
630
|
+
let stopped = false;
|
|
631
|
+
let heartbeatTimer = null;
|
|
632
|
+
let reconnectTimer = null;
|
|
633
|
+
let backoffMs = 1000;
|
|
634
|
+
let refCounter = 1;
|
|
635
|
+
function connect() {
|
|
636
|
+
if (stopped)
|
|
637
|
+
return;
|
|
638
|
+
ws = new WSClass(wsUrl);
|
|
639
|
+
ws.onopen = () => {
|
|
640
|
+
backoffMs = 1000;
|
|
641
|
+
log('realtime: WebSocket active');
|
|
642
|
+
// Join the postgres_changes channel
|
|
643
|
+
ws.send(JSON.stringify({
|
|
644
|
+
topic: 'realtime:public:spidersan_git_events',
|
|
645
|
+
event: 'phx_join',
|
|
646
|
+
payload: {
|
|
647
|
+
config: {
|
|
648
|
+
broadcast: { ack: false },
|
|
649
|
+
presence: { key: '' },
|
|
650
|
+
postgres_changes: [
|
|
651
|
+
{ event: 'INSERT', schema: 'public', table: 'spidersan_git_events' },
|
|
652
|
+
],
|
|
653
|
+
},
|
|
654
|
+
access_token: cfg.jwt ?? cfg.key,
|
|
655
|
+
},
|
|
656
|
+
ref: String(refCounter++),
|
|
657
|
+
}));
|
|
658
|
+
// Heartbeat every 25s
|
|
659
|
+
heartbeatTimer = setInterval(() => {
|
|
660
|
+
if (ws?.readyState === 1 /* OPEN */) {
|
|
661
|
+
ws.send(JSON.stringify({
|
|
662
|
+
topic: 'phoenix',
|
|
663
|
+
event: 'heartbeat',
|
|
664
|
+
payload: {},
|
|
665
|
+
ref: String(refCounter++),
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
}, 25_000);
|
|
669
|
+
};
|
|
670
|
+
ws.onmessage = (ev) => {
|
|
671
|
+
let msg;
|
|
672
|
+
try {
|
|
673
|
+
msg = JSON.parse(ev.data);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
// Supabase sends postgres_changes events
|
|
679
|
+
if (msg.event !== 'postgres_changes')
|
|
680
|
+
return;
|
|
681
|
+
const data = msg.payload?.data;
|
|
682
|
+
if (!data || data.type !== 'INSERT')
|
|
683
|
+
return;
|
|
684
|
+
const record = data.record;
|
|
685
|
+
if (!record || typeof record.seq !== 'number')
|
|
686
|
+
return;
|
|
687
|
+
// Dedup with shared high-water mark
|
|
688
|
+
if (record.seq <= highWaterMark.seq)
|
|
689
|
+
return;
|
|
690
|
+
highWaterMark.seq = record.seq;
|
|
691
|
+
writeCursor(record.seq);
|
|
692
|
+
dispatchEvent(record, repoMap, opts).catch(() => { });
|
|
693
|
+
};
|
|
694
|
+
ws.onerror = () => { };
|
|
695
|
+
ws.onclose = () => {
|
|
696
|
+
if (heartbeatTimer) {
|
|
697
|
+
clearInterval(heartbeatTimer);
|
|
698
|
+
heartbeatTimer = null;
|
|
699
|
+
}
|
|
700
|
+
if (stopped)
|
|
701
|
+
return;
|
|
702
|
+
log(`realtime: disconnected — reconnecting in ${backoffMs / 1000}s`);
|
|
703
|
+
reconnectTimer = setTimeout(() => {
|
|
704
|
+
// Catch up on events missed during disconnect, then reconnect
|
|
705
|
+
if (getConfig())
|
|
706
|
+
catchUp(cfg, repoMap, opts).catch(() => { }).finally(connect);
|
|
707
|
+
}, backoffMs);
|
|
708
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
connect();
|
|
712
|
+
return {
|
|
713
|
+
stop: () => {
|
|
714
|
+
stopped = true;
|
|
715
|
+
if (heartbeatTimer)
|
|
716
|
+
clearInterval(heartbeatTimer);
|
|
717
|
+
if (reconnectTimer)
|
|
718
|
+
clearTimeout(reconnectTimer);
|
|
719
|
+
ws?.close();
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
724
|
+
/**
|
|
725
|
+
* Start the git-events daemon in hybrid mode:
|
|
726
|
+
* - Supabase Realtime WebSocket for < 1s delivery (when available)
|
|
727
|
+
* - 5-min safety-sweep poll to catch events missed during disconnects
|
|
728
|
+
* Returns a handle with a stop() method.
|
|
729
|
+
*/
|
|
730
|
+
export async function startGitWatch(opts = {}) {
|
|
731
|
+
const log = opts.logger ?? (opts.quiet ? () => { } : (m) => console.log(`[git-watch] ${m}`));
|
|
732
|
+
// Safety-sweep interval: 5 min when realtime is active, otherwise use configured interval
|
|
733
|
+
const pollMs = opts.pollIntervalMs ?? 60_000;
|
|
734
|
+
const cfg = getConfig();
|
|
735
|
+
if (!cfg) {
|
|
736
|
+
throw new Error('git-watch: SUPABASE_URL and (SUPABASE_KEY or COLONY_SESSION_JWT) are required');
|
|
737
|
+
}
|
|
738
|
+
const repoMap = buildRepoMap(opts.repoPaths ?? []);
|
|
739
|
+
log(`Watching ${repoMap.size} repo(s): ${[...repoMap.keys()].join(', ')}`);
|
|
740
|
+
// Shared high-water mark (monotonic seq dedup between realtime + poll paths)
|
|
741
|
+
const highWaterMark = { seq: readCursor() };
|
|
742
|
+
// Catch-up on startup
|
|
743
|
+
await catchUp(cfg, repoMap, opts);
|
|
744
|
+
await consumePendingTreePairs(log);
|
|
745
|
+
// Start Realtime WebSocket (unless explicitly disabled)
|
|
746
|
+
let realtimeHandle = null;
|
|
747
|
+
if (!opts.disableRealtime) {
|
|
748
|
+
realtimeHandle = startRealtimeSubscription(cfg, repoMap, opts, highWaterMark, log);
|
|
749
|
+
if (!realtimeHandle) {
|
|
750
|
+
log('realtime: WebSocket unavailable — polling only');
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// Safety-sweep poll (5 min when realtime active, else use configured interval)
|
|
754
|
+
const sweepMs = realtimeHandle ? Math.max(pollMs, 5 * 60_000) : pollMs;
|
|
755
|
+
const pollTimer = setInterval(async () => {
|
|
756
|
+
await catchUp(cfg, repoMap, opts);
|
|
757
|
+
await consumePendingTreePairs(log);
|
|
758
|
+
}, sweepMs);
|
|
759
|
+
return {
|
|
760
|
+
stop: () => {
|
|
761
|
+
clearInterval(pollTimer);
|
|
762
|
+
realtimeHandle?.stop();
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* One-shot catch-up: fetch any missed events since last cursor, then exit.
|
|
768
|
+
* Useful for cron/CI environments that don't run persistent daemons.
|
|
769
|
+
*/
|
|
770
|
+
export async function catchUpOnce(opts = {}) {
|
|
771
|
+
const cfg = getConfig();
|
|
772
|
+
if (!cfg)
|
|
773
|
+
throw new Error('git-watch: SUPABASE_URL and (SUPABASE_KEY or COLONY_SESSION_JWT) are required');
|
|
774
|
+
const repoMap = buildRepoMap(opts.repoPaths ?? []);
|
|
775
|
+
const log = opts.logger ?? (opts.quiet ? () => { } : (m) => console.log(`[git-watch] ${m}`));
|
|
776
|
+
await catchUp(cfg, repoMap, opts);
|
|
777
|
+
await consumePendingTreePairs(log);
|
|
778
|
+
}
|
|
779
|
+
//# sourceMappingURL=git-events-subscriber.js.map
|