astrocode-workflow 0.4.0 → 0.4.1

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 (147) hide show
  1. package/dist/index.js +6 -0
  2. package/dist/shared/metrics.d.ts +66 -0
  3. package/dist/shared/metrics.js +112 -0
  4. package/dist/src/agents/commands.d.ts +9 -0
  5. package/dist/src/agents/commands.js +121 -0
  6. package/dist/src/agents/prompts.d.ts +3 -0
  7. package/dist/src/agents/prompts.js +232 -0
  8. package/dist/src/agents/registry.d.ts +6 -0
  9. package/dist/src/agents/registry.js +242 -0
  10. package/dist/src/agents/types.d.ts +14 -0
  11. package/dist/src/agents/types.js +8 -0
  12. package/dist/src/astro/workflow-runner.d.ts +15 -0
  13. package/dist/src/astro/workflow-runner.js +25 -0
  14. package/dist/src/config/config-handler.d.ts +4 -0
  15. package/dist/src/config/config-handler.js +46 -0
  16. package/dist/src/config/defaults.d.ts +3 -0
  17. package/dist/src/config/defaults.js +3 -0
  18. package/dist/src/config/loader.d.ts +11 -0
  19. package/dist/src/config/loader.js +82 -0
  20. package/dist/src/config/schema.d.ts +195 -0
  21. package/dist/src/config/schema.js +224 -0
  22. package/dist/src/hooks/continuation-enforcer.d.ts +34 -0
  23. package/dist/src/hooks/continuation-enforcer.js +190 -0
  24. package/dist/src/hooks/inject-provider.d.ts +27 -0
  25. package/dist/src/hooks/inject-provider.js +189 -0
  26. package/dist/src/hooks/tool-output-truncator.d.ts +25 -0
  27. package/dist/src/hooks/tool-output-truncator.js +57 -0
  28. package/dist/src/index.d.ts +3 -0
  29. package/dist/src/index.js +313 -0
  30. package/dist/src/shared/deep-merge.d.ts +8 -0
  31. package/dist/src/shared/deep-merge.js +25 -0
  32. package/dist/src/shared/hash.d.ts +1 -0
  33. package/dist/src/shared/hash.js +4 -0
  34. package/dist/src/shared/log.d.ts +7 -0
  35. package/dist/src/shared/log.js +24 -0
  36. package/dist/src/shared/metrics.d.ts +66 -0
  37. package/dist/src/shared/metrics.js +112 -0
  38. package/dist/src/shared/model-tuning.d.ts +9 -0
  39. package/dist/src/shared/model-tuning.js +28 -0
  40. package/dist/src/shared/paths.d.ts +19 -0
  41. package/dist/src/shared/paths.js +64 -0
  42. package/dist/src/shared/text.d.ts +4 -0
  43. package/dist/src/shared/text.js +19 -0
  44. package/dist/src/shared/time.d.ts +1 -0
  45. package/dist/src/shared/time.js +3 -0
  46. package/dist/src/state/adapters/index.d.ts +41 -0
  47. package/dist/src/state/adapters/index.js +115 -0
  48. package/dist/src/state/db.d.ts +16 -0
  49. package/dist/src/state/db.js +225 -0
  50. package/dist/src/state/ids.d.ts +8 -0
  51. package/dist/src/state/ids.js +25 -0
  52. package/dist/src/state/repo-lock.d.ts +67 -0
  53. package/dist/src/state/repo-lock.js +580 -0
  54. package/dist/src/state/schema.d.ts +2 -0
  55. package/dist/src/state/schema.js +258 -0
  56. package/dist/src/state/types.d.ts +71 -0
  57. package/dist/src/state/types.js +1 -0
  58. package/dist/src/state/workflow-repo-lock.d.ts +23 -0
  59. package/dist/src/state/workflow-repo-lock.js +83 -0
  60. package/dist/src/tools/artifacts.d.ts +18 -0
  61. package/dist/src/tools/artifacts.js +71 -0
  62. package/dist/src/tools/health.d.ts +8 -0
  63. package/dist/src/tools/health.js +119 -0
  64. package/dist/src/tools/index.d.ts +20 -0
  65. package/dist/src/tools/index.js +97 -0
  66. package/dist/src/tools/init.d.ts +17 -0
  67. package/dist/src/tools/init.js +96 -0
  68. package/dist/src/tools/injects.d.ts +53 -0
  69. package/dist/src/tools/injects.js +325 -0
  70. package/dist/src/tools/lock.d.ts +4 -0
  71. package/dist/src/tools/lock.js +78 -0
  72. package/dist/src/tools/metrics.d.ts +7 -0
  73. package/dist/src/tools/metrics.js +61 -0
  74. package/dist/src/tools/repair.d.ts +8 -0
  75. package/dist/src/tools/repair.js +59 -0
  76. package/dist/src/tools/reset.d.ts +8 -0
  77. package/dist/src/tools/reset.js +92 -0
  78. package/dist/src/tools/run.d.ts +13 -0
  79. package/dist/src/tools/run.js +54 -0
  80. package/dist/src/tools/spec.d.ts +12 -0
  81. package/dist/src/tools/spec.js +44 -0
  82. package/dist/src/tools/stage.d.ts +23 -0
  83. package/dist/src/tools/stage.js +371 -0
  84. package/dist/src/tools/status.d.ts +8 -0
  85. package/dist/src/tools/status.js +125 -0
  86. package/dist/src/tools/story.d.ts +23 -0
  87. package/dist/src/tools/story.js +85 -0
  88. package/dist/src/tools/workflow.d.ts +13 -0
  89. package/dist/src/tools/workflow.js +359 -0
  90. package/dist/src/ui/inject.d.ts +12 -0
  91. package/dist/src/ui/inject.js +107 -0
  92. package/dist/src/ui/toasts.d.ts +13 -0
  93. package/dist/src/ui/toasts.js +39 -0
  94. package/dist/src/workflow/artifacts.d.ts +24 -0
  95. package/dist/src/workflow/artifacts.js +45 -0
  96. package/dist/src/workflow/baton.d.ts +72 -0
  97. package/dist/src/workflow/baton.js +166 -0
  98. package/dist/src/workflow/context.d.ts +20 -0
  99. package/dist/src/workflow/context.js +113 -0
  100. package/dist/src/workflow/directives.d.ts +39 -0
  101. package/dist/src/workflow/directives.js +137 -0
  102. package/dist/src/workflow/repair.d.ts +8 -0
  103. package/dist/src/workflow/repair.js +99 -0
  104. package/dist/src/workflow/state-machine.d.ts +86 -0
  105. package/dist/src/workflow/state-machine.js +216 -0
  106. package/dist/src/workflow/story-helpers.d.ts +9 -0
  107. package/dist/src/workflow/story-helpers.js +13 -0
  108. package/dist/state/db.d.ts +1 -0
  109. package/dist/state/db.js +9 -0
  110. package/dist/state/repo-lock.d.ts +3 -0
  111. package/dist/state/repo-lock.js +29 -0
  112. package/dist/test/integration/db-transactions.test.d.ts +1 -0
  113. package/dist/test/integration/db-transactions.test.js +126 -0
  114. package/dist/test/integration/injection-metrics.test.d.ts +1 -0
  115. package/dist/test/integration/injection-metrics.test.js +129 -0
  116. package/dist/tools/health.d.ts +8 -0
  117. package/dist/tools/health.js +119 -0
  118. package/dist/tools/index.js +9 -0
  119. package/dist/tools/metrics.d.ts +7 -0
  120. package/dist/tools/metrics.js +61 -0
  121. package/dist/tools/reset.d.ts +8 -0
  122. package/dist/tools/reset.js +92 -0
  123. package/dist/tools/workflow.js +178 -168
  124. package/dist/ui/inject.js +21 -9
  125. package/package.json +6 -4
  126. package/src/astro/workflow-runner.ts +36 -0
  127. package/src/config/schema.ts +1 -0
  128. package/src/hooks/inject-provider.ts +94 -14
  129. package/src/index.ts +14 -0
  130. package/src/shared/metrics.ts +148 -0
  131. package/src/state/db.ts +10 -1
  132. package/src/state/repo-lock.ts +706 -0
  133. package/src/state/schema.ts +8 -1
  134. package/src/state/workflow-repo-lock.ts +111 -0
  135. package/src/tools/health.ts +128 -0
  136. package/src/tools/index.ts +15 -3
  137. package/src/tools/init.ts +7 -6
  138. package/src/tools/lock.ts +75 -0
  139. package/src/tools/metrics.ts +71 -0
  140. package/src/tools/repair.ts +44 -6
  141. package/src/tools/reset.ts +100 -0
  142. package/src/tools/stage.ts +1 -0
  143. package/src/tools/status.ts +2 -1
  144. package/src/tools/story.ts +1 -0
  145. package/src/tools/workflow.ts +19 -1
  146. package/src/ui/inject.ts +21 -9
  147. package/src/workflow/repair.ts +2 -2
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // Source of truth: SQLite file at .astro/astro.db
6
6
 
