stagent 0.6.2 → 0.7.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 (176) hide show
  1. package/README.md +21 -2
  2. package/dist/cli.js +272 -1
  3. package/docs/.coverage-gaps.json +66 -16
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/dashboard-kanban.md +13 -7
  6. package/docs/features/settings.md +15 -3
  7. package/docs/features/tables.md +122 -0
  8. package/docs/index.md +3 -2
  9. package/docs/journeys/developer.md +26 -16
  10. package/docs/journeys/personal-use.md +23 -9
  11. package/docs/journeys/power-user.md +40 -14
  12. package/docs/journeys/work-use.md +43 -15
  13. package/docs/manifest.json +27 -17
  14. package/package.json +3 -2
  15. package/src/app/api/chat/entities/search/route.ts +12 -3
  16. package/src/app/api/documents/[id]/route.ts +5 -1
  17. package/src/app/api/documents/[id]/versions/route.ts +53 -0
  18. package/src/app/api/documents/route.ts +5 -1
  19. package/src/app/api/projects/[id]/documents/route.ts +124 -0
  20. package/src/app/api/projects/[id]/route.ts +72 -3
  21. package/src/app/api/projects/__tests__/delete-project.test.ts +13 -0
  22. package/src/app/api/schedules/route.ts +19 -1
  23. package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
  24. package/src/app/api/snapshots/[id]/route.ts +44 -0
  25. package/src/app/api/snapshots/route.ts +54 -0
  26. package/src/app/api/snapshots/settings/route.ts +67 -0
  27. package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
  28. package/src/app/api/tables/[id]/charts/route.ts +72 -0
  29. package/src/app/api/tables/[id]/columns/route.ts +70 -0
  30. package/src/app/api/tables/[id]/export/route.ts +94 -0
  31. package/src/app/api/tables/[id]/history/route.ts +15 -0
  32. package/src/app/api/tables/[id]/import/route.ts +111 -0
  33. package/src/app/api/tables/[id]/route.ts +86 -0
  34. package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
  35. package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
  36. package/src/app/api/tables/[id]/rows/route.ts +101 -0
  37. package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
  38. package/src/app/api/tables/[id]/triggers/route.ts +122 -0
  39. package/src/app/api/tables/route.ts +65 -0
  40. package/src/app/api/tables/templates/route.ts +92 -0
  41. package/src/app/api/tasks/[id]/route.ts +37 -2
  42. package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
  43. package/src/app/api/tasks/route.ts +8 -9
  44. package/src/app/api/workflows/[id]/documents/route.ts +209 -0
  45. package/src/app/api/workflows/[id]/execute/route.ts +6 -2
  46. package/src/app/api/workflows/[id]/route.ts +16 -3
  47. package/src/app/api/workflows/[id]/status/route.ts +18 -2
  48. package/src/app/api/workflows/route.ts +13 -2
  49. package/src/app/documents/page.tsx +5 -1
  50. package/src/app/layout.tsx +0 -1
  51. package/src/app/manifest.ts +3 -3
  52. package/src/app/projects/[id]/page.tsx +62 -2
  53. package/src/app/settings/page.tsx +2 -0
  54. package/src/app/tables/[id]/page.tsx +67 -0
  55. package/src/app/tables/page.tsx +21 -0
  56. package/src/app/tables/templates/page.tsx +19 -0
  57. package/src/components/chat/chat-table-result.tsx +139 -0
  58. package/src/components/documents/document-browser.tsx +1 -1
  59. package/src/components/documents/document-chip-bar.tsx +17 -1
  60. package/src/components/documents/document-detail-view.tsx +51 -0
  61. package/src/components/documents/document-grid.tsx +5 -0
  62. package/src/components/documents/document-table.tsx +4 -0
  63. package/src/components/documents/types.ts +3 -0
  64. package/src/components/projects/project-form-sheet.tsx +109 -2
  65. package/src/components/schedules/schedule-form.tsx +91 -1
  66. package/src/components/settings/data-management-section.tsx +17 -12
  67. package/src/components/settings/database-snapshots-section.tsx +469 -0
  68. package/src/components/shared/app-sidebar.tsx +2 -0
  69. package/src/components/shared/document-picker-sheet.tsx +486 -0
  70. package/src/components/tables/table-browser.tsx +234 -0
  71. package/src/components/tables/table-cell-editor.tsx +226 -0
  72. package/src/components/tables/table-chart-builder.tsx +288 -0
  73. package/src/components/tables/table-chart-view.tsx +146 -0
  74. package/src/components/tables/table-column-header.tsx +103 -0
  75. package/src/components/tables/table-column-sheet.tsx +331 -0
  76. package/src/components/tables/table-create-sheet.tsx +240 -0
  77. package/src/components/tables/table-detail-sheet.tsx +144 -0
  78. package/src/components/tables/table-detail-tabs.tsx +278 -0
  79. package/src/components/tables/table-grid.tsx +61 -0
  80. package/src/components/tables/table-history-tab.tsx +148 -0
  81. package/src/components/tables/table-import-wizard.tsx +542 -0
  82. package/src/components/tables/table-list-table.tsx +95 -0
  83. package/src/components/tables/table-relation-combobox.tsx +217 -0
  84. package/src/components/tables/table-spreadsheet.tsx +499 -0
  85. package/src/components/tables/table-template-gallery.tsx +162 -0
  86. package/src/components/tables/table-template-preview.tsx +219 -0
  87. package/src/components/tables/table-toolbar.tsx +79 -0
  88. package/src/components/tables/table-triggers-tab.tsx +446 -0
  89. package/src/components/tables/types.ts +6 -0
  90. package/src/components/tables/use-spreadsheet-keys.ts +171 -0
  91. package/src/components/tables/utils.ts +29 -0
  92. package/src/components/tasks/task-card.tsx +8 -1
  93. package/src/components/tasks/task-create-panel.tsx +111 -14
  94. package/src/components/tasks/task-detail-view.tsx +47 -0
  95. package/src/components/tasks/task-edit-dialog.tsx +103 -2
  96. package/src/components/workflows/workflow-form-view.tsx +207 -7
  97. package/src/components/workflows/workflow-kanban-card.tsx +8 -1
  98. package/src/components/workflows/workflow-list.tsx +90 -45
  99. package/src/components/workflows/workflow-status-view.tsx +168 -23
  100. package/src/instrumentation.ts +3 -0
  101. package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
  102. package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
  103. package/src/lib/agents/claude-agent.ts +3 -1
  104. package/src/lib/agents/profiles/registry.ts +6 -3
  105. package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
  106. package/src/lib/agents/runtime/openai-direct.ts +29 -0
  107. package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
  108. package/src/lib/book/chapter-generator.ts +4 -19
  109. package/src/lib/book/chapter-mapping.ts +17 -0
  110. package/src/lib/book/content.ts +5 -16
  111. package/src/lib/book/update-detector.ts +3 -16
  112. package/src/lib/chat/engine.ts +1 -0
  113. package/src/lib/chat/stagent-tools.ts +2 -0
  114. package/src/lib/chat/system-prompt.ts +9 -1
  115. package/src/lib/chat/tool-catalog.ts +35 -0
  116. package/src/lib/chat/tools/settings-tools.ts +109 -0
  117. package/src/lib/chat/tools/table-tools.ts +955 -0
  118. package/src/lib/chat/tools/workflow-tools.ts +145 -2
  119. package/src/lib/constants/table-status.ts +68 -0
  120. package/src/lib/data/__tests__/clear.test.ts +1 -1
  121. package/src/lib/data/clear.ts +57 -0
  122. package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
  123. package/src/lib/data/seed-data/conversations.ts +350 -42
  124. package/src/lib/data/seed-data/documents.ts +564 -591
  125. package/src/lib/data/seed-data/learned-context.ts +101 -22
  126. package/src/lib/data/seed-data/notifications.ts +344 -70
  127. package/src/lib/data/seed-data/profile-test-results.ts +92 -11
  128. package/src/lib/data/seed-data/profiles.ts +144 -46
  129. package/src/lib/data/seed-data/projects.ts +50 -18
  130. package/src/lib/data/seed-data/repo-imports.ts +28 -13
  131. package/src/lib/data/seed-data/schedules.ts +208 -41
  132. package/src/lib/data/seed-data/table-templates.ts +234 -0
  133. package/src/lib/data/seed-data/tasks.ts +614 -116
  134. package/src/lib/data/seed-data/usage-ledger.ts +182 -103
  135. package/src/lib/data/seed-data/user-tables.ts +203 -0
  136. package/src/lib/data/seed-data/views.ts +52 -7
  137. package/src/lib/data/seed-data/workflows.ts +231 -84
  138. package/src/lib/data/seed.ts +55 -14
  139. package/src/lib/data/tables.ts +417 -0
  140. package/src/lib/db/bootstrap.ts +275 -0
  141. package/src/lib/db/index.ts +9 -0
  142. package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
  143. package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
  144. package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
  145. package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
  146. package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
  147. package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
  148. package/src/lib/db/schema.ts +445 -0
  149. package/src/lib/docs/reader.ts +2 -3
  150. package/src/lib/documents/context-builder.ts +75 -2
  151. package/src/lib/documents/document-resolver.ts +119 -0
  152. package/src/lib/documents/processors/spreadsheet.ts +2 -1
  153. package/src/lib/schedules/scheduler.ts +31 -1
  154. package/src/lib/snapshots/auto-backup.ts +132 -0
  155. package/src/lib/snapshots/retention.ts +64 -0
  156. package/src/lib/snapshots/snapshot-manager.ts +429 -0
  157. package/src/lib/tables/computed.ts +61 -0
  158. package/src/lib/tables/context-builder.ts +139 -0
  159. package/src/lib/tables/formula-engine.ts +415 -0
  160. package/src/lib/tables/history.ts +115 -0
  161. package/src/lib/tables/import.ts +343 -0
  162. package/src/lib/tables/query-builder.ts +152 -0
  163. package/src/lib/tables/trigger-evaluator.ts +146 -0
  164. package/src/lib/tables/types.ts +141 -0
  165. package/src/lib/tables/validation.ts +119 -0
  166. package/src/lib/utils/app-root.ts +20 -0
  167. package/src/lib/utils/stagent-paths.ts +20 -0
  168. package/src/lib/validators/__tests__/task.test.ts +43 -10
  169. package/src/lib/validators/task.ts +7 -1
  170. package/src/lib/workflows/blueprints/registry.ts +3 -3
  171. package/src/lib/workflows/engine.ts +24 -8
  172. package/src/lib/workflows/types.ts +14 -0
  173. package/tsconfig.json +3 -1
  174. package/public/icon.svg +0 -13
  175. package/src/components/tasks/file-upload.tsx +0 -120
  176. /package/docs/features/{playbook.md → user-guide.md} +0 -0
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { db } from "@/lib/db";
15
- import { schedules, tasks, agentLogs } from "@/lib/db/schema";
15
+ import { schedules, tasks, agentLogs, scheduleDocumentInputs, documents } from "@/lib/db/schema";
16
16
  import { eq, and, lte, inArray, sql } from "drizzle-orm";
