astrocode-workflow 0.1.58 → 0.2.0

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 (54) hide show
  1. package/README.md +243 -11
  2. package/dist/agents/prompts.d.ts +1 -0
  3. package/dist/agents/prompts.js +159 -0
  4. package/dist/agents/registry.js +11 -1
  5. package/dist/config/loader.js +34 -0
  6. package/dist/config/schema.d.ts +7 -1
  7. package/dist/config/schema.js +2 -0
  8. package/dist/hooks/continuation-enforcer.d.ts +9 -1
  9. package/dist/hooks/continuation-enforcer.js +2 -1
  10. package/dist/hooks/inject-provider.d.ts +9 -1
  11. package/dist/hooks/inject-provider.js +2 -1
  12. package/dist/hooks/tool-output-truncator.d.ts +9 -1
  13. package/dist/hooks/tool-output-truncator.js +2 -1
  14. package/dist/index.js +228 -45
  15. package/dist/state/adapters/index.d.ts +4 -2
  16. package/dist/state/adapters/index.js +23 -27
  17. package/dist/state/db.d.ts +6 -8
  18. package/dist/state/db.js +106 -45
  19. package/dist/tools/index.d.ts +13 -3
  20. package/dist/tools/index.js +14 -31
  21. package/dist/tools/init.d.ts +10 -1
  22. package/dist/tools/init.js +73 -18
  23. package/dist/tools/injects.js +90 -26
  24. package/dist/tools/spec.d.ts +0 -1
  25. package/dist/tools/spec.js +4 -1
  26. package/dist/tools/status.d.ts +1 -1
  27. package/dist/tools/status.js +70 -52
  28. package/dist/tools/workflow.js +2 -2
  29. package/dist/ui/inject.d.ts +16 -2
  30. package/dist/ui/inject.js +104 -33
  31. package/dist/workflow/directives.d.ts +2 -0
  32. package/dist/workflow/directives.js +34 -19
  33. package/dist/workflow/state-machine.d.ts +46 -3
  34. package/dist/workflow/state-machine.js +249 -92
  35. package/package.json +1 -1
  36. package/src/agents/prompts.ts +160 -0
  37. package/src/agents/registry.ts +16 -1
  38. package/src/config/loader.ts +39 -4
  39. package/src/config/schema.ts +3 -0
  40. package/src/hooks/continuation-enforcer.ts +9 -2
  41. package/src/hooks/inject-provider.ts +9 -2
  42. package/src/hooks/tool-output-truncator.ts +9 -2
  43. package/src/index.ts +260 -56
  44. package/src/state/adapters/index.ts +21 -26
  45. package/src/state/db.ts +114 -58
  46. package/src/tools/index.ts +29 -31
  47. package/src/tools/init.ts +91 -22
  48. package/src/tools/injects.ts +147 -53
  49. package/src/tools/spec.ts +6 -2
  50. package/src/tools/status.ts +71 -55
  51. package/src/tools/workflow.ts +3 -3
  52. package/src/ui/inject.ts +115 -41
  53. package/src/workflow/directives.ts +103 -75
  54. package/src/workflow/state-machine.ts +327 -109
@@ -13,7 +13,8 @@ function pickMaxChars(toolName, cfg) {
13
13
  return cfg.truncation.max_chars_default;
14
14
  }
