@logicseek/hello-cc 0.1.4 → 0.1.6
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 +126 -0
- package/bin/hcc.mjs +2739 -6482
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +3 -1
- package/docs/architecture-split-review.md +217 -0
- package/docs/architecture.md +385 -0
- package/docs/architecture.zh-CN.md +364 -0
- package/docs/commands.md +21 -9
- package/docs/commands.zh-CN.md +19 -9
- package/docs/layout-split-stack-audit.md +298 -0
- package/lib/automation.mjs +8 -0
- package/lib/cli-args.mjs +89 -0
- package/lib/cli-runtime.mjs +44 -0
- package/lib/coordination-state.mjs +218 -0
- package/lib/core/coordination/automation.mjs +222 -0
- package/lib/core/coordination/locks.mjs +46 -0
- package/lib/core/coordination/messages.mjs +116 -0
- package/lib/core/coordination/tasks.mjs +224 -0
- package/lib/core/coordination/teams.mjs +73 -0
- package/lib/core/coordination/timeline.mjs +187 -0
- package/lib/core/peers/bindings.mjs +83 -0
- package/lib/core/peers/format.mjs +13 -0
- package/lib/core/peers/identity.mjs +53 -0
- package/lib/core/peers/liveness.mjs +90 -0
- package/lib/core/peers/reconcile.mjs +238 -0
- package/lib/core/peers/session.mjs +14 -0
- package/lib/core/sessions/launch.mjs +89 -0
- package/lib/db/schema.mjs +324 -0
- package/lib/db/stores/peers.mjs +231 -0
- package/lib/db-schema.mjs +7 -0
- package/lib/errors.mjs +1 -0
- package/lib/format.mjs +39 -0
- package/lib/guidance.mjs +10 -2
- package/lib/handoff.mjs +26 -0
- package/lib/help.mjs +3 -0
- package/lib/integrations/hooks.mjs +171 -0
- package/lib/integrations/peers/identity.mjs +208 -0
- package/lib/integrations/peers/processes.mjs +151 -0
- package/lib/integrations/providers.mjs +282 -0
- package/lib/integrations/shims/script.mjs +384 -0
- package/lib/integrations/shims.mjs +106 -0
- package/lib/json-file.mjs +1 -0
- package/lib/locks.mjs +10 -0
- package/lib/messages.mjs +3 -0
- package/lib/package-meta.mjs +6 -24
- package/lib/peer-bindings.mjs +14 -0
- package/lib/peer-format.mjs +4 -0
- package/lib/peer-identity.mjs +15 -0
- package/lib/project-context.mjs +6 -0
- package/lib/project-registry.mjs +7 -0
- package/lib/provider-commands.mjs +13 -0
- package/lib/release/package-meta.mjs +31 -0
- package/lib/release/release-notes.mjs +40 -0
- package/lib/release-notes.mjs +6 -40
- package/lib/runtime/client.mjs +42 -0
- package/lib/runtime/paths.mjs +45 -0
- package/lib/runtime/project-context.mjs +32 -0
- package/lib/runtime/projects.mjs +76 -0
- package/lib/runtime/state.mjs +128 -0
- package/lib/runtime-client.mjs +3 -0
- package/lib/runtime-paths.mjs +11 -0
- package/lib/runtime-state.mjs +11 -0
- package/lib/session-launch.mjs +17 -0
- package/lib/setup.mjs +18 -765
- package/lib/shared/errors.mjs +7 -0
- package/lib/shared/json-file.mjs +14 -0
- package/lib/shell-path.mjs +69 -0
- package/lib/shim-script.mjs +1 -0
- package/lib/state-render.mjs +5 -0
- package/lib/task-cli.mjs +45 -0
- package/lib/task-liveness.mjs +8 -0
- package/lib/task-store.mjs +3 -0
- package/lib/team-planning.mjs +7 -0
- package/lib/terminal/tmux.mjs +372 -0
- package/lib/timeline.mjs +12 -0
- package/lib/tmux.mjs +18 -0
- package/lib/ui/help.mjs +419 -0
- package/lib/ui/state-render.mjs +61 -0
- package/lib/web/http.mjs +55 -0
- package/lib/web/peer-actions.mjs +270 -0
- package/lib/web/runtime.mjs +154 -0
- package/lib/web/ui-template.mjs +2055 -0
- package/lib/web-http.mjs +7 -0
- package/lib/web-peer-actions.mjs +3 -0
- package/lib/web-runtime.mjs +17 -0
- package/lib/web-ui-template.mjs +3 -0
- package/package.json +1 -1
- package/scripts/github-release.mjs +2 -2
- package/scripts/regression.mjs +2738 -22
- package/scripts/release-notes.mjs +2 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { shortHash } from '../peers/format.mjs';
|
|
5
|
+
|
|
6
|
+
export const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
|
|
7
|
+
export const LAUNCH_FINGERPRINT_ENV = 'HCC_LAUNCH_FINGERPRINT';
|
|
8
|
+
export const PROVIDER_STATE_ENV = 'HCC_PROVIDER_STATE';
|
|
9
|
+
|
|
10
|
+
const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
11
|
+
'_',
|
|
12
|
+
'COLUMNS',
|
|
13
|
+
'HCC_DB',
|
|
14
|
+
'HCC_NO_AUTO_INSTALL_TMUX',
|
|
15
|
+
'HCC_PEER',
|
|
16
|
+
'HCC_ROOT',
|
|
17
|
+
PROVIDER_STATE_ENV,
|
|
18
|
+
'HCC_SHIM_ENSURED',
|
|
19
|
+
'HCC_SHIM_NO_ATTACH',
|
|
20
|
+
'HCC_WEB_TOKEN',
|
|
21
|
+
LAUNCH_FINGERPRINT_ENV,
|
|
22
|
+
'LINES',
|
|
23
|
+
'OLDPWD',
|
|
24
|
+
'PROMPT_COMMAND',
|
|
25
|
+
'PS1',
|
|
26
|
+
'PS2',
|
|
27
|
+
'PS4',
|
|
28
|
+
'PWD',
|
|
29
|
+
'SHLVL',
|
|
30
|
+
'TERM',
|
|
31
|
+
'TERMCAP',
|
|
32
|
+
'TMUX',
|
|
33
|
+
'TMUX_PANE',
|
|
34
|
+
'WINDOWID'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function childSessionEnv(extra = {}, baseEnv = process.env) {
|
|
38
|
+
const env = { ...(baseEnv || {}), ...extra };
|
|
39
|
+
delete env[WEB_CHILD_ENV];
|
|
40
|
+
return env;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function launchEnvironmentFingerprint(env) {
|
|
44
|
+
const entries = Object.entries(env || {})
|
|
45
|
+
.filter(([key, value]) =>
|
|
46
|
+
value !== undefined &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
!LAUNCH_ENV_IGNORED_KEYS.has(key) &&
|
|
49
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
|
|
50
|
+
)
|
|
51
|
+
.map(([key, value]) => [key, String(value)])
|
|
52
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
53
|
+
return shortHash(JSON.stringify(entries));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function launchFingerprint({ command, cwd, env }) {
|
|
57
|
+
return shortHash(JSON.stringify({
|
|
58
|
+
command: command || '',
|
|
59
|
+
cwd: cwd || '',
|
|
60
|
+
env: launchEnvironmentFingerprint(env || {})
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isLikelyShellCommand(command) {
|
|
65
|
+
const base = path.basename(String(command || '')).replace(/^-/, '');
|
|
66
|
+
return new Set(['bash', 'dash', 'fish', 'ksh', 'mksh', 'sh', 'zsh']).has(base);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isProviderFallbackWrapper(command) {
|
|
70
|
+
const text = String(command || '');
|
|
71
|
+
return text.includes(PROVIDER_STATE_ENV) || /\bexec\s+(?:bash|dash|fish|ksh|mksh|sh|zsh)\b/.test(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isRelaunchableProviderSession(kind, command, binding = {}) {
|
|
75
|
+
const provider = binding.provider || kind;
|
|
76
|
+
return ['claude', 'codex'].includes(provider) && isProviderFallbackWrapper(command);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isolatedEnvCommandArgs(env) {
|
|
80
|
+
const envBin = fs.existsSync('/usr/bin/env') ? '/usr/bin/env' : 'env';
|
|
81
|
+
const args = [envBin, '-i'];
|
|
82
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
83
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
84
|
+
if (['TMUX', 'TMUX_PANE'].includes(key)) continue;
|
|
85
|
+
if (value === undefined || value === null) continue;
|
|
86
|
+
args.push(`${key}=${String(value)}`);
|
|
87
|
+
}
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/errors.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CliError } from './shared/errors.mjs';
|
package/lib/format.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function compactText(value, limit = 160) {
|
|
2
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
3
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}\u2026` : text;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function formatJson(ok, dataOrError) {
|
|
7
|
+
if (ok) return JSON.stringify({ ok: true, data: dataOrError }, null, 2);
|
|
8
|
+
return JSON.stringify({ ok: false, error: dataOrError }, null, 2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function printResult(ctx, data, render) {
|
|
12
|
+
if (ctx.json) {
|
|
13
|
+
console.log(formatJson(true, data));
|
|
14
|
+
} else {
|
|
15
|
+
const output = render ? render(data) : String(data ?? '');
|
|
16
|
+
if (output) console.log(output);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shellQuoteArg(value) {
|
|
21
|
+
const text = String(value);
|
|
22
|
+
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(text)) return text;
|
|
23
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shellExports(values) {
|
|
27
|
+
return Object.entries(values)
|
|
28
|
+
.map(([key, value]) => `export ${key}=${shellQuoteArg(value)}`)
|
|
29
|
+
.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function table(rows, columns) {
|
|
33
|
+
if (!rows.length) return '(none)';
|
|
34
|
+
const widths = columns.map((col) => Math.max(col.label.length, ...rows.map((row) => String(col.value(row) ?? '').length)));
|
|
35
|
+
const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
|
|
36
|
+
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
|
37
|
+
const body = rows.map((row) => columns.map((col, i) => String(col.value(row) ?? '').padEnd(widths[i])).join(' '));
|
|
38
|
+
return [header, sep, ...body].join('\n');
|
|
39
|
+
}
|