ework-web 0.4.2 → 0.4.4
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/package.json +1 -1
- package/src/db-admin.ts +138 -1
- package/src/index.ts +25 -14
package/package.json
CHANGED
package/src/db-admin.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type TestMysqlResult =
|
|
|
33
33
|
| { ok: false; error: string; hint?: string };
|
|
34
34
|
|
|
35
35
|
export type MigrateResult =
|
|
36
|
-
| { ok: true; tables: { table: string; rows: number }[] }
|
|
36
|
+
| { ok: true; tables: { table: string; rows: number }[]; skipped?: string }
|
|
37
37
|
| { ok: false; error: string; partial?: string[] };
|
|
38
38
|
|
|
39
39
|
export type WriteEnvResult = { written: string[]; envPath: string };
|
|
@@ -523,6 +523,143 @@ export async function migrateMysqlToSqlite(targetPath: string): Promise<MigrateR
|
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
// The daemon has its own SQLite DB separate from the web's. When switching
|
|
527
|
+
// to MySQL, its old session data must be copied too. MySQL tables are
|
|
528
|
+
// created with basic columns matching the daemon's SQLite schema;
|
|
529
|
+
// coordination columns (owner_daemon_id, etc.) are added by the daemon's
|
|
530
|
+
// own ALTER TABLE migration on boot.
|
|
531
|
+
|
|
532
|
+
const DAEMON_TABLES = ["issues", "op_sessions", "messages"] as const;
|
|
533
|
+
const DAEMON_DDL: Record<string, string> = {
|
|
534
|
+
issues:
|
|
535
|
+
"CREATE TABLE IF NOT EXISTS {{t}} (" +
|
|
536
|
+
"id VARCHAR(36) PRIMARY KEY," +
|
|
537
|
+
"tracker_type VARCHAR(32) NOT NULL," +
|
|
538
|
+
"tracker_scope_key VARCHAR(255) NOT NULL," +
|
|
539
|
+
"tracker_scope TEXT NOT NULL," +
|
|
540
|
+
"tracker_issue_id VARCHAR(64) NOT NULL," +
|
|
541
|
+
"state VARCHAR(16) NOT NULL DEFAULT 'created'," +
|
|
542
|
+
"title TEXT NOT NULL," +
|
|
543
|
+
"created_at VARCHAR(40) NOT NULL," +
|
|
544
|
+
"updated_at VARCHAR(40) NOT NULL," +
|
|
545
|
+
"UNIQUE(tracker_type, tracker_scope_key, tracker_issue_id)" +
|
|
546
|
+
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
547
|
+
op_sessions:
|
|
548
|
+
"CREATE TABLE IF NOT EXISTS {{t}} (" +
|
|
549
|
+
"id VARCHAR(36) PRIMARY KEY," +
|
|
550
|
+
"issue_id VARCHAR(36) NOT NULL," +
|
|
551
|
+
"name VARCHAR(255) NOT NULL," +
|
|
552
|
+
"state VARCHAR(16) NOT NULL DEFAULT 'idle'," +
|
|
553
|
+
"opencode_session_id VARCHAR(64)," +
|
|
554
|
+
"opencode_pid BIGINT," +
|
|
555
|
+
"workdir TEXT," +
|
|
556
|
+
"created_at VARCHAR(40) NOT NULL," +
|
|
557
|
+
"started_at BIGINT," +
|
|
558
|
+
"progress_comment_id VARCHAR(64)," +
|
|
559
|
+
"reaction_comment_id VARCHAR(64)," +
|
|
560
|
+
"current_prompt MEDIUMTEXT," +
|
|
561
|
+
"UNIQUE(issue_id, name)" +
|
|
562
|
+
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
563
|
+
messages:
|
|
564
|
+
"CREATE TABLE IF NOT EXISTS {{t}} (" +
|
|
565
|
+
"id VARCHAR(36) PRIMARY KEY," +
|
|
566
|
+
"session_id VARCHAR(36) NOT NULL," +
|
|
567
|
+
"content MEDIUMTEXT NOT NULL," +
|
|
568
|
+
"source_comment_id VARCHAR(64)," +
|
|
569
|
+
"reaction_comment_id VARCHAR(64)," +
|
|
570
|
+
"status VARCHAR(16) NOT NULL DEFAULT 'pending'," +
|
|
571
|
+
"attempts INT NOT NULL DEFAULT 0," +
|
|
572
|
+
"error TEXT," +
|
|
573
|
+
"created_at VARCHAR(40) NOT NULL," +
|
|
574
|
+
"updated_at VARCHAR(40) NOT NULL" +
|
|
575
|
+
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
export async function migrateDaemonSqliteToMysql(
|
|
579
|
+
sqlitePath: string,
|
|
580
|
+
opts: MysqlTargetOpts,
|
|
581
|
+
): Promise<MigrateResult> {
|
|
582
|
+
if (!existsSync(sqlitePath)) {
|
|
583
|
+
return { ok: true, tables: [], skipped: "no daemon sqlite file found" };
|
|
584
|
+
}
|
|
585
|
+
const prefix = validatePrefix(opts.prefix ?? "");
|
|
586
|
+
let pool: Pool | null = null;
|
|
587
|
+
let conn: PoolConnection | null = null;
|
|
588
|
+
const completed: { table: string; rows: number }[] = [];
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
const probe = new Database(sqlitePath, { readonly: true });
|
|
592
|
+
const tableNames = probe
|
|
593
|
+
.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
594
|
+
.all() as { name: string }[];
|
|
595
|
+
probe.close();
|
|
596
|
+
|
|
597
|
+
const hasDaemonTables = DAEMON_TABLES.every((t) => tableNames.some((r) => r.name === t));
|
|
598
|
+
if (!hasDaemonTables) {
|
|
599
|
+
return { ok: true, tables: [], skipped: "daemon sqlite has no daemon tables" };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
pool = createPool({
|
|
603
|
+
host: opts.host,
|
|
604
|
+
port: opts.port,
|
|
605
|
+
user: opts.user,
|
|
606
|
+
password: opts.password,
|
|
607
|
+
database: opts.database,
|
|
608
|
+
waitForConnections: true,
|
|
609
|
+
connectionLimit: 2,
|
|
610
|
+
charset: "utf8mb4",
|
|
611
|
+
});
|
|
612
|
+
conn = await pool.getConnection();
|
|
613
|
+
|
|
614
|
+
await conn.query("SET FOREIGN_KEY_CHECKS = 0");
|
|
615
|
+
|
|
616
|
+
for (const table of DAEMON_TABLES) {
|
|
617
|
+
const ddl = applyTargetPrefix(DAEMON_DDL[table]!.replace(/{{t}}/g, `{{${table}}}`), prefix);
|
|
618
|
+
await conn.query(ddl);
|
|
619
|
+
await conn.query(`TRUNCATE TABLE ${ident(prefix + table)}`);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const sqliteRo = new Database(sqlitePath, { readonly: true });
|
|
623
|
+
for (const table of DAEMON_TABLES) {
|
|
624
|
+
const rows = sqliteRo.query(`SELECT * FROM ${table}`).all() as Record<string, unknown>[];
|
|
625
|
+
if (rows.length === 0) {
|
|
626
|
+
completed.push({ table, rows: 0 });
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const cols = Object.keys(rows[0]!);
|
|
630
|
+
const placeholders = cols.map(() => "?").join(",");
|
|
631
|
+
const insertSql = `INSERT INTO ${ident(prefix + table)} (${cols.map((c) => ident(c)).join(",")}) VALUES (${placeholders})`;
|
|
632
|
+
for (let i = 0; i < rows.length; i += 500) {
|
|
633
|
+
const batch = rows.slice(i, i + 500);
|
|
634
|
+
for (const row of batch) {
|
|
635
|
+
const vals = cols.map((c) => {
|
|
636
|
+
const v = row[c];
|
|
637
|
+
return v === undefined ? null : v;
|
|
638
|
+
});
|
|
639
|
+
await conn.query(insertSql, vals);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const [countRows] = await conn.query(`SELECT COUNT(*) AS c FROM ${ident(prefix + table)}`);
|
|
643
|
+
const countRow = (countRows as Record<string, unknown>[])[0];
|
|
644
|
+
const count = (countRow?.c as number) ?? -1;
|
|
645
|
+
if (count !== rows.length) {
|
|
646
|
+
throw new Error(`Row-count mismatch for daemon ${table}: sqlite=${rows.length} mysql=${count}`);
|
|
647
|
+
}
|
|
648
|
+
completed.push({ table, rows: rows.length });
|
|
649
|
+
}
|
|
650
|
+
sqliteRo.close();
|
|
651
|
+
|
|
652
|
+
await conn.query("SET FOREIGN_KEY_CHECKS = 1");
|
|
653
|
+
return { ok: true, tables: completed };
|
|
654
|
+
} catch (e) {
|
|
655
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
656
|
+
return { ok: false, error: redactPassword(msg), partial: completed.map((c) => `${c.table}=${c.rows}`) };
|
|
657
|
+
} finally {
|
|
658
|
+
if (conn) conn.release();
|
|
659
|
+
if (pool) await pool.end();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
526
663
|
export async function writeSqliteEnv(envPath: string, dbPath: string): Promise<WriteEnvResult> {
|
|
527
664
|
const desired: Record<string, string> = {
|
|
528
665
|
WORK_DB_DRIVER: "sqlite",
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir } from "os";
|
|
|
6
6
|
import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
|
|
7
7
|
import type { Config } from "./config";
|
|
8
8
|
import { setConfig, initDB } from "./db";
|
|
9
|
-
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv } from "./db-admin";
|
|
9
|
+
import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
|
|
10
10
|
import type { MysqlTargetOpts } from "./db-admin";
|
|
11
11
|
import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
|
|
12
12
|
import { OpencodeClient, OpencodeError } from "./opencode";
|
|
@@ -205,25 +205,20 @@ function parseDbTargetOpts(payload: unknown): MysqlTargetOpts | { error: string
|
|
|
205
205
|
// Two restart paths depending on how this process was launched. Both run
|
|
206
206
|
// AFTER the HTTP response is sent (caller schedules this last):
|
|
207
207
|
// systemd (INVOCATION_ID set): exit → the unit's Restart=always brings us back
|
|
208
|
-
// PID-file mode (ework-aio start): `ework-aio restart web
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
208
|
+
// PID-file mode (ework-aio start): spawn `ework-aio restart web`, then exit.
|
|
209
|
+
// The restart child reads our PID file, finds a dead PID (stale), cleans
|
|
210
|
+
// up, and starts fresh. Always exiting avoids the race where the child
|
|
211
|
+
// SIGTERMs us while we're still serving the old config.
|
|
212
212
|
function scheduleRestart(): void {
|
|
213
213
|
if (process.env.INVOCATION_ID) {
|
|
214
214
|
setTimeout(() => process.exit(0), 750);
|
|
215
215
|
return;
|
|
216
216
|
}
|
|
217
|
-
|
|
217
|
+
spawn("ework-aio", ["restart", "web"], {
|
|
218
218
|
detached: true,
|
|
219
219
|
stdio: "ignore",
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// ework-aio not on PATH (dev mode) — best-effort exit; if no supervisor
|
|
223
|
-
// is watching, the admin restarts manually.
|
|
224
|
-
setTimeout(() => process.exit(0), 750);
|
|
225
|
-
});
|
|
226
|
-
child.unref();
|
|
220
|
+
}).unref();
|
|
221
|
+
setTimeout(() => process.exit(0), 1500);
|
|
227
222
|
}
|
|
228
223
|
|
|
229
224
|
// The daemon's `issues` table (thin ref) collides with the web's `issues`
|
|
@@ -252,6 +247,16 @@ function daemonEnvPath(): string | null {
|
|
|
252
247
|
return null;
|
|
253
248
|
}
|
|
254
249
|
|
|
250
|
+
function readDaemonSqlitePath(envPath: string): string | null {
|
|
251
|
+
try {
|
|
252
|
+
const content = readFileSync(envPath, "utf8");
|
|
253
|
+
const m = content.match(/^DAEMON_DB_PATH\s*=\s*(.+)$/m);
|
|
254
|
+
return m?.[1]?.trim() ?? null;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
255
260
|
function spawnDaemonRestart(): void {
|
|
256
261
|
try {
|
|
257
262
|
const child = spawn("ework-aio", ["restart", "daemon"], { detached: true, stdio: "ignore" });
|
|
@@ -753,9 +758,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
753
758
|
return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
|
|
754
759
|
}
|
|
755
760
|
try {
|
|
761
|
+
const daemonDbPath = readDaemonSqlitePath(envPath);
|
|
762
|
+
let daemonMigrated: { table: string; rows: number }[] | null = null;
|
|
763
|
+
if (daemonDbPath) {
|
|
764
|
+
const mig = await migrateDaemonSqliteToMysql(daemonDbPath, daemonOpts);
|
|
765
|
+
if (mig.ok) daemonMigrated = mig.tables;
|
|
766
|
+
}
|
|
756
767
|
const written = await writeMysqlEnv(envPath, daemonOpts);
|
|
757
768
|
spawnDaemonRestart();
|
|
758
|
-
return json({ ok: true, configured: true, envPath, written: written.written });
|
|
769
|
+
return json({ ok: true, configured: true, envPath, written: written.written, daemonMigrated });
|
|
759
770
|
} catch (e) {
|
|
760
771
|
return json({ ok: false, configured: false, error: errMsg(e), manual: daemonManualInstructions(daemonOpts) });
|
|
761
772
|
}
|