15
15
  export function createToolOutputTruncatorHook(opts) {
16
- const { ctx, config, db } = opts;
16
+ const { ctx, config, runtime } = opts;
17
+ const { db } = runtime;
17
18
  return async function toolExecuteAfter(input, output) {
18
19
  if (!config.truncation.enabled)
19
20
  return;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadAstrocodeConfig } from "./config/loader";
2
2
  import { createConfigHandler } from "./config/config-handler";
3
- import { openSqlite, configurePragmas, ensureSchema } from "./state/db";
3
+ import { openSqlite, configurePragmas, ensureSchema, getSchemaVersion } from "./state/db";
4
4
  import { getAstroPaths, ensureAstroDirs } from "./shared/paths";
5
5
  import { createAstroTools } from "./tools";
6
6
  import { createContinuationEnforcer } from "./hooks/continuation-enforcer";
@@ -8,106 +8,289 @@ import { createToolOutputTruncatorHook } from "./hooks/tool-output-truncator";
8
8
  import { createInjectProvider } from "./hooks/inject-provider";
9
9
  import { createToastManager } from "./ui/toasts";
10
10
  import { createAstroAgents } from "./agents/registry";
11
+ import { info, warn } from "./shared/log";
12
+ // Safe config cloning with structuredClone preference (fallback for older Node versions)
13
+ // CONTRACT: Config is guaranteed JSON-serializable (enforced by loadAstrocodeConfig validation)
14
+ const cloneConfig = (v) => {
15
+ // Prefer structuredClone when available (keeps more types)
16
+ if (typeof globalThis.structuredClone === "function") {
17
+ return globalThis.structuredClone(v);
18
+ }
19
+ // Fallback: JSON clone (safe since config is validated as JSON-serializable)
20
+ return JSON.parse(JSON.stringify(v));
21
+ };
22
+ // Minimal config defaults for limited-mode fallback
23
+ const applyMinimalConfigDefaults = (cfg) => {
24
+ if (!cfg || typeof cfg !== "object")
25
+ return;
26
+ if (!Array.isArray(cfg.disabled_hooks))
27
+ cfg.disabled_hooks = [];
28
+ if (!cfg.ui || typeof cfg.ui !== "object")
29
+ cfg.ui = {};
30
+ if (!cfg.ui.toasts || typeof cfg.ui.toasts !== "object")
31
+ cfg.ui.toasts = { enabled: false };
32
+ if (typeof cfg.ui.toasts.enabled !== "boolean")
33
+ cfg.ui.toasts.enabled = false;
34
+ };
11
35
  const Astrocode = async (ctx) => {
36
+ if (!ctx.directory || typeof ctx.directory !== "string") {
37
+ throw new Error("Astrocode requires ctx.directory to be a string repo root.");
38
+ }
12
39
  const repoRoot = ctx.directory;
13
40
  // Always load config first - this provides defaults even in limited mode
14
- let pluginConfig = loadAstrocodeConfig(repoRoot);
15
- // Create agents for registration
16
- const agents = createAstroAgents({ pluginConfig });
41
+ let pluginConfig;
42
+ try {
43
+ pluginConfig = loadAstrocodeConfig(repoRoot);
44
+ }
45
+ catch (e) {
46
+ warn(`Config loading failed, using minimal defaults.`, { err: e });
47
+ // Fallback to minimal safe defaults when config loading fails
48
+ pluginConfig = {
49
+ disabled_hooks: [],
50
+ disabled_agents: [],
51
+ determinism: { mode: "on", strict_stage_order: true },
52
+ db: {
53
+ path: ".astro/astro.db",
54
+ busy_timeout_ms: 5000,
55
+ pragmas: {},
56
+ schema_version_required: 2,
57
+ allow_auto_migrate: true,
58
+ fail_on_downgrade: true,
59
+ },
60
+ ui: {
61
+ toasts: { enabled: true, throttle_ms: 1500, show_run_started: true, show_stage_started: true, show_stage_completed: true, show_stage_failed: true, show_run_completed: true, show_auto_continue: true },
62
+ },
63
+ agents: {
64
+ orchestrator_name: "Astro",
65
+ stage_agent_names: { frame: "Frame", plan: "Plan", spec: "Spec", implement: "Implement", review: "Review", verify: "Verify", close: "Close" },
66
+ librarian_name: "Librarian",
67
+ explore_name: "Explore",
68
+ qa_name: "QA",
69
+ agent_variant_overrides: {},
70
+ },
71
+ workflow: {
72
+ default_mode: "step",
73
+ default_max_steps: 10,
74
+ loop_max_steps_hard_cap: 100,
75
+ evidence_required: { verify: false },
76
+ },
77
+ permissions: { enforce_task_tool_restrictions: false, deny_delegate_task_in_subagents: false },
78
+ };
79
+ }
17
80
  // Always ensure .astro directories exist, even in limited mode
18
81
  const paths = getAstroPaths(repoRoot, pluginConfig.db.path);
19
82
  ensureAstroDirs(paths);
20
83
  let db = null;
21
- let tools = null;
22
- let configHandler = null;
84
+ let agents = undefined;
85
+ let configHandler = async () => {
86
+ throw new Error("ConfigHandler called before initialization");
87
+ };
23
88
  let continuation = null;
24
89
  let truncatorHook = null;
25
90
  let injectProvider = null;
26
- let toasts = null;
91
+ let toastManager = null;
92
+ let limitedModeReason = null;
93
+ let limitedMode = false;
94
+ // Phase 1: Database initialization (only DB-related operations)
27
95
  try {
28
96
  db = openSqlite(paths.dbPath, { busyTimeoutMs: pluginConfig.db.busy_timeout_ms });
29
97
  configurePragmas(db, pluginConfig.db.pragmas);
30
- ensureSchema(db, { allowAutoMigrate: pluginConfig.db.allow_auto_migrate, failOnDowngrade: pluginConfig.db.fail_on_downgrade });
31
- // Database initialized successfully
32
- configHandler = createConfigHandler({ pluginConfig });
33
- tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
34
- continuation = createContinuationEnforcer({ ctx, config: pluginConfig, db });
35
- truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, db });
36
- injectProvider = createInjectProvider({ ctx, config: pluginConfig, db });
37
- toasts = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
98
+ // Policy enforced in harness; db layer is mechanics only (migration + structural schema creation)
99
+ ensureSchema(db, { allowAutoMigrate: pluginConfig.db.allow_auto_migrate });
100
+ // Harness-exclusive: enforce schema version requirement
101
+ // Explicitly enforce schema version requirement (harness-level policy)
102
+ const required = pluginConfig.db.schema_version_required;
103
+ let current = getSchemaVersion(db);
104
+ if (current < required) {
105
+ if (!pluginConfig.db.allow_auto_migrate) {
106
+ // Migration disabled - enter limited mode
107
+ limitedModeReason = { code: "schema_too_old", details: { current, required } };
108
+ warn("Entering limited mode (schema too old, migration disabled).", {
109
+ dbPath: paths.dbPath,
110
+ schemaCurrent: current,
111
+ schemaRequired: required,
112
+ failOnDowngrade: pluginConfig.db.fail_on_downgrade,
113
+ allowAutoMigrate: pluginConfig.db.allow_auto_migrate,
114
+ });
115
+ db.close?.();
116
+ db = null;
117
+ }
118
+ else {
119
+ // Migration allowed - re-run ensureSchema (idempotent) in case first call failed
120
+ ensureSchema(db, { allowAutoMigrate: true });
121
+ current = getSchemaVersion(db);
122
+ if (current < required) {
123
+ // Migration failed - enter limited mode
124
+ limitedModeReason = { code: "schema_migration_failed", details: { current, required } };
125
+ warn("Entering limited mode (schema migration failed).", {
126
+ dbPath: paths.dbPath,
127
+ schemaCurrent: current,
128
+ schemaRequired: required,
129
+ failOnDowngrade: pluginConfig.db.fail_on_downgrade,
130
+ allowAutoMigrate: pluginConfig.db.allow_auto_migrate,
131
+ });
132
+ db.close?.();
133
+ db = null;
134
+ }
135
+ }
136
+ }
137
+ else if (current > required && pluginConfig.db.fail_on_downgrade) {
138
+ limitedModeReason = { code: "schema_downgrade", details: { current, required } };
139
+ warn("Entering limited mode (schema downgrade).", {
140
+ dbPath: paths.dbPath,
141
+ schemaCurrent: current,
142
+ schemaRequired: required,
143
+ failOnDowngrade: pluginConfig.db.fail_on_downgrade,
144
+ allowAutoMigrate: pluginConfig.db.allow_auto_migrate,
145
+ });
146
+ db.close?.();
147
+ db = null;
148
+ }
38
149
  }
