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.
- package/README.md +243 -11
- package/dist/agents/prompts.d.ts +1 -0
- package/dist/agents/prompts.js +159 -0
- package/dist/agents/registry.js +11 -1
- package/dist/config/loader.js +34 -0
- package/dist/config/schema.d.ts +7 -1
- package/dist/config/schema.js +2 -0
- package/dist/hooks/continuation-enforcer.d.ts +9 -1
- package/dist/hooks/continuation-enforcer.js +2 -1
- package/dist/hooks/inject-provider.d.ts +9 -1
- package/dist/hooks/inject-provider.js +2 -1
- package/dist/hooks/tool-output-truncator.d.ts +9 -1
- package/dist/hooks/tool-output-truncator.js +2 -1
- package/dist/index.js +228 -45
- package/dist/state/adapters/index.d.ts +4 -2
- package/dist/state/adapters/index.js +23 -27
- package/dist/state/db.d.ts +6 -8
- package/dist/state/db.js +106 -45
- package/dist/tools/index.d.ts +13 -3
- package/dist/tools/index.js +14 -31
- package/dist/tools/init.d.ts +10 -1
- package/dist/tools/init.js +73 -18
- package/dist/tools/injects.js +90 -26
- package/dist/tools/spec.d.ts +0 -1
- package/dist/tools/spec.js +4 -1
- package/dist/tools/status.d.ts +1 -1
- package/dist/tools/status.js +70 -52
- package/dist/tools/workflow.js +2 -2
- package/dist/ui/inject.d.ts +16 -2
- package/dist/ui/inject.js +104 -33
- package/dist/workflow/directives.d.ts +2 -0
- package/dist/workflow/directives.js +34 -19
- package/dist/workflow/state-machine.d.ts +46 -3
- package/dist/workflow/state-machine.js +249 -92
- package/package.json +1 -1
- package/src/agents/prompts.ts +160 -0
- package/src/agents/registry.ts +16 -1
- package/src/config/loader.ts +39 -4
- package/src/config/schema.ts +3 -0
- package/src/hooks/continuation-enforcer.ts +9 -2
- package/src/hooks/inject-provider.ts +9 -2
- package/src/hooks/tool-output-truncator.ts +9 -2
- package/src/index.ts +260 -56
- package/src/state/adapters/index.ts +21 -26
- package/src/state/db.ts +114 -58
- package/src/tools/index.ts +29 -31
- package/src/tools/init.ts +91 -22
- package/src/tools/injects.ts +147 -53
- package/src/tools/spec.ts +6 -2
- package/src/tools/status.ts +71 -55
- package/src/tools/workflow.ts +3 -3
- package/src/ui/inject.ts +115 -41
- package/src/workflow/directives.ts +103 -75
- 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,
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
22
|
-
let configHandler =
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
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
|
|
235
|
+
if (input?.tool !== "task")
|
|
68
236
|
return;
|
|
69
|
-
|
|
70
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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 &&
|
|
268
|
+
if (injectProvider && hookEnabled("inject-provider")) {
|
|
88
269
|
await injectProvider.onChatMessage(input);
|
|
89
270
|
}
|
|
90
|
-
if (continuation &&
|
|
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 &&
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
283
|
+
if (db && typeof db.close === "function") {
|
|
284
|
+
try {
|
|
285
|
+
db.close();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// ignore
|
|
289
|
+
}
|
|
107
290
|
}
|
|
108
|
-
if (
|
|
291
|
+
if (toastManager && pluginConfig.ui.toasts.enabled) {
|
|
109
292
|
try {
|
|
110
|
-
await
|
|
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
|
|
21
|
+
export declare class NullAdapter implements DatabaseAdapter {
|
|
22
22
|
isAvailable(): boolean;
|
|
23
|
-
open(
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
export class MockDatabaseAdapter {
|
|
1
|
+
// Null adapter for when no database driver is available
|
|
2
|
+
export class NullAdapter {
|
|
4
3
|
isAvailable() {
|
|
5
|
-
return
|
|
4
|
+
return false;
|
|
6
5
|
}
|
|
7
|
-
open() {
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
}
|
package/dist/state/db.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
}
|