@logicseek/hello-cc 0.1.5 → 0.1.7

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 (88) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +21 -3
  3. package/README.zh-CN.md +21 -3
  4. package/assets/screenshots/web-console.png +0 -0
  5. package/bin/hcc.mjs +1943 -3519
  6. package/docs/README.md +2 -0
  7. package/docs/README.zh-CN.md +3 -1
  8. package/docs/architecture-split-review.md +217 -0
  9. package/docs/architecture.md +385 -0
  10. package/docs/architecture.zh-CN.md +364 -0
  11. package/docs/layout-split-stack-audit.md +298 -0
  12. package/lib/automation.mjs +8 -0
  13. package/lib/cli-args.mjs +1 -1
  14. package/lib/cli-runtime.mjs +45 -0
  15. package/lib/coordination-state.mjs +218 -0
  16. package/lib/core/coordination/automation.mjs +222 -0
  17. package/lib/core/coordination/locks.mjs +46 -0
  18. package/lib/core/coordination/messages.mjs +116 -0
  19. package/lib/core/coordination/tasks.mjs +240 -0
  20. package/lib/core/coordination/teams.mjs +73 -0
  21. package/lib/core/coordination/timeline.mjs +196 -0
  22. package/lib/core/peers/bindings.mjs +83 -0
  23. package/lib/core/peers/format.mjs +13 -0
  24. package/lib/core/peers/identity.mjs +61 -0
  25. package/lib/core/peers/liveness.mjs +90 -0
  26. package/lib/core/peers/reconcile.mjs +238 -0
  27. package/lib/core/peers/session.mjs +14 -0
  28. package/lib/core/sessions/launch.mjs +89 -0
  29. package/lib/db/schema.mjs +324 -0
  30. package/lib/db/stores/peers.mjs +231 -0
  31. package/lib/db-schema.mjs +7 -324
  32. package/lib/errors.mjs +1 -7
  33. package/lib/handoff.mjs +26 -0
  34. package/lib/help.mjs +3 -0
  35. package/lib/integrations/hooks.mjs +171 -0
  36. package/lib/integrations/peers/identity.mjs +208 -0
  37. package/lib/integrations/peers/processes.mjs +151 -0
  38. package/lib/integrations/providers.mjs +282 -0
  39. package/lib/integrations/shims/script.mjs +384 -0
  40. package/lib/integrations/shims.mjs +106 -0
  41. package/lib/json-file.mjs +1 -14
  42. package/lib/locks.mjs +10 -0
  43. package/lib/messages.mjs +3 -0
  44. package/lib/package-meta.mjs +6 -24
  45. package/lib/peer-bindings.mjs +14 -0
  46. package/lib/peer-format.mjs +4 -0
  47. package/lib/peer-identity.mjs +15 -0
  48. package/lib/project-context.mjs +6 -0
  49. package/lib/project-registry.mjs +7 -0
  50. package/lib/provider-commands.mjs +13 -0
  51. package/lib/release/package-meta.mjs +31 -0
  52. package/lib/release/release-notes.mjs +40 -0
  53. package/lib/release-notes.mjs +6 -40
  54. package/lib/runtime/client.mjs +42 -0
  55. package/lib/runtime/paths.mjs +45 -0
  56. package/lib/runtime/project-context.mjs +32 -0
  57. package/lib/runtime/projects.mjs +76 -0
  58. package/lib/runtime/state.mjs +128 -0
  59. package/lib/runtime-client.mjs +3 -0
  60. package/lib/runtime-paths.mjs +11 -45
  61. package/lib/runtime-state.mjs +11 -0
  62. package/lib/session-launch.mjs +17 -0
  63. package/lib/setup.mjs +17 -293
  64. package/lib/shared/errors.mjs +7 -0
  65. package/lib/shared/json-file.mjs +14 -0
  66. package/lib/shim-script.mjs +1 -384
  67. package/lib/state-render.mjs +5 -0
  68. package/lib/task-cli.mjs +45 -0
  69. package/lib/task-liveness.mjs +8 -0
  70. package/lib/task-store.mjs +3 -0
  71. package/lib/team-planning.mjs +7 -0
  72. package/lib/terminal/tmux.mjs +372 -0
  73. package/lib/timeline.mjs +12 -0
  74. package/lib/tmux.mjs +18 -0
  75. package/lib/ui/help.mjs +419 -0
  76. package/lib/ui/state-render.mjs +61 -0
  77. package/lib/web/http.mjs +55 -0
  78. package/lib/web/peer-actions.mjs +306 -0
  79. package/lib/web/runtime.mjs +154 -0
  80. package/lib/web/ui-template.mjs +2057 -0
  81. package/lib/web-http.mjs +7 -55
  82. package/lib/web-peer-actions.mjs +3 -0
  83. package/lib/web-runtime.mjs +17 -108
  84. package/lib/web-ui-template.mjs +3 -1909
  85. package/package.json +1 -1
  86. package/scripts/github-release.mjs +2 -2
  87. package/scripts/regression.mjs +2901 -79
  88. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,324 @@