39
150
  catch (e) {
40
- // Database initialization failed - setup limited mode
41
- // Reload config to ensure all defaults are present
42
- pluginConfig = loadAstrocodeConfig(repoRoot);
43
- // Modify config for limited mode
44
- pluginConfig.disabled_hooks = [...(pluginConfig.disabled_hooks || []), "continuation-enforcer", "tool-output-truncator"];
45
- pluginConfig.ui.toasts.enabled = false;
46
- // Create limited functionality
151
+ // True database initialization failure - enter limited mode
152
+ warn(`Database initialization failed. Entering limited mode with reduced functionality.`, { err: e });
153
+ // Clean up partially-opened database connection
154
+ if (db && typeof db.close === "function") {
155
+ try {
156
+ db.close();
157
+ }
158
+ catch (closeErr) {
159
+ // Ignore close errors during cleanup
160
+ }
161
+ }
47
162
  db = null;
48
- configHandler = createConfigHandler({ pluginConfig });
49
- tools = createAstroTools({ ctx, config: pluginConfig, db, agents });
163
+ limitedModeReason = { code: "db_init_failed", details: e };
164
+ }
165
+ // Derive limited mode flag once for single source of truth
166
+ limitedMode = db === null;
167
+ // Single runtime state object for downstream components
168
+ const runtime = { db, limitedMode, limitedModeReason };
169
+ // Phase 2: Component initialization (based on DB availability)
170
+ if (!limitedMode) {
171
+ // Normal mode: DB available, create all components
172
+ try {
173
+ agents = createAstroAgents({ pluginConfig });
174
+ // Database initialized successfully
175
+ info("Database initialized successfully. Plugin ready.");
176
+ configHandler = createConfigHandler({ pluginConfig });
177
+ continuation = createContinuationEnforcer({ ctx, config: pluginConfig, runtime });
178
+ truncatorHook = createToolOutputTruncatorHook({ ctx, config: pluginConfig, runtime });
179
+ injectProvider = createInjectProvider({ ctx, config: pluginConfig, runtime });
180
+ toastManager = createToastManager({ ctx, throttleMs: pluginConfig.ui.toasts.throttle_ms });
181
+ }
182
+ catch (e) {
183
+ // Component failure with working DB → fail plugin boot
184
+ const err = e instanceof Error ? e : new Error(String(e));
185
+ throw new Error("Plugin initialization failed after successful database setup", { cause: err });
186
+ }
187
+ }
188
+ else {
189
+ // Limited mode: DB unavailable, create minimal components
190
+ // Clone the already-loaded config to avoid mutating shared state, then apply limited mode overrides
191
+ pluginConfig = cloneConfig(pluginConfig);
192
+ // Apply limited-mode config overrides BEFORE creating any components
193
+ pluginConfig.disabled_hooks = Array.from(new Set([...(pluginConfig.disabled_hooks || []), "continuation-enforcer", "tool-output-truncator", "inject-provider"]));
194
+ pluginConfig.ui.toasts.enabled = false;
195
+ // Show one-shot limited mode notification via logging (keeps toast policy consistent)
196
+ const reasonMessage = limitedModeReason ?
197
+ (limitedModeReason.code === "db_init_failed" ? "Database initialization failed. Check native deps or run astro_init." :
198
+ limitedModeReason.code === "schema_too_old" ? "Database schema too old and migration disabled." :
199
+ limitedModeReason.code === "schema_downgrade" ? "Database schema newer than plugin (downgrade detected)." :
200
+ limitedModeReason.code === "schema_migration_failed" ? "Database schema migration failed." :
201
+ "Database unavailable.") :
202
+ "Database unavailable.";
203
+ warn(`Astrocode: Limited Mode - ${reasonMessage}`, { dbPath: paths.dbPath });
204
+ // Create limited functionality (no global toast manager since toasts are disabled)
205
+ try {
206
+ configHandler = createConfigHandler({ pluginConfig });
207
+ }
208
+ catch (e) {
209
+ warn("Limited Mode: config handler unavailable", { err: e });
210
+ configHandler = async (config) => {
211
+ applyMinimalConfigDefaults(config);
212
+ };
213
+ }
50
214
  continuation = null;
51
215
  truncatorHook = null;
52
216
  injectProvider = null;
53
- toasts = null;
217
+ toastManager = null; // No global toast manager in limited mode
54
218
  }