17
17
  import { computeNextFireTime } from "./interval-parser";
18
18
  import { executeTaskWithRuntime } from "@/lib/agents/runtime";
@@ -178,6 +178,21 @@ async function fireSchedule(
178
178
  updatedAt: now,
179
179
  });
180
180
 
181
+ // Link schedule's documents to the created task
182
+ try {
183
+ const schedDocs = await db
184
+ .select({ documentId: scheduleDocumentInputs.documentId })
185
+ .from(scheduleDocumentInputs)
186
+ .where(eq(scheduleDocumentInputs.scheduleId, schedule.id));
187
+ for (const { documentId } of schedDocs) {
188
+ await db.update(documents)
189
+ .set({ taskId, projectId: schedule.projectId, updatedAt: now })
190
+ .where(eq(documents.id, documentId));
191
+ }
192
+ } catch (err) {
193
+ console.error(`[scheduler] Document linking failed for schedule ${schedule.id}:`, err);
194
+ }
195
+
181
196
  // Update schedule counters
182
197
  const isOneShot = !schedule.recurs;
183
198
  const reachedMax =
@@ -335,6 +350,21 @@ async function fireHeartbeat(
335
350
  updatedAt: now,
336
351
  });
337
352
 
353
+ // Link schedule's documents to the heartbeat task
354
+ try {
355
+ const schedDocs = await db
356
+ .select({ documentId: scheduleDocumentInputs.documentId })
357
+ .from(scheduleDocumentInputs)
358
+ .where(eq(scheduleDocumentInputs.scheduleId, schedule.id));
359
+ for (const { documentId } of schedDocs) {
360
+ await db.update(documents)
361
+ .set({ taskId: evalTaskId, projectId: schedule.projectId, updatedAt: now })
362
+ .where(eq(documents.id, documentId));
363
+ }
364
+ } catch (err) {
365
+ console.error(`[scheduler] Document linking failed for heartbeat ${schedule.id}:`, err);
366
+ }
367
+
338
368
  // 5. Execute and wait for result (with timeout)