7
- export const SCHEMA_VERSION = 2;
7
+ export const SCHEMA_VERSION = 3; // v3: Added advisory lock support + database constraints
8
8
 
9
9
  export const SCHEMA_SQL = `
10
10
  PRAGMA foreign_keys = ON;
@@ -235,6 +235,13 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
235
235
  CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
236
236
  CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
237
237
  CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
238
+
239
+ -- CONSTRAINT: Only one running run at a time (partial unique index)
240
+ -- This provides database-level safety when using advisory locks
241
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_single_running_run ON runs(status) WHERE status = 'running';
242
+
243
+ -- CONSTRAINT: Only one run can lock a story at a time (partial unique index)
244
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_single_story_lock ON stories(in_progress) WHERE in_progress = 1;
238
245
  CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
239
246
  CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
240
247
  CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
@@ -0,0 +1,111 @@
1
+ // src/state/workflow-repo-lock.ts
2
+ import type { acquireRepoLock } from "./repo-lock";
3
+
4
+ type RepoLockAcquire = typeof acquireRepoLock;
5
+
6
+ type Held = {
7
+ release: () => void;
8
+ depth: number;
9
+ };
10
+
11
+ const HELD_BY_KEY = new Map<string, Held>();
12
+
13
+ function key(lockPath: string, sessionId?: string) {
14
+ return `${lockPath}::${sessionId ?? ""}`;
15
+ }
16
+
17
+ /**
18
+ * Acquire ONCE per workflow/session in this process.
19
+ * Nested calls reuse the same held lock (no reacquire, no churn).
20
+ *
21
+ * ADVISORY LOCK MODE:
22
+ * - Creates lock file to signal other sessions
23
+ * - If lock held by another session: WARN and proceed anyway
24
+ * - Database constraints provide actual safety (single running run)
25
+ * - Better UX: no blocking, just helpful warnings
26
+ */
27
+ export async function workflowRepoLock<T>(
28
+ deps: { acquireRepoLock: RepoLockAcquire },
29
+ opts: {
30
+ lockPath: string;
31
+ repoRoot: string;
32
+ sessionId?: string;
33
+ owner?: string;
34
+ fn: () => Promise<T>;
35
+ advisory?: boolean; // If true, warn instead of error on contention
36
+ }
37
+ ): Promise<T> {
38
+ const k = key(opts.lockPath, opts.sessionId);
39
+ const existing = HELD_BY_KEY.get(k);
40
+
41
+ if (existing) {
42
+ existing.depth += 1;
43
+ try {
44
+ return await opts.fn();
45
+ } finally {
46
+ existing.depth -= 1;
47
+ if (existing.depth <= 0) {
48
+ HELD_BY_KEY.delete(k);
49
+ existing.release();
50
+ }
51
+ }
52
+ }
53
+
54
+ // IMPORTANT: this is tuned for "hold for whole workflow".
55
+ let handle: { release: () => void } | null = null;
56
+
57
+ try {
58
+ handle = await deps.acquireRepoLock({
59
+ lockPath: opts.lockPath,
60
+ repoRoot: opts.repoRoot,
61
+ sessionId: opts.sessionId,
62
+ owner: opts.owner,
63
+
64
+ retryMs: opts.advisory ? 1000 : 30_000, // Advisory: fail fast, hard: wait longer
65
+ staleMs: 30_000, // Reduced from 2 minutes to 30 seconds for faster stale lock recovery
66
+ heartbeatMs: 200,
67
+ minWriteMs: 800,
68
+ pollMs: 20,
69
+ pollMaxMs: 250,
70
+ });
71
+ } catch (err: any) {
72
+ // Lock acquisition failed - check if advisory mode
73
+ if (opts.advisory) {
74
+ // Advisory mode: warn and proceed without lock
75
+ // eslint-disable-next-line no-console
76
+ console.warn(`âš ī¸ [Astrocode] Another session may be active. Proceeding anyway (advisory lock mode).`);
77
+ // eslint-disable-next-line no-console
78
+ console.warn(` ${err.message}`);
79
+
80
+ // Proceed without lock - database constraints will ensure safety
81
+ try {
82
+ return await opts.fn();
83
+ } catch (dbErr: any) {
84
+ // Check if this is a concurrency error
85
+ if (dbErr.message?.includes('UNIQUE constraint') || dbErr.message?.includes('SQLITE_BUSY')) {
86
+ throw new Error(
87
+ `Another session is actively working on this story. Database prevented concurrent modification. ` +
88
+ `Please wait for the other session to complete, or work on a different story.`
89
+ );
90
+ }
91
+ throw dbErr;
92
+ }
93
+ }
94
+
95
+ // Hard lock mode: propagate error
96
+ throw err;
97
+ }
98
+
99
+ const held: Held = { release: handle.release, depth: 1 };
100
+ HELD_BY_KEY.set(k, held);
101
+
102
+ try {
103
+ return await opts.fn();
104
+ } finally {
105
+ held.depth -= 1;
106
+ if (held.depth <= 0) {
107
+ HELD_BY_KEY.delete(k);
108
+ held.release();
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,128 @@
1
+ // src/tools/health.ts
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import type { AstrocodeConfig } from "../config/schema";
4
+ import type { SqliteDb } from "../state/db";
5
+ import { getSchemaVersion } from "../state/db";
6
+ import { getActiveRun } from "../workflow/state-machine";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ export function createAstroHealthTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
11
+ const { ctx, config, db } = opts;
12
+
13
+ return tool({
14
+ description: "Check Astrocode health: DB status, locks, schema, active runs, recent events.",
15
+ args: {},
16
+ execute: async () => {
17
+ const lines: string[] = [];
18
+ const repoRoot = (ctx as any).directory || process.cwd();
19
+ const dbPath = config.db?.path || ".astro/astro.db";
20
+ const fullDbPath = path.resolve(repoRoot, dbPath);
21
+
22
+ // System info
23
+ lines.push("# Astrocode Health Check");
24
+ lines.push(`- PID: ${(process as any).pid || "unknown"}`);
25
+ lines.push(`- Repo: ${repoRoot}`);
26
+ lines.push(`- DB Path: ${fullDbPath}`);
27
+
28
+ // Lock status
29
+ const lockPath = `${repoRoot}/.astro/astro.lock`;
30
+ try {
31
+ if (fs.existsSync(lockPath)) {
32
+ const lockContent = fs.readFileSync(lockPath, "utf8").trim();
33
+ const parts = lockContent.split(" ");
34
+ if (parts.length >= 2) {
35
+ const pid = parseInt(parts[0]);
36
+ const startedAt = parts[1];
37
+
38
+ // Check if PID is still running
39
+ try {
40
+ (process as any).kill(pid, 0); // Signal 0 just checks if process exists
41
+ lines.push(`- Lock: HELD by PID ${pid} (started ${startedAt})`);
42
+ } catch {
43
+ lines.push(`- Lock: STALE (PID ${pid} not running, started ${startedAt})`);
44
+ lines.push(` → Run: rm "${lockPath}"`);
45
+ }
46
+ } else {
47
+ lines.push(`- Lock: MALFORMED (${lockContent})`);
48
+ }
49
+ } else {
50
+ lines.push(`- Lock: NONE (no lock file)`);
51
+ }
52
+ } catch (e) {
53
+ lines.push(`- Lock: ERROR (${String(e)})`);
54
+ }
55
+
56
+ // DB file status
57
+ const dbExists = fs.existsSync(fullDbPath);
58
+ const walExists = fs.existsSync(`${fullDbPath}-wal`);
59
+ const shmExists = fs.existsSync(`${fullDbPath}-shm`);
60
+
61
+ lines.push(`- DB Files:`);
62
+ lines.push(` - Main: ${dbExists ? "EXISTS" : "MISSING"}`);
63
+ lines.push(` - WAL: ${walExists ? "EXISTS" : "MISSING"}`);
64
+ lines.push(` - SHM: ${shmExists ? "EXISTS" : "MISSING"}`);
65
+
66
+ if (!dbExists) {
67
+ lines.push(`- STATUS: DB MISSING - run astro_init first`);
68
+ return lines.join("\n");
69
+ }
70
+
71
+ // Schema version
72
+ try {
73
+ const schemaVersion = getSchemaVersion(db);
74
+ lines.push(`- Schema Version: ${schemaVersion}`);
75
+ } catch (e) {
76
+ lines.push(`- Schema Version: ERROR (${String(e)})`);
77
+ lines.push(`- STATUS: DB CORRUPTED`);
78
+ return lines.join("\n");
79
+ }
80
+
81
+ // Active run
82
+ try {
83
+ const activeRun = getActiveRun(db);
84
+ if (activeRun) {
85
+ lines.push(`- Active Run: ${activeRun.run_id} (${activeRun.status})`);
86
+ lines.push(` - Story: ${activeRun.story_key}`);
87
+ lines.push(` - Stage: ${activeRun.current_stage_key || "none"}`);
88
+ lines.push(` - Started: ${activeRun.started_at}`);
89
+ } else {
90
+ lines.push(`- Active Run: NONE`);
91
+ }
92
+ } catch (e) {
93
+ lines.push(`- Active Run: ERROR (${String(e)})`);
94
+ }
95
+
96
+ // Recent events
97
+ try {
98
+ const events = db.prepare(`
99
+ SELECT event_id, run_id, stage_key, type, created_at
100
+ FROM events
101
+ ORDER BY created_at DESC
102
+ LIMIT 10
103
+ `).all() as any[];
104
+
105
+ lines.push(`- Recent Events (${events.length}):`);
106
+ for (const event of events) {
107
+ const stage = event.stage_key ? `/${event.stage_key}` : "";
108
+ lines.push(` - ${event.created_at}: ${event.type} (${event.run_id || "global"}${stage})`);
109
+ }
110
+ } catch (e) {
111
+ lines.push(`- Recent Events: ERROR (${String(e)})`);
112
+ }
113
+
114
+ // Status summary
115
+ lines.push(``);
116
+ lines.push(`## Status`);
117
+ lines.push(`✅ DB accessible`);
118
+ lines.push(`✅ Schema valid`);
119
+ lines.push(`✅ Lock file checked`);
120
+
121
+ if (walExists || shmExists) {
122
+ lines.push(`âš ī¸ WAL/SHM files present - indicates unclean shutdown or active transaction`);
123
+ }
124
+
125
+ return lines.join("\n");
126
+ },
127
+ });
128
+ }
@@ -12,6 +12,10 @@ import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroSta
12
12
  import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
