ework-web 0.4.3 → 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 +18 -2
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";
|
|
@@ -247,6 +247,16 @@ function daemonEnvPath(): string | null {
|
|
|
247
247
|
return null;
|
|
248
248
|
}
|
|
249
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
|
+
|
|
250
260
|
function spawnDaemonRestart(): void {
|
|
251
261
|
try {
|
|
252
262
|
const child = spawn("ework-aio", ["restart", "daemon"], { detached: true, stdio: "ignore" });
|
|
@@ -748,9 +758,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
748
758
|
return json({ ok: false, configured: false, manual: daemonManualInstructions(daemonOpts) });
|
|
749
759
|
}
|
|
750
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
|
+
}
|
|
751
767
|
const written = await writeMysqlEnv(envPath, daemonOpts);
|
|
752
768
|
spawnDaemonRestart();
|
|
753
|
-
return json({ ok: true, configured: true, envPath, written: written.written });
|
|
769
|
+
return json({ ok: true, configured: true, envPath, written: written.written, daemonMigrated });
|
|
754
770
|
} catch (e) {
|
|
755
771
|
return json({ ok: false, configured: false, error: errMsg(e), manual: daemonManualInstructions(daemonOpts) });
|
|
756
772
|
}
|