339
369
  try {
340
370
  await executeTaskWithRuntime(evalTaskId);
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Auto-backup timer — creates snapshots on a user-configurable interval.
3
+ *
4
+ * Lifecycle:
5
+ * - `startAutoBackup()` — call once at server boot (idempotent)
6
+ * - `stopAutoBackup()` — call on graceful shutdown
7
+ * - `tickAutoBackup()` — exposed for testing; runs one check cycle
8
+ *
9
+ * Each tick reads settings to check if auto-backup is enabled and whether
10
+ * enough time has elapsed since the last snapshot. This allows users to
11
+ * change settings without restarting the server.
12
+ */
13
+
14
+ import { db } from "@/lib/db";
15
+ import { snapshots } from "@/lib/db/schema";
16
+ import { desc, eq } from "drizzle-orm";
17
+ import { getSettingSync } from "@/lib/settings/helpers";
18
+ import { parseInterval } from "@/lib/schedules/interval-parser";
19
+ import { computeNextFireTime } from "@/lib/schedules/interval-parser";
20
+ import { createSnapshot, isSnapshotLocked } from "./snapshot-manager";
21
+ import { enforceRetention } from "./retention";
22
+
23
+ // Check every 60 seconds whether an auto-backup is due
24
+ const POLL_INTERVAL_MS = 60_000;
25
+
26
+ let intervalHandle: ReturnType<typeof setInterval> | null = null;
27
+
28
+ // Settings keys
29
+ const SETTINGS = {
30
+ enabled: "snapshot.autoBackup.enabled",
31
+ interval: "snapshot.autoBackup.interval",
32
+ maxCount: "snapshot.retention.maxCount",
33
+ maxAgeWeeks: "snapshot.retention.maxAgeWeeks",
34
+ } as const;
35
+
36
+ /**
37
+ * Start the auto-backup timer. Safe to call multiple times.
38
+ */
39
+ export function startAutoBackup(): void {
40
+ if (intervalHandle !== null) return;
41
+
42
+ console.log("[auto-backup] Starting auto-backup timer (60s poll)");
43
+ intervalHandle = setInterval(() => {
44
+ tickAutoBackup().catch((err) => {
45
+ console.error("[auto-backup] Tick error:", err);
46
+ });
47
+ }, POLL_INTERVAL_MS);
48
+
49
+ // Run first check shortly after startup
50
+ setTimeout(() => {
51
+ tickAutoBackup().catch((err) => {
52
+ console.error("[auto-backup] Initial tick error:", err);
53
+ });
54
+ }, 5_000);
55
+ }
56
+
57
+ /**
58
+ * Stop the auto-backup timer.
59
+ */
60
+ export function stopAutoBackup(): void {
61
+ if (intervalHandle !== null) {
62
+ clearInterval(intervalHandle);
63
+ intervalHandle = null;
64
+ console.log("[auto-backup] Stopped auto-backup timer");
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Run one auto-backup check cycle. Exposed for testing.
70
+ */
71
+ export async function tickAutoBackup(): Promise<void> {
72
+ // 1. Check if enabled
73
+ const enabled = getSettingSync(SETTINGS.enabled);
74
+ if (enabled !== "true") return;
75
+
76
+ // 2. Skip if a snapshot operation is in progress
77
+ if (isSnapshotLocked()) return;
78
+
79
+ // 3. Get the configured interval
80
+ const intervalStr = getSettingSync(SETTINGS.interval) || "1d";
81
+ let cronExpr: string;
82
+ try {
83
+ cronExpr = parseInterval(intervalStr);
84
+ } catch {
85
+ console.warn(`[auto-backup] Invalid interval "${intervalStr}", skipping`);
86
+ return;
87
+ }
88
+
89
+ // 4. Check if enough time has passed since last auto-backup
90
+ const [lastAutoSnapshot] = await db
91
+ .select()
92
+ .from(snapshots)
93
+ .where(eq(snapshots.type, "auto"))
94
+ .orderBy(desc(snapshots.createdAt))
95
+ .limit(1);
96
+
97
+ if (lastAutoSnapshot) {
98
+ const lastTime = lastAutoSnapshot.createdAt;
99
+ const nextFire = computeNextFireTime(cronExpr, lastTime);
100
+ if (nextFire && nextFire > new Date()) {
101
+ // Not due yet
102
+ return;
103
+ }
104
+ }
105
+
106
+ // 5. Create auto-backup snapshot
107
+ console.log("[auto-backup] Creating auto-backup snapshot...");
108
+ try {
109
+ const now = new Date();
110
+ const label = `Auto-backup ${now.toISOString().slice(0, 16).replace("T", " ")}`;
111
+ await createSnapshot(label, "auto");
112
+ console.log("[auto-backup] Auto-backup snapshot created successfully");
113
+ } catch (err) {
114
+ console.error("[auto-backup] Failed to create snapshot:", err);
115
+ return;
116
+ }
117
+
118
+ // 6. Enforce retention after snapshot creation
119
+ try {
120
+ const maxCount = parseInt(getSettingSync(SETTINGS.maxCount) || "10", 10);
121
+ const maxAgeWeeks = parseInt(
122
+ getSettingSync(SETTINGS.maxAgeWeeks) || "4",
123
+ 10
124
+ );
125
+ const deleted = await enforceRetention(maxCount, maxAgeWeeks);
126
+ if (deleted > 0) {
127
+ console.log(`[auto-backup] Retention enforced: deleted ${deleted} old snapshot(s)`);
128
+ }
129
+ } catch (err) {
130
+ console.error("[auto-backup] Retention enforcement failed:", err);
131
+ }
132
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Snapshot retention policy — enforces max count and max age limits.
3
+ * Deletes oldest snapshots first when either limit is exceeded.
4
+ */
5
+
6
+ import { db } from "@/lib/db";
7
+ import { snapshots } from "@/lib/db/schema";
8
+ import { asc, eq, and, lte } from "drizzle-orm";
9
+ import { deleteSnapshot } from "./snapshot-manager";
10
+
11
+ /**
12
+ * Enforce retention limits. Should be called after every snapshot creation.
13
+ *
14
+ * @param maxCount - Maximum number of snapshots to keep (0 = unlimited)
15
+ * @param maxAgeWeeks - Maximum age in weeks (0 = unlimited)
16
+ * @returns Number of snapshots deleted
17
+ */
18
+ export async function enforceRetention(
19
+ maxCount: number,
20
+ maxAgeWeeks: number
21
+ ): Promise<number> {
22
+ let deleted = 0;
23
+
24
+ // 1. Enforce max age — delete snapshots older than N weeks
25
+ if (maxAgeWeeks > 0) {
26
+ const cutoff = new Date();
27
+ cutoff.setDate(cutoff.getDate() - maxAgeWeeks * 7);
28
+
29
+ const expired = await db
30
+ .select()
31
+ .from(snapshots)
32
+ .where(
33
+ and(
34
+ eq(snapshots.status, "completed"),
35
+ lte(snapshots.createdAt, cutoff)
36
+ )
37
+ )
38
+ .orderBy(asc(snapshots.createdAt));
39
+
40
+ for (const row of expired) {
41
+ await deleteSnapshot(row.id);
42
+ deleted++;
43
+ }
44
+ }
45
+
46
+ // 2. Enforce max count — delete oldest beyond the limit
47
+ if (maxCount > 0) {
48
+ const all = await db
49
+ .select()
50
+ .from(snapshots)
51
+ .where(eq(snapshots.status, "completed"))
52
+ .orderBy(asc(snapshots.createdAt));
53
+
54
+ const excess = all.length - maxCount;
55
+ if (excess > 0) {
56
+ for (let i = 0; i < excess; i++) {
57
+ await deleteSnapshot(all[i].id);
58
+ deleted++;
59
+ }
60
+ }
61
+ }
62
+
63
+ return deleted;
64
+ }
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Core snapshot manager — create, list, delete, and restore full-state snapshots.
3
+ *
4
+ * A snapshot includes:
5
+ * 1. Atomic SQLite backup via .backup() API (WAL-safe)
6
+ * 2. Tarball of all ~/.stagent/ file directories (uploads, screenshots, outputs, etc.)
7
+ * 3. manifest.json with metadata
8
+ */
9
+
10
+ import { db, sqlite } from "@/lib/db";
11
+ import { snapshots } from "@/lib/db/schema";
12
+ import type { SnapshotRow } from "@/lib/db/schema";
13
+ import {
14
+ getStagentDataDir,
15
+ getStagentSnapshotsDir,
16
+ getStagentDbPath,
17
+ } from "@/lib/utils/stagent-paths";
18
+ import { eq, desc } from "drizzle-orm";
19
+ import {
20
+ mkdirSync,
21
+ existsSync,
22
+ statSync,
23
+ rmSync,
24
+ readdirSync,
25
+ writeFileSync,
26
+ readFileSync,
27
+ } from "fs";
28
+ import { join } from "path";
29
+ import * as tar from "tar";
30
+
31
+ // Directories included in snapshot (relative to stagent data dir)
32
+ const SNAPSHOT_DIRS = [
33
+ "uploads",
34
+ "screenshots",
35
+ "outputs",
36
+ "sessions",
37
+ "documents",
38
+ "logs",
39
+ ];
40
+
41
+ // Directories excluded from snapshot
42
+ const EXCLUDED_DIRS = ["backups", "snapshots"];
43
+
44
+ // Mutex to prevent concurrent snapshot operations
45
+ let snapshotLock = false;
46
+
47
+ export function isSnapshotLocked(): boolean {
48
+ return snapshotLock;
49
+ }
50
+
51
+ interface SnapshotManifest {
52
+ version: 1;
53
+ timestamp: string;
54
+ label: string;
55
+ type: "manual" | "auto";
56
+ includedDirs: string[];
57
+ excludedDirs: string[];
58
+ dirStats: Record<string, { fileCount: number; sizeBytes: number }>;
59
+ dbSizeBytes: number;
60
+ filesSizeBytes: number;
61
+ totalSizeBytes: number;
62
+ }
63
+
64
+ function generateId(): string {
65
+ return crypto.randomUUID();
66
+ }
67
+
68
+ function formatTimestamp(date: Date): string {
69
+ return date.toISOString().replace(/[:.]/g, "-");
70
+ }
71
+
72
+ /** Calculate total size of files in a directory (recursive). */
73
+ function dirSize(dirPath: string): { fileCount: number; sizeBytes: number } {
74
+ if (!existsSync(dirPath)) return { fileCount: 0, sizeBytes: 0 };
75
+
76
+ let fileCount = 0;
77
+ let sizeBytes = 0;
78
+
79
+ function walk(dir: string) {
80
+ try {
81
+ for (const entry of readdirSync(dir)) {
82
+ const fullPath = join(dir, entry);
83
+ try {
84
+ const stat = statSync(fullPath);
85
+ if (stat.isDirectory()) {
86
+ walk(fullPath);
87
+ } else if (stat.isFile()) {
88
+ fileCount++;
89
+ sizeBytes += stat.size;
90
+ }
91
+ } catch {
92
+ // Skip unreadable entries
93
+ }
94
+ }
95
+ } catch {
96
+ // Skip unreadable directories
97
+ }
98
+ }
99
+
100
+ walk(dirPath);
101
+ return { fileCount, sizeBytes };
102
+ }
103
+
104
+ /**
105
+ * Create a full-state snapshot (DB + files).
106
+ */
107
+ export async function createSnapshot(
108
+ label: string,
109
+ type: "manual" | "auto" = "manual"
110
+ ): Promise<SnapshotRow> {
111
+ if (snapshotLock) {
112
+ throw new Error("Another snapshot operation is already in progress");
113
+ }
114
+
115
+ snapshotLock = true;
116
+ const id = generateId();
117
+ const now = new Date();
118
+ const sanitizedLabel = label.replace(/[^a-zA-Z0-9-_ ]/g, "_").slice(0, 100);
119
+ const dirName = `${formatTimestamp(now)}_${sanitizedLabel.replace(/\s+/g, "_")}`;
120
+ const snapshotsDir = getStagentSnapshotsDir();
121
+ const snapshotPath = join(snapshotsDir, dirName);
122
+ const dataDir = getStagentDataDir();
123
+
124
+ try {
125
+ mkdirSync(snapshotPath, { recursive: true });
126
+
127
+ // Insert in-progress record
128
+ await db.insert(snapshots).values({
129
+ id,
130
+ label: sanitizedLabel,
131
+ type,
132
+ status: "in_progress",
133
+ filePath: snapshotPath,
134
+ sizeBytes: 0,
135
+ dbSizeBytes: 0,
136
+ filesSizeBytes: 0,
137
+ fileCount: 0,
138
+ createdAt: now,
139
+ });
140
+
141
+ // 1. Atomic SQLite backup
142
+ const dbDestPath = join(snapshotPath, "snapshot.db");
143
+ await sqlite.backup(dbDestPath);
144
+ const dbSize = statSync(dbDestPath).size;
145
+
146
+ // 2. Tarball of file directories
147
+ const tarballPath = join(snapshotPath, "files.tar.gz");
148
+ const existingDirs = SNAPSHOT_DIRS.filter((d) =>
149
+ existsSync(join(dataDir, d))
150
+ );
151
+
152
+ let filesSizeBytes = 0;
153
+ let totalFileCount = 0;
154
+ const dirStats: Record<string, { fileCount: number; sizeBytes: number }> =
155
+ {};
156
+
157
+ for (const dir of existingDirs) {
158
+ const stats = dirSize(join(dataDir, dir));
159
+ dirStats[dir] = stats;
160
+ filesSizeBytes += stats.sizeBytes;
161
+ totalFileCount += stats.fileCount;
162
+ }
163
+
164
+ if (existingDirs.length > 0) {
165
+ await tar.create(
166
+ {
167
+ gzip: true,
168
+ file: tarballPath,
169
+ cwd: dataDir,
170
+ },
171
+ existingDirs
172
+ );
173
+ } else {
174
+ // Create empty tarball
175
+ await tar.create(
176
+ {
177
+ gzip: true,
178
+ file: tarballPath,
179
+ cwd: dataDir,
180
+ },
181
+ []
182
+ );
183
+ }
184
+
185
+ const tarballSize = existsSync(tarballPath)
186
+ ? statSync(tarballPath).size
187
+ : 0;
188
+ const totalSize = dbSize + tarballSize;
189
+
190
+ // 3. Write manifest
191
+ const manifest: SnapshotManifest = {
192
+ version: 1,
193
+ timestamp: now.toISOString(),
194
+ label: sanitizedLabel,
195
+ type,
196
+ includedDirs: existingDirs,
197
+ excludedDirs: EXCLUDED_DIRS,
198
+ dirStats,
199
+ dbSizeBytes: dbSize,
200
+ filesSizeBytes,
201
+ totalSizeBytes: totalSize,
202
+ };
203
+ writeFileSync(
204
+ join(snapshotPath, "manifest.json"),
205
+ JSON.stringify(manifest, null, 2)
206
+ );
207
+
208
+ // 4. Update record with final sizes
209
+ await db
210
+ .update(snapshots)
211
+ .set({
212
+ status: "completed",
213
+ sizeBytes: totalSize,
214
+ dbSizeBytes: dbSize,
215
+ filesSizeBytes: tarballSize,
216
+ fileCount: totalFileCount,
217
+ })
218
+ .where(eq(snapshots.id, id));
219
+
220
+ const [row] = await db
221
+ .select()
222
+ .from(snapshots)
223
+ .where(eq(snapshots.id, id));
224
+ return row;
225
+ } catch (error) {
226
+ // Mark as failed
227
+ try {
228
+ await db
229
+ .update(snapshots)
230
+ .set({
231
+ status: "failed",
232
+ error: error instanceof Error ? error.message : String(error),
233
+ })
234
+ .where(eq(snapshots.id, id));
235
+ } catch {
236
+ // If we can't even update the record, clean up the directory
237
+ }
238
+
239
+ // Clean up partial snapshot directory
240
+ if (existsSync(snapshotPath)) {
241
+ try {
242
+ rmSync(snapshotPath, { recursive: true, force: true });
243
+ } catch {
244
+ // Best effort cleanup
245
+ }
246
+ }
247
+
248
+ throw error;
249
+ } finally {
250
+ snapshotLock = false;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * List all snapshots, newest first.
256
+ * Checks file existence and marks missing snapshots.
257
+ */
258
+ export async function listSnapshots(): Promise<
259
+ (SnapshotRow & { filesMissing: boolean })[]
260
+ > {
261
+ const rows = await db
262
+ .select()
263
+ .from(snapshots)
264
+ .orderBy(desc(snapshots.createdAt));
265
+
266
+ return rows.map((row) => ({
267
+ ...row,
268
+ filesMissing: row.status === "completed" && !existsSync(row.filePath),
269
+ }));
270
+ }
271
+
272
+ /**
273
+ * Get a single snapshot by ID.
274
+ */
275
+ export async function getSnapshot(
276
+ id: string
277
+ ): Promise<(SnapshotRow & { filesMissing: boolean; manifest: SnapshotManifest | null }) | null> {
278
+ const [row] = await db
279
+ .select()
280
+ .from(snapshots)
281
+ .where(eq(snapshots.id, id));
282
+
283
+ if (!row) return null;
284
+
285
+ let manifest: SnapshotManifest | null = null;
286
+ const manifestPath = join(row.filePath, "manifest.json");
287
+ if (existsSync(manifestPath)) {
288
+ try {
289
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
290
+ } catch {
291
+ // Corrupt manifest — continue without it
292
+ }
293
+ }
294
+
295
+ return {
296
+ ...row,
297
+ filesMissing: row.status === "completed" && !existsSync(row.filePath),
298
+ manifest,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Delete a snapshot (metadata + files on disk).
304
+ */
305
+ export async function deleteSnapshot(id: string): Promise<boolean> {
306
+ const [row] = await db
307
+ .select()
308
+ .from(snapshots)
309
+ .where(eq(snapshots.id, id));
310
+
311
+ if (!row) return false;
312
+
313
+ // Delete files on disk
314
+ if (existsSync(row.filePath)) {
315
+ rmSync(row.filePath, { recursive: true, force: true });
316
+ }
317
+
318
+ // Delete metadata
319
+ await db.delete(snapshots).where(eq(snapshots.id, id));
320
+ return true;
321
+ }
322
+
323
+ /**
324
+ * Get total disk usage of all snapshot files.
325
+ */
326
+ export async function getSnapshotsSize(): Promise<{
327
+ totalBytes: number;
328
+ snapshotCount: number;
329
+ }> {
330
+ const snapshotsDir = getStagentSnapshotsDir();
331
+ if (!existsSync(snapshotsDir)) return { totalBytes: 0, snapshotCount: 0 };
332
+
333
+ const rows = await db.select().from(snapshots);
334
+ let totalBytes = 0;
335
+
336
+ for (const row of rows) {
337
+ if (row.status === "completed" && existsSync(row.filePath)) {
338
+ const stats = dirSize(row.filePath);
339
+ totalBytes += stats.sizeBytes;
340
+ }
341
+ }
342
+
343
+ return { totalBytes, snapshotCount: rows.length };
344
+ }
345
+
346
+ /**
347
+ * Restore from a snapshot — DESTRUCTIVE operation.
348
+ * Creates a pre-restore safety snapshot, then replaces DB + files.
349
+ * Returns { requiresRestart: true } to signal the server must restart.
350
+ */
351
+ export async function restoreFromSnapshot(id: string): Promise<{
352
+ requiresRestart: boolean;
353
+ preRestoreSnapshotId: string;
354
+ }> {
355
+ if (snapshotLock) {
356
+ throw new Error("Another snapshot operation is already in progress");
357
+ }
358
+
359
+ const snapshot = await getSnapshot(id);
360
+ if (!snapshot) throw new Error("Snapshot not found");
361
+ if (snapshot.status !== "completed")
362
+ throw new Error("Cannot restore from incomplete snapshot");
363
+ if (snapshot.filesMissing)
364
+ throw new Error("Snapshot files are missing from disk");
365
+
366
+ const snapshotDbPath = join(snapshot.filePath, "snapshot.db");
367
+ const snapshotTarPath = join(snapshot.filePath, "files.tar.gz");
368
+
369
+ if (!existsSync(snapshotDbPath)) {
370
+ throw new Error("Snapshot database file is missing");
371
+ }
372
+
373
+ snapshotLock = true;
374
+
375
+ try {
376
+ // 1. Create pre-restore safety snapshot
377
+ const preRestore = await createSnapshot(
378
+ `pre-restore-${formatTimestamp(new Date())}`,
379
+ "auto"
380
+ );
381
+
382
+ // 2. Replace file directories
383
+ const dataDir = getStagentDataDir();
384
+
385
+ // Clear existing file directories
386
+ for (const dir of SNAPSHOT_DIRS) {
387
+ const fullPath = join(dataDir, dir);
388
+ if (existsSync(fullPath)) {
389
+ rmSync(fullPath, { recursive: true, force: true });
390
+ }
391
+ }
392
+
393
+ // Extract tarball
394
+ if (existsSync(snapshotTarPath)) {
395
+ await tar.extract({
396
+ file: snapshotTarPath,
397
+ cwd: dataDir,
398
+ });
399
+ }
400
+
401
+ // 3. Replace database file
402
+ // Close any prepared statements first
403
+ const currentDbPath = getStagentDbPath();
404
+ const walPath = currentDbPath + "-wal";
405
+ const shmPath = currentDbPath + "-shm";
406
+
407
+ // Checkpoint WAL to ensure consistency
408
+ try {
409
+ sqlite.pragma("wal_checkpoint(TRUNCATE)");
410
+ } catch {
411
+ // Best effort
412
+ }
413
+
414
+ // Copy snapshot DB over current DB
415
+ const { copyFileSync } = await import("fs");
416
+ copyFileSync(snapshotDbPath, currentDbPath);
417
+
418
+ // Remove WAL/SHM files (snapshot DB is self-contained)
419
+ if (existsSync(walPath)) rmSync(walPath);
420
+ if (existsSync(shmPath)) rmSync(shmPath);
421
+
422
+ return {
423
+ requiresRestart: true,
424
+ preRestoreSnapshotId: preRestore.id,
425
+ };
426
+ } finally {
427
+ snapshotLock = false;
428
+ }
429
+ }