13
13
  import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
14
14
  import { createAstroRepairTool } from "./repair";
15
+ import { createAstroHealthTool } from "./health";
16
+ import { createAstroResetTool } from "./reset";
17
+ import { createAstroMetricsTool } from "./metrics";
18
+ import { createAstroLockStatusTool } from "./lock";
15
19
 
16
20
  import { AgentConfig } from "@opencode-ai/sdk";
17
21
 
@@ -35,9 +39,13 @@ export function createAstroTools(opts: CreateAstroToolsOptions): Record<string,
35
39
 
36
40
  const tools: Record<string, ToolDefinition> = {};
37
41
 
38
- // Always available tools (work without database - guaranteed DB-independent)
39
- tools.astro_status = createAstroStatusTool({ ctx, config });
40
- tools.astro_spec_get = createAstroSpecGetTool({ ctx, config });
42
+ // Always available tools (work without database - guaranteed DB-independent)
43
+ tools.astro_status = createAstroStatusTool({ ctx, config });
44
+ tools.astro_spec_get = createAstroSpecGetTool({ ctx, config });
45
+ tools.astro_health = createAstroHealthTool({ ctx, config, db });
46
+ tools.astro_reset = createAstroResetTool({ ctx, config, db });
47
+ tools.astro_metrics = createAstroMetricsTool({ ctx, config });
48
+ tools.astro_lock_status = createAstroLockStatusTool({ ctx });
41
49
 
