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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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` SIGTERMs us via the
209
- // pidfile and spawns a fresh process that re-reads the updated .env.
210
- // The ework-aio spawn is UNSAFE under systemd it would start a second process
211
- // on the same port so the INVOCATION_ID gate is load-bearing.
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
- const child = spawn("ework-aio", ["restart", "web"], {
217
+ spawn("ework-aio", ["restart", "web"], {
218
218
  detached: true,
219
219
  stdio: "ignore",
220
- });
221
- child.on("error", () => {
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
  }