219
+ // Helper for hook enablement checks (config is now finalized)
220
+ const hookEnabled = (key) => !pluginConfig.disabled_hooks?.includes(key);
221
+ // Create tools once with final state (limited mode gets DB-safe tool implementations)
222
+ const tools = createAstroTools({ ctx, config: pluginConfig, agents, runtime });
55
223
  return {
56
- name: "Astrocode",
224
+ name: "astrocode-harness",
57
225
  // Register agents
58
226
  agents,
59
227
  // Merge agents + slash commands into system config
60
228
  config: configHandler,
61
229
  // Register tools
62
- tool: createAstroTools({ ctx, config: pluginConfig, db, agents }),
230
+ tool: tools,
63
231
  // Limit created subagents from spawning more subagents (OMO-style).
64
232
  "tool.execute.before": async (input, output) => {
65
233
  if (!pluginConfig.permissions.enforce_task_tool_restrictions)
66
234
  return;
67
- if (input.tool !== "task")
235
+ if (input?.tool !== "task")
68
236
  return;
69
- output.args = output.args ?? {};
70
- const toolsMap = { ...(output.args.tools ?? {}) };
237
+ const baseArgs = (output && typeof output === "object" ? output.args : undefined) ?? {};
238
+ const baseTools = (baseArgs && typeof baseArgs === "object" && baseArgs.tools && typeof baseArgs.tools === "object")
239
+ ? baseArgs.tools
240
+ : {};
241
+ const nextTools = { ...baseTools };
71
242
  if (pluginConfig.permissions.deny_delegate_task_in_subagents) {
72
- toolsMap.delegate_task = false;
243
+ nextTools.delegate_task = false;
244
+ }
245
+ const nextArgs = { ...baseArgs, tools: nextTools };
246
+ // Back-compat: mutate if possible
247
+ if (output && typeof output === "object" && !Object.isFrozen(output)) {
248
+ output.args = nextArgs;
73
249
  }
74
- output.args.tools = toolsMap;
250
+ // Forward-compat: return conservative merge
251
+ if (output && typeof output === "object") {
252
+ return { ...output, args: nextArgs };
253
+ }
254
+ return { args: nextArgs };
75
255
  },
76
256
  "tool.execute.after": async (input, output) => {
77
257
  // Truncate huge tool outputs to artifacts
78
- if (truncatorHook && !pluginConfig.disabled_hooks.includes("tool-output-truncator")) {
79
- await truncatorHook(input, output);
258
+ if (truncatorHook && hookEnabled("tool-output-truncator")) {
259
+ await truncatorHook(input, output ?? null);
80
260
  }
81
261
  // Schedule continuation (do not immediately spam)
82
- if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
262
+ if (continuation && hookEnabled("continuation-enforcer")) {
83
263
  await continuation.onToolAfter(input);
84
264
  }
265
+ return output;
85
266
  },
86
267
  "chat.message": async (input, output) => {
87
- if (injectProvider && !pluginConfig.disabled_hooks.includes("inject-provider")) {
268
+ if (injectProvider && hookEnabled("inject-provider")) {
88
269
  await injectProvider.onChatMessage(input);
89
270
  }
90
- if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
271
+ if (continuation && hookEnabled("continuation-enforcer")) {
91
272
  await continuation.onChatMessage(input);
92
273
  }
93
274
  return output;
94
275
  },
95
276
  event: async (input) => {
96
- if (continuation && !pluginConfig.disabled_hooks.includes("continuation-enforcer")) {
277
+ if (continuation && hookEnabled("continuation-enforcer")) {
97
278
  await continuation.onEvent(input);
98
279
  }
99
280
  },
100
281
  // Best-effort cleanup
101
282
  close: async () => {
102
- try {
103
- db.close();
104
- }
105
- catch {
106
- // ignore
283
+ if (db && typeof db.close === "function") {
284
+ try {
285
+ db.close();
286
+ }
287
+ catch {
288
+ // ignore
289
+ }
107
290
  }
108
- if (toasts && pluginConfig.ui.toasts.enabled) {
291
+ if (toastManager && pluginConfig.ui.toasts.enabled) {
109
292
  try {
110
- await toasts.show({ title: "Astrocode", message: "Plugin closed", variant: "info" });
293
+ await toastManager.show({ title: "Astrocode", message: "Plugin closed", variant: "info" });
111
294
  }
112
295
  catch {
113
296
  // ignore
@@ -18,9 +18,11 @@ export interface DatabaseAdapter {
18
18
  busyTimeoutMs?: number;
19
19
  }): DatabaseConnection;
20
20
  }
21
- export declare class MockDatabaseAdapter implements DatabaseAdapter {
21
+ export declare class NullAdapter implements DatabaseAdapter {
22
22
  isAvailable(): boolean;
23
- open(): DatabaseConnection;
23
+ open(path: string, opts?: {
24
+ busyTimeoutMs?: number;
25
+ }): DatabaseConnection;
24
26
  }
25
27
  export declare class BunSqliteAdapter implements DatabaseAdapter {
26
28
  isAvailable(): boolean;
@@ -1,21 +1,10 @@
1
- import { warn } from "../../shared/log";
2
- // Mock adapter for when no database is available
3
- export class MockDatabaseAdapter {
1
+ // Null adapter for when no database driver is available
2
+ export class NullAdapter {
4
3
  isAvailable() {
5
- return true; // Mock is always available
4
+ return false;
6
5
  }
7
- open() {
8
- warn("Using mock database - no persistence available");
9
- return {
10
- pragma: () => { },
11
- exec: () => { },
12
- prepare: () => ({
13
- run: () => ({ changes: 0, lastInsertRowid: null }),
14
- get: () => null,
15
- all: () => []
16
- }),
17
- close: () => { }
18
- };
6
+ open(path, opts) {
7
+ throw new Error("No SQLite driver available");
19
8
  }
20
9
  }
21
10
  // Bun SQLite adapter - singleton pattern to avoid multiple initializations
@@ -92,7 +81,7 @@ export class BetterSqliteAdapter {
92
81
  db.pragma(`busy_timeout = ${opts.busyTimeoutMs}`);
93
82
  }
94
83
  return {
95
- pragma: (sql) => db.pragma(sql.replace('PRAGMA ', '')),
84
+ pragma: (sql) => db.pragma(sql),
96
85
  exec: (sql) => db.exec(sql),
97
86
  prepare: (sql) => db.prepare(sql),
98
87
  close: () => db.close()
@@ -103,17 +92,24 @@ export class BetterSqliteAdapter {
103
92
  export function createDatabaseAdapter() {
104
93
  const isBun = typeof globalThis.Bun !== 'undefined';
105
94
  if (isBun) {
106
- const adapter = new BunSqliteAdapter();
107
- if (adapter.isAvailable()) {
108
- return adapter;
109
- }
110
- throw new Error("Bun runtime detected but bun:sqlite not available");
95
+ // Prefer bun:sqlite in Bun runtime
96
+ const bunAdapter = new BunSqliteAdapter();
97
+ if (bunAdapter.isAvailable())
98
+ return bunAdapter;
99
+ // Fallback to better-sqlite3
100
+ const betterAdapter = new BetterSqliteAdapter();
101
+ if (betterAdapter.isAvailable())
102
+ return betterAdapter;
111
103
  }
112
104
  else {
113
- const adapter = new BetterSqliteAdapter();
114
- if (adapter.isAvailable()) {
115
- return adapter;
116
- }
117
- throw new Error("Node.js runtime detected but better-sqlite3 not available");
105
+ // Prefer better-sqlite3 in Node.js
106
+ const betterAdapter = new BetterSqliteAdapter();
107
+ if (betterAdapter.isAvailable())
108
+ return betterAdapter;
109
+ // Fallback to bun:sqlite (rare but possible)
110
+ const bunAdapter = new BunSqliteAdapter();
111
+ if (bunAdapter.isAvailable())
112
+ return bunAdapter;
118
113
  }
114
+ return new NullAdapter();
119
115
  }
@@ -3,15 +3,13 @@ export type SqliteDb = DatabaseConnection;
3
3
  export declare function openSqlite(dbPath: string, opts?: {
4
4
  busyTimeoutMs?: number;
5
5
  }): SqliteDb;
6
- export declare function configurePragmas(db: SqliteDb, pragmas: {
7
- journal_mode?: "WAL" | "DELETE";
8
- synchronous?: "NORMAL" | "FULL" | "OFF";
9
- foreign_keys?: boolean;
10
- temp_store?: "DEFAULT" | "MEMORY" | "FILE";
11
- }): void;
6
+ export declare function configurePragmas(db: SqliteDb, pragmas: Record<string, any>): void;
12
7
  /** BEGIN IMMEDIATE transaction helper. */
13
- export declare function withTx<T>(db: SqliteDb, fn: () => T): T;
8
+ export declare function withTx<T>(db: SqliteDb, fn: () => T, opts?: {
9
+ require?: boolean;
10
+ }): T;
11
+ export declare function getSchemaVersion(db: SqliteDb): number;
14
12
  export declare function ensureSchema(db: SqliteDb, opts?: {
15
13
  allowAutoMigrate?: boolean;
16
- failOnDowngrade?: boolean;
14
+ silent?: boolean;
17
15
  }): void;
package/dist/state/db.js CHANGED
@@ -1,21 +1,42 @@
1
+ // src/state/db.ts
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { SCHEMA_SQL, SCHEMA_VERSION } from "./schema";
4
5
  import { nowISO } from "../shared/time";
5
- import { info } from "../shared/log";
6
+ import { info, warn } from "../shared/log";
6
7
  import { createDatabaseAdapter } from "./adapters";
7
8
  /** Ensure directory exists for a file path. */
8
9
  function ensureParentDir(filePath) {
9
10
  const dir = path.dirname(filePath);
10
11
  fs.mkdirSync(dir, { recursive: true });
11
12
  }
13
+ function tableExists(db, tableName) {
14
+ try {
15
+ const row = db
16
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
17
+ .get(tableName);
18
+ return row?.name === tableName;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
12
24
  export function openSqlite(dbPath, opts) {
13
25
  ensureParentDir(dbPath);
14
26
  const adapter = createDatabaseAdapter();
15
- const db = adapter.open(dbPath, opts);
16
- return db;
27
+ if (!adapter.isAvailable()) {
28
+ throw new Error("No SQLite driver available (adapter unavailable).");
29
+ }
30
+ return adapter.open(dbPath, opts);
17
31
  }
18
32
  export function configurePragmas(db, pragmas) {
33
+ if (!pragmas || typeof pragmas !== "object")
34
+ return;
35
+ const known = new Set(["journal_mode", "synchronous", "foreign_keys", "temp_store"]);
36
+ for (const k of Object.keys(pragmas)) {
37
+ if (!known.has(k))
38
+ warn(`[Astrocode] Unknown pragma ignored: ${k}`);
39
+ }
19
40
  if (pragmas.journal_mode)
20
41
  db.pragma(`journal_mode = ${pragmas.journal_mode}`);
21
42
  if (pragmas.synchronous)
@@ -26,10 +47,12 @@ export function configurePragmas(db, pragmas) {
26
47
  db.pragma(`temp_store = ${pragmas.temp_store}`);
27
48
  }
28
49
  /** BEGIN IMMEDIATE transaction helper. */
29
- export function withTx(db, fn) {
50
+ export function withTx(db, fn, opts) {
30
51
  const adapter = createDatabaseAdapter();
31
- if (!adapter.isAvailable()) {
32
- // No database available, just run the function
52
+ const available = adapter.isAvailable();
53
+ if (!available) {
54
+ if (opts?.require)
55
+ throw new Error("Database adapter unavailable; transaction required.");
33
56
  return fn();
34
57
  }
35
58
  db.exec("BEGIN IMMEDIATE");
@@ -48,50 +71,88 @@ export function withTx(db, fn) {
48
71
  throw e;
49
72
  }
50
73
  }
51
- export function ensureSchema(db, opts) {
52
- const adapter = createDatabaseAdapter();
53
- if (!adapter.isAvailable()) {
54
- // Silent skip for mock adapter
55
- return;
74
+ export function getSchemaVersion(db) {
75
+ try {
76
+ const row = db
77
+ .prepare("SELECT schema_version FROM repo_state WHERE id = 1")
78
+ .get();
79
+ return row?.schema_version ?? 0;
56
80
  }
81
+ catch (e) {
82
+ const msg = e instanceof Error ? e.message : String(e);
83
+ if (msg.includes("no such table"))
84
+ return 0;
85
+ throw new Error(`Failed to read schema version: ${msg}`);
86
+ }
87
+ }
88
+ function migrateStageRunsCreatedAt(db) {
89
+ // v2 requires stage_runs.created_at to be meaningful; never write ''.
90
+ // This migration is best-effort and does not assume constraint rewrite support.
57
91
  try {
58
- db.exec(SCHEMA_SQL);
59
- // Migrations for existing databases
60
- // Add created_at to stage_runs if missing (introduced in schema version 2)
61
- try {
62
- const columns = db.prepare("PRAGMA table_info(stage_runs)").all();
63
- const hasCreatedAt = columns.some(col => col.name === 'created_at');
64
- if (!hasCreatedAt) {
65
- db.exec("ALTER TABLE stage_runs ADD COLUMN created_at TEXT NOT NULL DEFAULT ''");
66
- info("[Astrocode] Added created_at column to stage_runs table");
67
- }
68
- }
69
- catch (e) {
70
- // Column might already exist or table doesn't exist, ignore
71
- }
72
- const row = db.prepare("SELECT schema_version FROM repo_state WHERE id = 1").get();
73
- if (!row) {
74
- const now = nowISO();
75
- db.prepare("INSERT INTO repo_state (id, schema_version, created_at, updated_at) VALUES (1, ?, ?, ?)").run(SCHEMA_VERSION, now, now);
76
- // Initialize story key seq
77
- db.prepare("INSERT OR IGNORE INTO story_keyseq (id, next_story_num) VALUES (1, 1)").run();
92
+ const cols = db.prepare("PRAGMA table_info(stage_runs)").all();
93
+ const hasCreatedAt = cols.some((c) => c.name === "created_at");
94
+ if (hasCreatedAt)
78
95
  return;
79
- }
80
- const currentVersion = row.schema_version ?? 0;
81
- if (currentVersion === SCHEMA_VERSION)
82
- return;
83
- if (currentVersion > SCHEMA_VERSION && (opts?.failOnDowngrade ?? true)) {
84
- throw new Error(`Astrocode DB schema_version ${currentVersion} is newer than this plugin (${SCHEMA_VERSION}). Refusing to downgrade.`);
85
- }
86
- if (currentVersion < SCHEMA_VERSION) {
87
- if (!(opts?.allowAutoMigrate ?? true)) {
88
- throw new Error(`Astrocode DB schema_version ${currentVersion} is older than required (${SCHEMA_VERSION}). Auto-migrate disabled.`);
96
+ // Add nullable column first (avoid poison defaults).
97
+ db.exec("ALTER TABLE stage_runs ADD COLUMN created_at TEXT");
98
+ const now = nowISO();
99
+ // Backfill using best available timestamp fields, falling back to now.
100
+ db.prepare(`
101
+ UPDATE stage_runs
102
+ SET created_at =
103
+ COALESCE(
104
+ NULLIF(created_at, ''),
105
+ NULLIF(updated_at, ''),
106
+ NULLIF(started_at, ''),
107
+ ?
108
+ )
109
+ WHERE created_at IS NULL OR created_at = ''
110
+ `).run(now);
111
+ info("[Astrocode] Added created_at column to stage_runs table (backfilled)");
112
+ }
113
+ catch (e) {
114
+ // Ignore: table may not exist yet, or migration already applied.
115
+ }
116
+ }
117
+ export function ensureSchema(db, opts) {
118
+ try {
119
+ return withTx(db, () => {
120
+ db.exec(SCHEMA_SQL);
121
+ // Deterministic assertions: if these fail, bootstrap is broken.
122
+ if (!tableExists(db, "repo_state"))
123
+ throw new Error("Schema missing required table repo_state after SCHEMA_SQL");
124
+ if (!tableExists(db, "story_keyseq"))
125
+ throw new Error("Schema missing required table story_keyseq after SCHEMA_SQL");
126
+ // Ensure required singleton rows exist.
127
+ db.prepare("INSERT OR IGNORE INTO story_keyseq (id, next_story_num) VALUES (1, 1)").run();
128
+ // Migrations for existing DBs.
129
+ migrateStageRunsCreatedAt(db);
130
+ const row = db
131
+ .prepare("SELECT schema_version FROM repo_state WHERE id = 1")
132
+ .get();
133
+ if (!row) {
134
+ const now = nowISO();
135
+ db.prepare("INSERT INTO repo_state (id, schema_version, created_at, updated_at) VALUES (1, ?, ?, ?)").run(SCHEMA_VERSION, now, now);
136
+ return;
89
137
  }
90
- // Additive schema: SCHEMA_SQL already created new tables/indexes if missing.
91
- db.prepare("UPDATE repo_state SET schema_version = ?, updated_at = ? WHERE id = 1").run(SCHEMA_VERSION, nowISO());
92
- }
138
+ const currentVersion = row.schema_version ?? 0;
139
+ if (currentVersion === SCHEMA_VERSION)
140
+ return;
141
+ if (currentVersion > SCHEMA_VERSION) {
142
+ // Newer schema - no action (policy enforcement elsewhere).
143
+ return;
144
+ }
145
+ if (currentVersion < SCHEMA_VERSION) {
146
+ if (opts?.allowAutoMigrate ?? true) {
147
+ db.prepare("UPDATE repo_state SET schema_version = ?, updated_at = ? WHERE id = 1").run(SCHEMA_VERSION, nowISO());
148
+ }
149
+ }
150
+ }, { require: true });
93
151
  }
94
152
  catch (e) {
95
- // Schema operations might fail on mock adapter, silently ignore
153
+ if (opts?.silent)
154
+ return;
155
+ const msg = e instanceof Error ? e.message : String(e);
156
+ throw new Error(`Schema initialization failed: ${msg}`);
96
157
  }
97
158
  }