42
50
  // Recovery tool - available even in limited mode to allow DB initialization
43
51
  tools.astro_init = createAstroInitTool({ ctx, config, runtime });
@@ -100,6 +108,10 @@ export function createAstroTools(opts: CreateAstroToolsOptions): Record<string,
100
108
  ["_astro_inject_eligible", "astro_inject_eligible"],
101
109
  ["_astro_inject_debug_due", "astro_inject_debug_due"],
102
110
  ["_astro_repair", "astro_repair"],
111
+ ["_astro_health", "astro_health"],
112
+ ["_astro_reset", "astro_reset"],
113
+ ["_astro_metrics", "astro_metrics"],
114
+ ["_astro_lock_status", "astro_lock_status"],
103
115
  ];
104
116
 
105
117
  // Only add aliases for tools that exist
package/src/tools/init.ts CHANGED
@@ -8,6 +8,7 @@ import { getAstroPaths, ensureAstroDirs } from "../shared/paths";
8
8
  import { nowISO } from "../shared/time";
9
9
  import { sha256Hex } from "../shared/hash";
10
10
 
11
+
11
12
  type RuntimeState = {
12
13
  db: SqliteDb | null;
13
14
  limitedMode: boolean;
@@ -29,14 +30,14 @@ export function createAstroInitTool(opts: { ctx: any; config: AstrocodeConfig; r
29
30
  },
30
31
  execute: async ({ ensure_spec, spec_placeholder }) => {
31
32
  const repoRoot = ctx.directory as string;
32
- const paths = getAstroPaths(repoRoot, config.db.path);
33
- ensureAstroDirs(paths);
33
+ const paths = getAstroPaths(repoRoot, config.db.path);
34
+ ensureAstroDirs(paths);
34
35
 
35
- const hadDbAlready = !!runtime.db;
36
- let db: SqliteDb | null = runtime.db;
37
- let publishedToRuntime = false;
36
+ const hadDbAlready = !!runtime.db;
37
+ let db: SqliteDb | null = runtime.db;
38
+ let publishedToRuntime = false;
38
39
 
39
- try {
40
+ try {
40
41
  if (!db) {
41
42
  try {
42
43
  db = openSqlite(paths.dbPath, { busyTimeoutMs: config.db.busy_timeout_ms });
@@ -0,0 +1,75 @@
1
+ import path from "node:path";
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
4
+
5
+ export function createAstroLockStatusTool(opts: { ctx: any }): ToolDefinition {
6
+ const { ctx } = opts;
7
+
8
+ return tool({
9
+ description: "Check Astrocode lock status and attempt repair. Shows diagnostics (PID, age, session) and can remove stale/dead locks.",
10
+ args: {
11
+ attempt_repair: tool.schema.boolean().default(false).describe("If true, attempt to remove stale or dead locks"),
12
+ },
13
+ execute: async ({ attempt_repair }) => {
14
+ const repoRoot = ctx.directory as string;
15
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
16
+
17
+ const status = getLockStatus(lockPath);
18
+
19
+ if (!status.exists) {
20
+ return "✅ No lock file found. Repository is unlocked.";
21
+ }
22
+
23
+ const lines: string[] = [];
24
+ lines.push("# Astrocode Lock Status");
25
+ lines.push("");
26
+ lines.push("## Lock Details");
27
+ lines.push(`- **Path**: ${status.path}`);
28
+ lines.push(`- **PID**: ${status.pid} (${status.pidAlive ? 'đŸŸĸ ALIVE' : '🔴 DEAD'})`);
29
+ lines.push(`- **Age**: ${status.ageMs ? Math.floor(status.ageMs / 1000) : '?'}s`);
30
+ lines.push(`- **Status**: ${status.isStale ? 'âš ī¸ STALE' : '✅ FRESH'}`);
31
+ if (status.sessionId) lines.push(`- **Session**: ${status.sessionId}`);
32
+ if (status.owner) lines.push(`- **Owner**: ${status.owner}`);
33
+ if (status.instanceId) lines.push(`- **Instance**: ${status.instanceId.substring(0, 8)}...`);
34
+ if (status.leaseId) lines.push(`- **Lease**: ${status.leaseId.substring(0, 8)}...`);
35
+ if (status.createdAt) lines.push(`- **Created**: ${status.createdAt}`);
36
+ if (status.updatedAt) lines.push(`- **Updated**: ${status.updatedAt}`);
37
+ if (status.repoRoot) lines.push(`- **Repo**: ${status.repoRoot}`);
38
+ lines.push(`- **Version**: ${status.version ?? 'unknown'}`);
39
+ lines.push("");
40
+
41
+ if (attempt_repair) {
42
+ lines.push("## Repair Attempt");
43
+ const result = tryRemoveStaleLock(lockPath);
44
+
45
+ if (result.removed) {
46
+ lines.push(`✅ **Lock removed**: ${result.reason}`);
47
+ lines.push("");
48
+ lines.push("The repository is now unlocked and ready for use.");
49
+ } else {
50
+ lines.push(`âš ī¸ **Lock NOT removed**: ${result.reason}`);
51
+ lines.push("");
52
+ lines.push("**Recommendations**:");
53
+ lines.push("- If the owning process has crashed, wait 30 seconds for automatic stale detection");
54
+ lines.push("- If the process is still running, wait for it to complete");
55
+ lines.push("- As a last resort, manually stop the process and run this tool again with attempt_repair=true");
56
+ }
57
+ } else {
58
+ lines.push("## Recommendations");
59
+ if (!status.pidAlive) {
60
+ lines.push("🔧 **Action Required**: Lock belongs to dead process. Run with `attempt_repair=true` to remove it.");
61
+ } else if (status.isStale) {
62
+ lines.push("🔧 **Action Suggested**: Lock is stale (not updated recently). Run with `attempt_repair=true` to remove it.");
63
+ } else {
64
+ lines.push("✅ Lock is active and healthy. The owning process is running normally.");
65
+ lines.push("");
66
+ lines.push("If you believe this is incorrect:");
67
+ lines.push("- Wait 30 seconds and check again (automatic stale detection)");
68
+ lines.push("- Run with `attempt_repair=true` only if you're certain the process has crashed");
69
+ }
70
+ }
71
+
72
+ return lines.join("\n");
73
+ },
74
+ });
75
+ }
@@ -0,0 +1,71 @@
1
+ // src/tools/metrics.ts
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import { metrics } from "../shared/metrics";
4
+
5
+ type CreateAstroMetricsToolOptions = {
6
+ ctx: any;
7
+ config: any;
8
+ };
9
+
10
+ export function createAstroMetricsTool(opts: CreateAstroMetricsToolOptions): ToolDefinition {
11
+ return tool({
12
+ description: "Get performance metrics for Astrocode operations including transaction times, injection success rates, and error statistics.",
13
+ args: {},
14
+ execute: async () => {
15
+ return runMetricsTool();
16
+ },
17
+ });
18
+ }
19
+
20
+ function runMetricsTool(): string {
21
+ const stats = metrics.getMetrics();
22
+ const txStats = metrics.getTransactionStats();
23
+ const injectionStats = metrics.getInjectionStats();
24
+
25
+ let output = "# Astrocode Performance Metrics\n\n";
26
+
27
+ // Transaction Stats
28
+ if (txStats) {
29
+ output += "## Database Transactions\n\n";
30
+ output += `**Total:** ${txStats.total}\n`;
31
+ output += `**Success Rate:** ${(txStats.successRate * 100).toFixed(1)}% (${txStats.successful}/${txStats.total})\n`;
32
+ output += `**Average Duration:** ${txStats.avgDuration.toFixed(2)}ms\n`;
33
+ output += `**Duration Range:** ${txStats.minDuration}ms - ${txStats.maxDuration}ms\n`;
34
+ output += `**Average Nesting Depth:** ${txStats.avgNestedDepth.toFixed(1)}\n\n`;
35
+ } else {
36
+ output += "## Database Transactions\n\nNo transaction data available.\n\n";
37
+ }
38
+
39
+ // Injection Stats
40
+ if (injectionStats) {
41
+ output += "## UI Injections\n\n";
42
+ output += `**Total:** ${injectionStats.total}\n`;
43
+ output += `**Success Rate:** ${(injectionStats.successRate * 100).toFixed(1)}% (${injectionStats.successful}/${injectionStats.total})\n`;
44
+ output += `**Average Attempts:** ${injectionStats.avgAttempts.toFixed(1)}\n`;
45
+ output += `**Total Retries:** ${injectionStats.totalRetries}\n`;
46
+ output += `**Average Duration:** ${injectionStats.avgDuration.toFixed(2)}ms\n\n`;
47
+ } else {
48
+ output += "## UI Injections\n\nNo injection data available.\n\n";
49
+ }
50
+
51
+ // Recent Errors
52
+ if (stats.errors.length > 0) {
53
+ output += "## Recent Errors\n\n";
54
+ const recentErrors = stats.errors.slice(-10); // Last 10 errors
55
+ for (const error of recentErrors) {
56
+ const timestamp = new Date(error.timestamp).toISOString();
57
+ output += `- **[${error.type}]** ${timestamp}: ${error.message}\n`;
58
+ }
59
+ output += "\n";
60
+ } else {
61
+ output += "## Recent Errors\n\nNo errors recorded.\n\n";
62
+ }
63
+
64
+ // Raw Data Summary
65
+ output += "## Data Summary\n\n";
66
+ output += `**Transactions Tracked:** ${stats.transactions.length}\n`;
67
+ output += `**Injections Tracked:** ${stats.injections.length}\n`;
68
+ output += `**Errors Recorded:** ${stats.errors.length}\n`;
69
+
70
+ return output;
71
+ }
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
2
3
  import type { AstrocodeConfig } from "../config/schema";
3
4
  import type { SqliteDb } from "../state/db";
@@ -5,27 +6,64 @@ import { withTx } from "../state/db";
5
6
  import { repairState, formatRepairReport } from "../workflow/repair";
6
7
  import { putArtifact } from "../workflow/artifacts";
7
8
  import { nowISO } from "../shared/time";
9
+ import { getLockStatus, tryRemoveStaleLock } from "../state/repo-lock";
10
+
8
11
 
9
12
  export function createAstroRepairTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
10
13
  const { ctx, config, db } = opts;
11
14
 
12
15
  return tool({
13
- description: "Repair Astrocode invariants and recover from inconsistent DB state. Writes a repair report artifact.",
16
+ description: "Repair Astrocode invariants and recover from inconsistent DB state. Also checks and repairs lock files. Writes a repair report artifact.",
14
17
  args: {
15
18
  write_report_artifact: tool.schema.boolean().default(true),
19
+ repair_lock: tool.schema.boolean().default(true).describe("Attempt to remove stale/dead lock files"),
16
20
  },
17
- execute: async ({ write_report_artifact }) => {
21
+ execute: async ({ write_report_artifact, repair_lock }) => {
18
22
  const repoRoot = ctx.directory as string;
23
+ const lockPath = path.join(repoRoot, ".astro", "astro.lock");
24
+
25
+ // First, check and repair lock if requested
26
+ const lockLines: string[] = [];
27
+ const lockStatus = getLockStatus(lockPath);
28
+
29
+ if (lockStatus.exists) {
30
+ lockLines.push("## Lock Status");
31
+ lockLines.push(`- Lock found: ${lockPath}`);
32
+ lockLines.push(`- PID: ${lockStatus.pid} (${lockStatus.pidAlive ? 'alive' : 'dead'})`);
33
+ lockLines.push(`- Age: ${lockStatus.ageMs ? Math.floor(lockStatus.ageMs / 1000) : '?'}s`);
34
+ lockLines.push(`- Status: ${lockStatus.isStale ? 'stale' : 'fresh'}`);
35
+
36
+ if (repair_lock) {
37
+ const result = tryRemoveStaleLock(lockPath);
38
+ if (result.removed) {
39
+ lockLines.push(`- **Removed**: ${result.reason}`);
40
+ } else {
41
+ lockLines.push(`- **Not removed**: ${result.reason}`);
42
+ }
43
+ } else {
44
+ if (!lockStatus.pidAlive || lockStatus.isStale) {
45
+ lockLines.push(`- **Recommendation**: Run with repair_lock=true to remove this ${!lockStatus.pidAlive ? 'dead' : 'stale'} lock`);
46
+ }
47
+ }
48
+ lockLines.push("");
49
+ }
50
+
51
+ // Then repair database state
19
52
  const report = withTx(db, () => repairState(db, config));
20
- const md = formatRepairReport(report);
53
+ const dbMd = formatRepairReport(report);
54
+
55
+ // Combine lock and DB repair
56
+ const fullMd = lockLines.length > 0
57
+ ? `# Astrocode Repair Report\n\n${lockLines.join("\n")}\n${dbMd.replace(/^# Astrocode repair report\n*/i, "")}`
58
+ : dbMd;
21
59
 
22
60
  if (write_report_artifact) {
23
61
  const rel = `.astro/repair/repair_${nowISO().replace(/[:.]/g, "-")}.md`;
24
- const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: md, meta: { kind: "repair" } });
25
- return md + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
62
+ const a = putArtifact({ repoRoot, db, run_id: null, stage_key: null, type: "log", rel_path: rel, content: fullMd, meta: { kind: "repair" } });
63
+ return fullMd + `\n\nReport saved: ${rel} (artifact=${a.artifact_id})`;
26
64
  }
27
65
 
28
- return md;
66
+ return fullMd;
29
67
  },
30
68
  });
31
69
  }
@@ -0,0 +1,100 @@
1
+ // src/tools/reset.ts
2
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ import type { AstrocodeConfig } from "../config/schema";
4
+ import type { SqliteDb } from "../state/db";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export function createAstroResetTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
9
+ const { ctx, config, db } = opts;
10
+
11
+ return tool({
12
+ description: "Reset Astrocode database: safely delete all DB files and WAL/SHM after killing concurrent processes.",
13
+ args: {
14
+ confirm: tool.schema.string().default("").describe("Type 'RESET' to confirm destructive operation"),
15
+ },
16
+ execute: async ({ confirm }) => {
17
+ if (confirm !== "RESET") {
18
+ return [
19
+ "❌ Reset cancelled - confirmation required",
20
+ "",
21
+ "This operation will:",
22
+ "- Delete .astro/astro.db",
23
+ "- Delete .astro/astro.db-wal (if exists)",
24
+ "- Delete .astro/astro.db-shm (if exists)",
25
+ "- Lose all workflow data, stories, runs, artifacts",
26
+ "",
27
+ "To confirm: astro_reset(confirm=\"RESET\")",
28
+ ].join("\n");
29
+ }
30
+
31
+ const repoRoot = (ctx as any).directory || process.cwd();
32
+ const dbPath = config.db?.path || ".astro/astro.db";
33
+ const fullDbPath = path.resolve(repoRoot, dbPath);
34
+
35
+ const lines: string[] = [];
36
+ lines.push("đŸ—‘ī¸ Astrocode Database Reset");
37
+ lines.push(`- Repo: ${repoRoot}`);
38
+ lines.push(`- Target: ${fullDbPath}`);
39
+
40
+ // Check for lock file
41
+ const lockPath = `${repoRoot}/.astro/astro.lock`;
42
+ if (fs.existsSync(lockPath)) {
43
+ try {
44
+ const lockContent = fs.readFileSync(lockPath, "utf8").trim();
45
+ const pid = parseInt(lockContent.split(" ")[0]);
46
+
47
+ lines.push(`- Lock file found for PID ${pid}`);
48
+
49
+ // Try to kill the process
50
+ try {
51
+ (process as any).kill(pid, 'SIGTERM');
52
+ lines.push(`- Sent SIGTERM to PID ${pid}, waiting 2s...`);
53
+ await new Promise(resolve => setTimeout(resolve, 2000));
54
+ } catch (e) {
55
+ lines.push(`- Could not kill PID ${pid}: ${String(e)}`);
56
+ }
57
+ } catch (e) {
58
+ lines.push(`- Error reading lock file: ${String(e)}`);
59
+ }
60
+ }
61
+
62
+ // Delete DB files
63
+ const filesToDelete = [
64
+ fullDbPath,
65
+ `${fullDbPath}-wal`,
66
+ `${fullDbPath}-shm`,
67
+ lockPath,
68
+ ];
69
+
70
+ let deletedCount = 0;
71
+ for (const filePath of filesToDelete) {
72
+ try {
73
+ if (fs.existsSync(filePath)) {
74
+ fs.unlinkSync(filePath);
75
+ lines.push(`- Deleted: ${path.relative(repoRoot, filePath)}`);
76
+ deletedCount++;
77
+ } else {
78
+ lines.push(`- Skipped: ${path.relative(repoRoot, filePath)} (not found)`);
79
+ }
80
+ } catch (e) {
81
+ lines.push(`- Failed to delete ${path.relative(repoRoot, filePath)}: ${String(e)}`);
82
+ }
83
+ }
84
+
85
+ lines.push(``);
86
+ if (deletedCount > 0) {
87
+ lines.push(`✅ Reset complete - ${deletedCount} files deleted`);
88
+ lines.push(``);
89
+ lines.push(`Next steps:`);
90
+ lines.push(`1. Run: astro_init`);
91
+ lines.push(`2. Run: astro_status`);
92
+ lines.push(`3. Import your stories and restart workflow`);
93
+ } else {
94
+ lines.push(`â„šī¸ No files found to delete`);
95
+ }
96
+
97
+ return lines.join("\n");
98
+ },
99
+ });
100
+ }
@@ -14,6 +14,7 @@ import { failRun, getActiveRun, getStageRuns, startStage, completeRun } from "..
14
14
  import { newEventId, newId } from "../state/ids";
15
15
  import { insertStory } from "../workflow/story-helpers";
16
16
 
17
+
17
18
  function nextStageKey(pipeline: StageKey[], current: StageKey): StageKey | null {
18
19
  const i = pipeline.indexOf(current);
19
20
  if (i === -1) return null;
@@ -3,6 +3,7 @@ import type { AstrocodeConfig } from "../config/schema";
3
3
  import type { SqliteDb } from "../state/db";
4
4
  import { decideNextAction, getActiveRun, getStageRuns, getStory } from "../workflow/state-machine";
5
5
 
6
+
6
7
  function statusIcon(status: string): string {
7
8
  switch (status) {
8
9
  case "running":
@@ -36,7 +37,7 @@ function stageIcon(status: string): string {
36
37
  }
37
38
 
38
39
  export function createAstroStatusTool(opts: { ctx: any; config: AstrocodeConfig; db?: SqliteDb | null }): ToolDefinition {
39
- const { config, db } = opts;
40
+ const { ctx, config, db } = opts;
40
41
 
41
42
  return tool({
42
43
  description: "Show a compact Astrocode status dashboard: active run/stage, pipeline, story board counts, and next action.",