1
+ import { CliError } from '../shared/errors.mjs';
2
+
3
+ export const DB_SCHEMA_VERSION = 5;
4
+
5
+ const sleepBuffer = new SharedArrayBuffer(4);
6
+ const sleepView = new Int32Array(sleepBuffer);
7
+
8
+ function now() {
9
+ return Math.floor(Date.now() / 1000);
10
+ }
11
+
12
+ function sleepSync(ms) {
13
+ Atomics.wait(sleepView, 0, 0, ms);
14
+ }
15
+
16
+ function isSqliteBusy(err) {
17
+ const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
18
+ return err?.errcode === 5 ||
19
+ err?.errcode === 6 ||
20
+ err?.errcode === 261 ||
21
+ /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database is busy/i.test(text);
22
+ }
23
+
24
+ export function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy = false } = {}) {
25
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
26
+ try {
27
+ db.exec(sql);
28
+ return true;
29
+ } catch (err) {
30
+ if (!isSqliteBusy(err)) throw err;
31
+ if (attempt === attempts - 1) {
32
+ if (ignoreBusy) return false;
33
+ throw err;
34
+ }
35
+ sleepSync(delayMs);
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ export function tx(db, fn) {
42
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE;');
43
+ try {
44
+ const result = fn();
45
+ db.exec('COMMIT;');
46
+ return result;
47
+ } catch (err) {
48
+ try {
49
+ db.exec('ROLLBACK;');
50
+ } catch {
51
+ // Ignore rollback failures; the original error is more useful.
52
+ }
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ export function initSchema(db, { beforePostMigrationIndexes = null } = {}) {
58
+ const existingVersion = readSchemaVersion(db);
59
+ if (existingVersion > DB_SCHEMA_VERSION) {
60
+ throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
61
+ db_schema_version: existingVersion,
62
+ supported_schema_version: DB_SCHEMA_VERSION
63
+ });
64
+ }
65
+ createBaseSchema(db);
66
+ runSchemaMigrations(db);
67
+ if (typeof beforePostMigrationIndexes === 'function') beforePostMigrationIndexes(db);
68
+ createPostMigrationIndexes(db);
69
+ }
70
+
71
+ function createBaseSchema(db) {
72
+ execWithBusyRetry(db, `
73
+ CREATE TABLE IF NOT EXISTS meta (
74
+ key TEXT PRIMARY KEY,
75
+ value TEXT NOT NULL
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS schema_migrations (
79
+ version INTEGER PRIMARY KEY,
80
+ name TEXT NOT NULL,
81
+ applied_at INTEGER NOT NULL
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS peers (
85
+ id TEXT PRIMARY KEY,
86
+ kind TEXT NOT NULL,
87
+ role TEXT,
88
+ worktree TEXT,
89
+ branch TEXT,
90
+ pid INTEGER,
91
+ status TEXT NOT NULL DEFAULT 'idle',
92
+ capabilities TEXT,
93
+ created_at INTEGER NOT NULL,
94
+ last_seen_at INTEGER NOT NULL
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS peer_bindings (
98
+ peer TEXT PRIMARY KEY,
99
+ provider TEXT NOT NULL,
100
+ provider_session_id TEXT,
101
+ provider_session_name TEXT,
102
+ resume_mode TEXT NOT NULL DEFAULT 'new',
103
+ resume_arg TEXT,
104
+ command TEXT,
105
+ transport TEXT NOT NULL,
106
+ runtime_session_id TEXT,
107
+ runtime_target TEXT,
108
+ created_at INTEGER NOT NULL,
109
+ updated_at INTEGER NOT NULL,
110
+ FOREIGN KEY (peer) REFERENCES peers(id) ON DELETE CASCADE
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS tasks (
114
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
115
+ title TEXT NOT NULL,
116
+ body TEXT,
117
+ status TEXT NOT NULL DEFAULT 'pending',
118
+ assignee TEXT,
119
+ owner TEXT,
120
+ parent_id INTEGER,
121
+ team_role TEXT,
122
+ priority INTEGER NOT NULL DEFAULT 100,
123
+ created_by TEXT,
124
+ claimed_at INTEGER,
125
+ completed_at INTEGER,
126
+ created_at INTEGER NOT NULL,
127
+ updated_at INTEGER NOT NULL
128
+ );
129
+
130
+ CREATE TABLE IF NOT EXISTS messages (
131
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
132
+ sender TEXT NOT NULL,
133
+ recipient TEXT,
134
+ task_id INTEGER,
135
+ kind TEXT NOT NULL DEFAULT 'note',
136
+ body TEXT NOT NULL,
137
+ reply_to INTEGER,
138
+ thread_id INTEGER,
139
+ created_at INTEGER NOT NULL
140
+ );
141
+
142
+ CREATE TABLE IF NOT EXISTS message_reads (
143
+ message_id INTEGER NOT NULL,
144
+ peer TEXT NOT NULL,
145
+ read_at INTEGER NOT NULL,
146
+ PRIMARY KEY (message_id, peer),
147
+ FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
148
+ );
149
+
150
+ CREATE TABLE IF NOT EXISTS locks (
151
+ resource TEXT PRIMARY KEY,
152
+ base_resource TEXT,
153
+ scope TEXT NOT NULL DEFAULT '*',
154
+ owner TEXT NOT NULL,
155
+ task_id INTEGER,
156
+ reason TEXT,
157
+ expires_at INTEGER NOT NULL,
158
+ created_at INTEGER NOT NULL
159
+ );
160
+
161
+ CREATE TABLE IF NOT EXISTS handoffs (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ task_id INTEGER,
164
+ from_peer TEXT NOT NULL,
165
+ to_peer TEXT,
166
+ summary TEXT NOT NULL,
167
+ changed_files TEXT,
168
+ tests TEXT,
169
+ risks TEXT,
170
+ created_at INTEGER NOT NULL
171
+ );
172
+
173
+ CREATE TABLE IF NOT EXISTS events (
174
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
175
+ type TEXT NOT NULL,
176
+ actor TEXT,
177
+ task_id INTEGER,
178
+ payload TEXT,
179
+ created_at INTEGER NOT NULL
180
+ );
181
+
182
+ CREATE INDEX IF NOT EXISTS idx_tasks_status_priority ON tasks(status, priority, id);
183
+ CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner);
184
+ CREATE INDEX IF NOT EXISTS idx_messages_recipient_id ON messages(recipient, id);
185
+ CREATE INDEX IF NOT EXISTS idx_events_id ON events(id);
186
+ CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
187
+ `);
188
+ }
189
+
190
+ function createPostMigrationIndexes(db) {
191
+ execWithBusyRetry(db, `
192
+ CREATE INDEX IF NOT EXISTS idx_peer_bindings_provider_session ON peer_bindings(provider, provider_session_id);
193
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_id_unique
194
+ ON peer_bindings(provider, provider_session_id)
195
+ WHERE provider_session_id IS NOT NULL;
196
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_provider_session_name_unique
197
+ ON peer_bindings(provider, provider_session_name)
198
+ WHERE provider_session_name IS NOT NULL;
199
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_peer_bindings_runtime_target_unique
200
+ ON peer_bindings(runtime_target)
201
+ WHERE runtime_target IS NOT NULL;
202
+ `);
203
+ }
204
+
205
+ function tableColumns(db, tableName) {
206
+ return new Set(db.prepare(`PRAGMA table_info(${tableName})`).all().map((col) => col.name));
207
+ }
208
+
209
+ function tableExists(db, tableName) {
210
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(tableName));
211
+ }
212
+
213
+ export function readSchemaVersion(db) {
214
+ const metaExists = tableExists(db, 'meta');
215
+ const metaVersion = metaExists ? Number(db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value || 0) : 0;
216
+ const pragmaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version || 0);
217
+ const migrationVersion = tableExists(db, 'schema_migrations')
218
+ ? Number(db.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()?.version || 0)
219
+ : 0;
220
+ return Math.max(metaVersion, pragmaVersion, migrationVersion);
221
+ }
222
+
223
+ function writeSchemaVersion(db, version) {
224
+ db.prepare(`
225
+ INSERT INTO meta(key, value) VALUES ('schema_version', ?)
226
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
227
+ `).run(String(version));
228
+ db.exec(`PRAGMA user_version = ${Number(version)}`);
229
+ }
230
+
231
+ function recordSchemaMigration(db, version, name) {
232
+ db.prepare(`
233
+ INSERT INTO schema_migrations(version, name, applied_at)
234
+ VALUES (?, ?, ?)
235
+ ON CONFLICT(version) DO NOTHING
236
+ `).run(version, name, now());
237
+ }
238
+
239
+ function addColumnIfMissing(db, tableName, columnName, ddl) {
240
+ const columns = tableColumns(db, tableName);
241
+ if (!columns.has(columnName)) db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${ddl}`);
242
+ }
243
+
244
+ function runSchemaMigrations(db) {
245
+ const migrations = [
246
+ {
247
+ version: 1,
248
+ name: 'baseline',
249
+ up() {
250
+ // createBaseSchema already created the baseline tables.
251
+ }
252
+ },
253
+ {
254
+ version: 2,
255
+ name: 'peer binding runtime targets',
256
+ up(database) {
257
+ addColumnIfMissing(database, 'peer_bindings', 'runtime_target', 'runtime_target TEXT');
258
+ }
259
+ },
260
+ {
261
+ version: 3,
262
+ name: 'threaded messages',
263
+ up(database) {
264
+ addColumnIfMissing(database, 'messages', 'reply_to', 'reply_to INTEGER');
265
+ addColumnIfMissing(database, 'messages', 'thread_id', 'thread_id INTEGER');
266
+ database.exec(`
267
+ UPDATE messages SET thread_id = id WHERE thread_id IS NULL;
268
+ CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(thread_id, id);
269
+ `);
270
+ }
271
+ },
272
+ {
273
+ version: 4,
274
+ name: 'team task hierarchy',
275
+ up(database) {
276
+ addColumnIfMissing(database, 'tasks', 'parent_id', 'parent_id INTEGER');
277
+ addColumnIfMissing(database, 'tasks', 'team_role', 'team_role TEXT');
278
+ database.exec('CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id, priority, id);');
279
+ }
280
+ },
281
+ {
282
+ version: 5,
283
+ name: 'scoped advisory locks',
284
+ up(database) {
285
+ addColumnIfMissing(database, 'locks', 'base_resource', 'base_resource TEXT');
286
+ addColumnIfMissing(database, 'locks', 'scope', "scope TEXT NOT NULL DEFAULT '*'");
287
+ database.exec(`
288
+ UPDATE locks
289
+ SET base_resource = resource
290
+ WHERE base_resource IS NULL OR base_resource = '';
291
+ UPDATE locks
292
+ SET scope = '*'
293
+ WHERE scope IS NULL OR scope = '';
294
+ CREATE INDEX IF NOT EXISTS idx_locks_base_scope ON locks(base_resource, scope);
295
+ `);
296
+ }
297
+ }
298
+ ];
299
+ let version = readSchemaVersion(db);
300
+ if (version > DB_SCHEMA_VERSION) {
301
+ throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${version} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
302
+ db_schema_version: version,
303
+ supported_schema_version: DB_SCHEMA_VERSION
304
+ });
305
+ }
306
+ const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
307
+ tx(db, () => {
308
+ for (const migration of migrations) {
309
+ if (version >= migration.version && applied.has(migration.version)) continue;
310
+ migration.up(db);
311
+ recordSchemaMigration(db, migration.version, migration.name);
312
+ applied.add(migration.version);
313
+ version = migration.version;
314
+ writeSchemaVersion(db, version);
315
+ }
316
+ if (version === 0) {
317
+ version = DB_SCHEMA_VERSION;
318
+ }
319
+ for (const migration of migrations) {
320
+ if (migration.version <= version) recordSchemaMigration(db, migration.version, migration.name);
321
+ }
322
+ writeSchemaVersion(db, version);
323
+ });
324
+ }
@@ -0,0 +1,231 @@
1
+ import { CliError } from '../../shared/errors.mjs';
2
+ import {
3
+ bindingHasProviderSession,
4
+ bindingHasRuntime,
5
+ bindingProviderSessionValue,
6
+ comparePeerBindings,
7
+ mergePeerBinding,
8
+ mergeRuntimeBinding
9
+ } from '../../core/peers/bindings.mjs';
10
+
11
+ function defaultNow() {
12
+ return Math.floor(Date.now() / 1000);
13
+ }
14
+
15
+ function noopEvent() {}
16
+
17
+ export function createPeerBindingStore(deps = {}) {
18
+ const now = typeof deps.now === 'function' ? deps.now : defaultNow;
19
+ const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
20
+
21
+ function dedupePeerBindingRows(db, rows, eventType, payload = {}) {
22
+ if (!rows || rows.length < 2) return 0;
23
+ const ordered = [...rows].sort(comparePeerBindings);
24
+ const survivor = ordered[0];
25
+ let deleted = 0;
26
+ for (const row of ordered.slice(1)) {
27
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(row.peer);
28
+ deleted += 1;
29
+ addEvent(db, eventType, survivor.peer, null, {
30
+ ...payload,
31
+ kept_peer: survivor.peer,
32
+ removed_peer: row.peer,
33
+ removed_transport: row.transport || null,
34
+ removed_runtime_target: row.runtime_target || null
35
+ });
36
+ }
37
+ return deleted;
38
+ }
39
+
40
+ function dedupeProviderSessionColumn(db, column) {
41
+ const groups = db.prepare(`
42
+ SELECT provider, ${column} AS session_value
43
+ FROM peer_bindings
44
+ WHERE ${column} IS NOT NULL
45
+ GROUP BY provider, ${column}
46
+ HAVING COUNT(*) > 1
47
+ `).all();
48
+ let deleted = 0;
49
+ for (const group of groups) {
50
+ const rows = db.prepare(`
51
+ SELECT *
52
+ FROM peer_bindings
53
+ WHERE provider = ? AND ${column} = ?
54
+ `).all(group.provider, group.session_value);
55
+ deleted += dedupePeerBindingRows(db, rows, 'provider.session.deduped', {
56
+ provider: group.provider,
57
+ provider_session: group.session_value,
58
+ provider_session_column: column
59
+ });
60
+ }
61
+ return deleted;
62
+ }
63
+
64
+ function dedupeRuntimeTargets(db) {
65
+ const groups = db.prepare(`
66
+ SELECT runtime_target
67
+ FROM peer_bindings
68
+ WHERE runtime_target IS NOT NULL
69
+ GROUP BY runtime_target
70
+ HAVING COUNT(*) > 1
71
+ `).all();
72
+ let deleted = 0;
73
+ for (const group of groups) {
74
+ const rows = db.prepare(`
75
+ SELECT *
76
+ FROM peer_bindings
77
+ WHERE runtime_target = ?
78
+ `).all(group.runtime_target);
79
+ deleted += dedupePeerBindingRows(db, rows, 'runtime.target.deduped', {
80
+ runtime_target: group.runtime_target
81
+ });
82
+ }
83
+ return deleted;
84
+ }
85
+
86
+ function dedupePeerBindings(db) {
87
+ for (let i = 0; i < 5; i += 1) {
88
+ const deleted =
89
+ dedupeProviderSessionColumn(db, 'provider_session_id') +
90
+ dedupeProviderSessionColumn(db, 'provider_session_name') +
91
+ dedupeRuntimeTargets(db);
92
+ if (!deleted) return;
93
+ }
94
+ }
95
+
96
+ function findProviderSessionBinding(db, binding) {
97
+ if (!bindingHasProviderSession(binding)) return null;
98
+ return db.prepare(`
99
+ SELECT *
100
+ FROM peer_bindings
101
+ WHERE provider = ?
102
+ AND peer <> ?
103
+ AND (
104
+ (? IS NOT NULL AND provider_session_id = ?)
105
+ OR (? IS NOT NULL AND provider_session_name = ?)
106
+ )
107
+ LIMIT 1
108
+ `).get(
109
+ binding.provider,
110
+ binding.peer,
111
+ binding.provider_session_id || null,
112
+ binding.provider_session_id || null,
113
+ binding.provider_session_name || null,
114
+ binding.provider_session_name || null
115
+ ) || null;
116
+ }
117
+
118
+ function canonicalizePeerBinding(db, binding, options = {}) {
119
+ const existing = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(binding.peer);
120
+ let next = mergePeerBinding(existing, binding);
121
+ const conflict = findProviderSessionBinding(db, next);
122
+ if (!conflict) return { peer: next.peer, binding: next, merged_from: null };
123
+
124
+ const incomingRuntime = bindingHasRuntime(next);
125
+ const conflictRuntime = bindingHasRuntime(conflict);
126
+ const providerSession = bindingProviderSessionValue(next);
127
+ const override = Boolean(options.override);
128
+
129
+ if (override && incomingRuntime) {
130
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(conflict.peer);
131
+ return { peer: next.peer, binding: next, merged_from: conflict.peer };
132
+ }
133
+
134
+ if (conflictRuntime && !incomingRuntime) {
135
+ next = mergeRuntimeBinding(conflict, { ...next, peer: conflict.peer });
136
+ return { peer: conflict.peer, binding: next, merged_from: binding.peer };
137
+ }
138
+
139
+ if (!conflictRuntime && incomingRuntime) {
140
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(conflict.peer);
141
+ return { peer: next.peer, binding: next, merged_from: conflict.peer };
142
+ }
143
+
144
+ if (!conflictRuntime && !incomingRuntime) {
145
+ next = {
146
+ ...next,
147
+ peer: conflict.peer,
148
+ command: conflict.command || next.command || null,
149
+ runtime_session_id: conflict.runtime_session_id || next.runtime_session_id || conflict.peer
150
+ };
151
+ return { peer: conflict.peer, binding: next, merged_from: binding.peer };
152
+ }
153
+
154
+ if (conflict.runtime_target && next.runtime_target && conflict.runtime_target === next.runtime_target) {
155
+ next = mergeRuntimeBinding(conflict, { ...next, peer: conflict.peer });
156
+ return { peer: conflict.peer, binding: next, merged_from: binding.peer };
157
+ }
158
+
159
+ throw new CliError('PROVIDER_SESSION_IN_USE', `${next.provider} session ${providerSession} is already bound to ${conflict.peer}`, {
160
+ peer: conflict.peer,
161
+ provider: conflict.provider,
162
+ provider_session: providerSession,
163
+ runtime_target: conflict.runtime_target || null
164
+ });
165
+ }
166
+
167
+ function upsertPeerBinding(db, binding, force = false) {
168
+ const t = now();
169
+ const existing = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(binding.peer);
170
+ binding = mergePeerBinding(existing, binding);
171
+ if ((binding.provider_session_id || binding.provider_session_name) && !force) {
172
+ const conflict = findProviderSessionBinding(db, binding);
173
+ if (conflict) {
174
+ const providerSession = bindingProviderSessionValue(conflict);
175
+ throw new CliError('PROVIDER_SESSION_IN_USE', `${binding.provider} session ${providerSession} is already bound to ${conflict.peer}`, {
176
+ peer: conflict.peer,
177
+ provider: conflict.provider,
178
+ provider_session: providerSession
179
+ });
180
+ }
181
+ }
182
+ db.prepare(`
183
+ INSERT INTO peer_bindings(
184
+ peer, provider, provider_session_id, provider_session_name, resume_mode,
185
+ resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
186
+ )
187
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
188
+ ON CONFLICT(peer) DO UPDATE SET
189
+ provider = excluded.provider,
190
+ provider_session_id = excluded.provider_session_id,
191
+ provider_session_name = excluded.provider_session_name,
192
+ resume_mode = excluded.resume_mode,
193
+ resume_arg = excluded.resume_arg,
194
+ command = excluded.command,
195
+ transport = excluded.transport,
196
+ runtime_session_id = excluded.runtime_session_id,
197
+ runtime_target = excluded.runtime_target,
198
+ updated_at = excluded.updated_at
199
+ `).run(
200
+ binding.peer,
201
+ binding.provider,
202
+ binding.provider_session_id || null,
203
+ binding.provider_session_name || null,
204
+ binding.resume_mode || 'new',
205
+ binding.resume_arg || null,
206
+ binding.command || null,
207
+ binding.transport,
208
+ binding.runtime_session_id || binding.peer,
209
+ binding.runtime_target || null,
210
+ t,
211
+ t
212
+ );
213
+ }
214
+
215
+ function upsertCanonicalPeerBinding(db, binding, force = false, options = {}) {
216
+ const result = canonicalizePeerBinding(db, binding, options);
217
+ upsertPeerBinding(db, result.binding, force);
218
+ return result;
219
+ }
220
+
221
+ return {
222
+ canonicalizePeerBinding,
223
+ dedupePeerBindings,
224
+ dedupePeerBindingRows,
225
+ dedupeProviderSessionColumn,
226
+ dedupeRuntimeTargets,
227
+ findProviderSessionBinding,
228
+ upsertCanonicalPeerBinding,
229
+ upsertPeerBinding
230
+ };
231
+ }