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.
- package/README.md +21 -2
- package/dist/cli.js +272 -1
- package/docs/.coverage-gaps.json +66 -16
- package/docs/.last-generated +1 -1
- package/docs/features/dashboard-kanban.md +13 -7
- package/docs/features/settings.md +15 -3
- package/docs/features/tables.md +122 -0
- package/docs/index.md +3 -2
- package/docs/journeys/developer.md +26 -16
- package/docs/journeys/personal-use.md +23 -9
- package/docs/journeys/power-user.md +40 -14
- package/docs/journeys/work-use.md +43 -15
- package/docs/manifest.json +27 -17
- package/package.json +3 -2
- package/src/app/api/chat/entities/search/route.ts +12 -3
- package/src/app/api/documents/[id]/route.ts +5 -1
- package/src/app/api/documents/[id]/versions/route.ts +53 -0
- package/src/app/api/documents/route.ts +5 -1
- package/src/app/api/projects/[id]/documents/route.ts +124 -0
- package/src/app/api/projects/[id]/route.ts +72 -3
- package/src/app/api/projects/__tests__/delete-project.test.ts +13 -0
- package/src/app/api/schedules/route.ts +19 -1
- package/src/app/api/snapshots/[id]/restore/route.ts +62 -0
- package/src/app/api/snapshots/[id]/route.ts +44 -0
- package/src/app/api/snapshots/route.ts +54 -0
- package/src/app/api/snapshots/settings/route.ts +67 -0
- package/src/app/api/tables/[id]/charts/[chartId]/route.ts +89 -0
- package/src/app/api/tables/[id]/charts/route.ts +72 -0
- package/src/app/api/tables/[id]/columns/route.ts +70 -0
- package/src/app/api/tables/[id]/export/route.ts +94 -0
- package/src/app/api/tables/[id]/history/route.ts +15 -0
- package/src/app/api/tables/[id]/import/route.ts +111 -0
- package/src/app/api/tables/[id]/route.ts +86 -0
- package/src/app/api/tables/[id]/rows/[rowId]/history/route.ts +32 -0
- package/src/app/api/tables/[id]/rows/[rowId]/route.ts +51 -0
- package/src/app/api/tables/[id]/rows/route.ts +101 -0
- package/src/app/api/tables/[id]/triggers/[triggerId]/route.ts +65 -0
- package/src/app/api/tables/[id]/triggers/route.ts +122 -0
- package/src/app/api/tables/route.ts +65 -0
- package/src/app/api/tables/templates/route.ts +92 -0
- package/src/app/api/tasks/[id]/route.ts +37 -2
- package/src/app/api/tasks/[id]/siblings/route.ts +48 -0
- package/src/app/api/tasks/route.ts +8 -9
- package/src/app/api/workflows/[id]/documents/route.ts +209 -0
- package/src/app/api/workflows/[id]/execute/route.ts +6 -2
- package/src/app/api/workflows/[id]/route.ts +16 -3
- package/src/app/api/workflows/[id]/status/route.ts +18 -2
- package/src/app/api/workflows/route.ts +13 -2
- package/src/app/documents/page.tsx +5 -1
- package/src/app/layout.tsx +0 -1
- package/src/app/manifest.ts +3 -3
- package/src/app/projects/[id]/page.tsx +62 -2
- package/src/app/settings/page.tsx +2 -0
- package/src/app/tables/[id]/page.tsx +67 -0
- package/src/app/tables/page.tsx +21 -0
- package/src/app/tables/templates/page.tsx +19 -0
- package/src/components/chat/chat-table-result.tsx +139 -0
- package/src/components/documents/document-browser.tsx +1 -1
- package/src/components/documents/document-chip-bar.tsx +17 -1
- package/src/components/documents/document-detail-view.tsx +51 -0
- package/src/components/documents/document-grid.tsx +5 -0
- package/src/components/documents/document-table.tsx +4 -0
- package/src/components/documents/types.ts +3 -0
- package/src/components/projects/project-form-sheet.tsx +109 -2
- package/src/components/schedules/schedule-form.tsx +91 -1
- package/src/components/settings/data-management-section.tsx +17 -12
- package/src/components/settings/database-snapshots-section.tsx +469 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/document-picker-sheet.tsx +486 -0
- package/src/components/tables/table-browser.tsx +234 -0
- package/src/components/tables/table-cell-editor.tsx +226 -0
- package/src/components/tables/table-chart-builder.tsx +288 -0
- package/src/components/tables/table-chart-view.tsx +146 -0
- package/src/components/tables/table-column-header.tsx +103 -0
- package/src/components/tables/table-column-sheet.tsx +331 -0
- package/src/components/tables/table-create-sheet.tsx +240 -0
- package/src/components/tables/table-detail-sheet.tsx +144 -0
- package/src/components/tables/table-detail-tabs.tsx +278 -0
- package/src/components/tables/table-grid.tsx +61 -0
- package/src/components/tables/table-history-tab.tsx +148 -0
- package/src/components/tables/table-import-wizard.tsx +542 -0
- package/src/components/tables/table-list-table.tsx +95 -0
- package/src/components/tables/table-relation-combobox.tsx +217 -0
- package/src/components/tables/table-spreadsheet.tsx +499 -0
- package/src/components/tables/table-template-gallery.tsx +162 -0
- package/src/components/tables/table-template-preview.tsx +219 -0
- package/src/components/tables/table-toolbar.tsx +79 -0
- package/src/components/tables/table-triggers-tab.tsx +446 -0
- package/src/components/tables/types.ts +6 -0
- package/src/components/tables/use-spreadsheet-keys.ts +171 -0
- package/src/components/tables/utils.ts +29 -0
- package/src/components/tasks/task-card.tsx +8 -1
- package/src/components/tasks/task-create-panel.tsx +111 -14
- package/src/components/tasks/task-detail-view.tsx +47 -0
- package/src/components/tasks/task-edit-dialog.tsx +103 -2
- package/src/components/workflows/workflow-form-view.tsx +207 -7
- package/src/components/workflows/workflow-kanban-card.tsx +8 -1
- package/src/components/workflows/workflow-list.tsx +90 -45
- package/src/components/workflows/workflow-status-view.tsx +168 -23
- package/src/instrumentation.ts +3 -0
- package/src/lib/__tests__/npx-process-cwd.test.ts +17 -2
- package/src/lib/agents/__tests__/claude-agent.test.ts +5 -1
- package/src/lib/agents/claude-agent.ts +3 -1
- package/src/lib/agents/profiles/registry.ts +6 -3
- package/src/lib/agents/runtime/anthropic-direct.ts +29 -0
- package/src/lib/agents/runtime/openai-direct.ts +29 -0
- package/src/lib/book/__tests__/chapter-slugs.test.ts +80 -0
- package/src/lib/book/chapter-generator.ts +4 -19
- package/src/lib/book/chapter-mapping.ts +17 -0
- package/src/lib/book/content.ts +5 -16
- package/src/lib/book/update-detector.ts +3 -16
- package/src/lib/chat/engine.ts +1 -0
- package/src/lib/chat/stagent-tools.ts +2 -0
- package/src/lib/chat/system-prompt.ts +9 -1
- package/src/lib/chat/tool-catalog.ts +35 -0
- package/src/lib/chat/tools/settings-tools.ts +109 -0
- package/src/lib/chat/tools/table-tools.ts +955 -0
- package/src/lib/chat/tools/workflow-tools.ts +145 -2
- package/src/lib/constants/table-status.ts +68 -0
- package/src/lib/data/__tests__/clear.test.ts +1 -1
- package/src/lib/data/clear.ts +57 -0
- package/src/lib/data/seed-data/__tests__/profiles.test.ts +28 -23
- package/src/lib/data/seed-data/conversations.ts +350 -42
- package/src/lib/data/seed-data/documents.ts +564 -591
- package/src/lib/data/seed-data/learned-context.ts +101 -22
- package/src/lib/data/seed-data/notifications.ts +344 -70
- package/src/lib/data/seed-data/profile-test-results.ts +92 -11
- package/src/lib/data/seed-data/profiles.ts +144 -46
- package/src/lib/data/seed-data/projects.ts +50 -18
- package/src/lib/data/seed-data/repo-imports.ts +28 -13
- package/src/lib/data/seed-data/schedules.ts +208 -41
- package/src/lib/data/seed-data/table-templates.ts +234 -0
- package/src/lib/data/seed-data/tasks.ts +614 -116
- package/src/lib/data/seed-data/usage-ledger.ts +182 -103
- package/src/lib/data/seed-data/user-tables.ts +203 -0
- package/src/lib/data/seed-data/views.ts +52 -7
- package/src/lib/data/seed-data/workflows.ts +231 -84
- package/src/lib/data/seed.ts +55 -14
- package/src/lib/data/tables.ts +417 -0
- package/src/lib/db/bootstrap.ts +275 -0
- package/src/lib/db/index.ts +9 -0
- package/src/lib/db/migrations/0016_add_workflow_document_inputs.sql +13 -0
- package/src/lib/db/migrations/0017_add_document_picker_tables.sql +25 -0
- package/src/lib/db/migrations/0018_add_workflow_run_number.sql +2 -0
- package/src/lib/db/migrations/0019_add_tables_feature.sql +160 -0
- package/src/lib/db/migrations/0020_add_table_triggers.sql +19 -0
- package/src/lib/db/migrations/0021_add_row_history.sql +15 -0
- package/src/lib/db/schema.ts +445 -0
- package/src/lib/docs/reader.ts +2 -3
- package/src/lib/documents/context-builder.ts +75 -2
- package/src/lib/documents/document-resolver.ts +119 -0
- package/src/lib/documents/processors/spreadsheet.ts +2 -1
- package/src/lib/schedules/scheduler.ts +31 -1
- package/src/lib/snapshots/auto-backup.ts +132 -0
- package/src/lib/snapshots/retention.ts +64 -0
- package/src/lib/snapshots/snapshot-manager.ts +429 -0
- package/src/lib/tables/computed.ts +61 -0
- package/src/lib/tables/context-builder.ts +139 -0
- package/src/lib/tables/formula-engine.ts +415 -0
- package/src/lib/tables/history.ts +115 -0
- package/src/lib/tables/import.ts +343 -0
- package/src/lib/tables/query-builder.ts +152 -0
- package/src/lib/tables/trigger-evaluator.ts +146 -0
- package/src/lib/tables/types.ts +141 -0
- package/src/lib/tables/validation.ts +119 -0
- package/src/lib/utils/app-root.ts +20 -0
- package/src/lib/utils/stagent-paths.ts +20 -0
- package/src/lib/validators/__tests__/task.test.ts +43 -10
- package/src/lib/validators/task.ts +7 -1
- package/src/lib/workflows/blueprints/registry.ts +3 -3
- package/src/lib/workflows/engine.ts +24 -8
- package/src/lib/workflows/types.ts +14 -0
- package/tsconfig.json +3 -1
- package/public/icon.svg +0 -13
- package/src/components/tasks/file-upload.tsx +0 -120
- /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
|
+
}
|