@saadnvd1/agent-os 0.3.595 → 0.3.597
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 +1 -0
- package/app/api/sessions/[id]/artifacts/[artifactId]/route.ts +34 -0
- package/app/api/sessions/[id]/artifacts/route.ts +25 -9
- package/components/Artifacts/ArtifactPreview.tsx +239 -0
- package/components/Artifacts/ArtifactRow.tsx +147 -0
- package/components/Artifacts/ArtifactsPanel.tsx +244 -0
- package/components/Artifacts/helpers.test.ts +90 -0
- package/components/Artifacts/helpers.ts +99 -0
- package/components/Artifacts/index.ts +1 -0
- package/components/Chat/ArtifactFrame.tsx +4 -1
- package/components/Pane/DesktopTabBar.tsx +2 -2
- package/components/Pane/MobileTabBar.tsx +14 -2
- package/components/Pane/PaneViewToggle.tsx +20 -3
- package/components/Pane/index.tsx +35 -3
- package/contexts/PaneContext.tsx +7 -1
- package/data/session-artifacts/index.ts +2 -0
- package/data/session-artifacts/keys.ts +7 -0
- package/data/session-artifacts/queries.ts +53 -0
- package/lib/chat/task-output.ts +1 -1
- package/lib/db/migration-types.ts +15 -0
- package/lib/db/migrations/0001_add_group_path_to_sessions.ts +11 -0
- package/lib/db/migrations/0002_add_agent_type_to_sessions.ts +11 -0
- package/lib/db/migrations/0003_add_worktree_columns_to_sessions.ts +12 -0
- package/lib/db/migrations/0004_add_pr_tracking_to_sessions.ts +11 -0
- package/lib/db/migrations/0005_add_group_path_index.ts +11 -0
- package/lib/db/migrations/0006_add_orchestration_columns_to_sessions.ts +16 -0
- package/lib/db/migrations/0007_add_auto_approve_to_sessions.ts +11 -0
- package/lib/db/migrations/0008_add_dev_server_columns.ts +19 -0
- package/lib/db/migrations/0009_add_project_id_to_sessions.ts +17 -0
- package/lib/db/migrations/0010_add_project_id_to_dev_servers.ts +36 -0
- package/lib/db/migrations/0011_add_tmux_name_to_sessions.ts +13 -0
- package/lib/db/migrations/0012_add_initial_prompt_to_projects.ts +9 -0
- package/lib/db/migrations/0013_add_project_repositories_table.ts +22 -0
- package/lib/db/migrations/0014_add_hosts.ts +23 -0
- package/lib/db/migrations/0015_add_workspaces.ts +18 -0
- package/lib/db/migrations/0016_add_task_columns_to_sessions.ts +13 -0
- package/lib/db/migrations/0017_add_bus_messages.ts +24 -0
- package/lib/db/migrations/0018_add_chat.ts +23 -0
- package/lib/db/migrations/0019_add_lumifyhub.ts +36 -0
- package/lib/db/migrations/0020_add_lumifyhub_published_docs.ts +18 -0
- package/lib/db/migrations/0021_add_chat_access.ts +14 -0
- package/lib/db/migrations/0022_add_last_seen.ts +10 -0
- package/lib/db/migrations/0023_mark_existing_sessions_seen.ts +12 -0
- package/lib/db/migrations/0024_add_stacks.ts +49 -0
- package/lib/db/migrations/0025_add_stack_item_attempts.ts +16 -0
- package/lib/db/migrations/0026_add_workspace_orchestrator.ts +47 -0
- package/lib/db/migrations/0027_add_devices_and_settings.ts +28 -0
- package/lib/db/migrations/0028_add_orchestrator_acting.ts +70 -0
- package/lib/db/migrations/0029_add_orchestrator_asks_and_pause.ts +81 -0
- package/lib/db/migrations/0030_add_sessions_archived_at.ts +14 -0
- package/lib/db/migrations/0031_add_stack_item_restacked_heads.ts +22 -0
- package/lib/db/migrations/0032_add_sessions_pinned.ts +12 -0
- package/lib/db/migrations/0033_add_program_status.ts +18 -0
- package/lib/db/migrations/0034_add_session_names.ts +21 -0
- package/lib/db/migrations/0035_add_artifacts.ts +23 -0
- package/lib/db/migrations/0036_add_chat_queue.ts +26 -0
- package/lib/db/migrations/0037_add_chat_plan_and_turns.ts +39 -0
- package/lib/db/migrations/0038_add_schedules.ts +56 -0
- package/lib/db/migrations/0039_add_session_name_source.ts +21 -0
- package/lib/db/migrations/0040_add_schedule_targets_and_phone_notify.ts +41 -0
- package/lib/db/migrations/0041_add_task_setup.ts +17 -0
- package/lib/db/migrations/0042_add_host_links_and_moved_tasks.ts +29 -0
- package/lib/db/migrations/0043_scratch_project.ts +14 -0
- package/lib/db/migrations/0044_chat_item_kind.ts +21 -0
- package/lib/db/migrations/0045_change_versions.ts +13 -0
- package/lib/db/migrations/0046_session_port_slots.ts +21 -0
- package/lib/db/migrations/0047_pin_orchestrators.ts +12 -0
- package/lib/db/migrations/0048_session_database.ts +15 -0
- package/lib/db/migrations/0049_peer_mirror.ts +14 -0
- package/lib/db/migrations/0050_task_queue.ts +44 -0
- package/lib/db/migrations/0051_chat_queue_sent_by.ts +16 -0
- package/lib/db/migrations/0052_sessions_chat_restarted.ts +17 -0
- package/lib/db/migrations/0053_brief_amendments_failure_sha_ask_raised_by.ts +37 -0
- package/lib/db/migrations/0054_sessions_chat_tools_digest.ts +15 -0
- package/lib/db/migrations/0055_sessions_merge_armed_and_rebase.ts +25 -0
- package/lib/db/migrations/0056_orchestrator_rules.ts +39 -0
- package/lib/db/migrations/0057_task_pr_wakes.ts +34 -0
- package/lib/db/migrations/0058_away_mode.ts +71 -0
- package/lib/db/migrations/0059_gate_failure_commits.ts +33 -0
- package/lib/db/migrations/0060_check_runs_and_compactions.ts +44 -0
- package/lib/db/migrations/0061_model_routing.ts +30 -0
- package/lib/db/migrations/0062_sessions_quick_of.ts +19 -0
- package/lib/db/migrations/0063_one_off_schedules.ts +16 -0
- package/lib/db/migrations/0064_chat_compactions_phase.ts +15 -0
- package/lib/db/migrations/0065_orchestrator_triage.ts +23 -0
- package/lib/db/migrations/0066_workspace_orch_mcp_servers.ts +16 -0
- package/lib/db/migrations/0067_orchestrator_event_log_suppressed.ts +14 -0
- package/lib/db/migrations/0068_usage_calls_and_rollups.ts +74 -0
- package/lib/db/migrations/0069_session_logs.ts +26 -0
- package/lib/db/migrations/0070_chat_items_running.ts +15 -0
- package/lib/db/migrations/0071_devices_push.ts +29 -0
- package/lib/db/migrations/0072_push_outbox.ts +33 -0
- package/lib/db/migrations/0073_orchestrated_task_moves.ts +27 -0
- package/lib/db/migrations/0074_held_task_replies.ts +27 -0
- package/lib/db/migrations/0075_plan_only_tasks.ts +17 -0
- package/lib/db/migrations/0076_welcome_backfill.ts +16 -0
- package/lib/db/migrations/0077_simulator_leases.ts +21 -0
- package/lib/db/migrations/0078_orchestrator_questions.ts +34 -0
- package/lib/db/migrations/0079_task_queue_agent.ts +13 -0
- package/lib/db/migrations/0080_recipes.ts +129 -0
- package/lib/db/migrations/0081_schedules_into_recipes.ts +18 -0
- package/lib/db/migrations/0082_session_shelf_times.ts +23 -0
- package/lib/db/migrations/0083_native_devices_can_approve.ts +18 -0
- package/lib/db/migrations/0084_approval_audit.ts +20 -0
- package/lib/db/migrations/0085_experiment_usage.ts +35 -0
- package/lib/db/migrations/0086_learning_cards.ts +28 -0
- package/lib/db/migrations/0087_plan_pieces_reconcile.ts +29 -0
- package/lib/db/migrations/0088_shared_links.ts +25 -0
- package/lib/db/migrations/0089_passkeys_device_keys.ts +23 -0
- package/lib/db/migrations/20261014T0900_task_scheduling.ts +57 -0
- package/lib/db/migrations/20261014T1235_placed_mirror_started_by.ts +19 -0
- package/lib/db/migrations/20261014T1600_queued_task_messages.ts +21 -0
- package/lib/db/migrations/20261014T1800_task_starter_device.ts +20 -0
- package/lib/db/migrations/20261014T1900_pause_note.ts +13 -0
- package/lib/db/migrations/20261014T2000_chat_bookmarks.ts +33 -0
- package/lib/db/migrations/20261014T2100_gate_failure_ask_held.ts +18 -0
- package/lib/db/migrations/README.md +35 -0
- package/lib/db/migrations-legacy-checksums.json +9 -8
- package/lib/db/migrations-split-snapshot.json +1759 -0
- package/lib/db/migrations.ts +76 -2337
- package/lib/done/worktree.ts +3 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/access.ts +81 -0
- package/lib/session-artifacts/collect.ts +318 -0
- package/lib/session-artifacts/derive.ts +437 -0
- package/lib/session-artifacts/keep.ts +119 -0
- package/lib/session-artifacts/peer.ts +187 -0
- package/lib/session-artifacts/serve.ts +265 -0
- package/lib/session-artifacts/types.ts +43 -0
- package/lib/tasks/finish.ts +2 -0
- package/package.json +6 -3
- package/scripts/check +1 -0
- package/scripts/gen-migrations-index.mjs +44 -0
- package/scripts/screenshots/demo.ts +5 -1
- package/scripts/screenshots/run.ts +5 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A session's artifacts from what AgentOS already stored about it: its chat
|
|
3
|
+
* items, PR, recipe run and log. No IO here; collect.ts reads the rows and
|
|
4
|
+
* the files, this decides what they are.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { isSecretPath } from "../secret-paths";
|
|
11
|
+
import type {
|
|
12
|
+
ArtifactKind,
|
|
13
|
+
ArtifactPreview,
|
|
14
|
+
ArtifactState,
|
|
15
|
+
SessionArtifact,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
// A chat item as collect.ts selects it: only the fields that matter, never
|
|
19
|
+
// a tool's output or a Write's content.
|
|
20
|
+
export interface ItemRow {
|
|
21
|
+
item_id: string;
|
|
22
|
+
kind: string;
|
|
23
|
+
at: number | null;
|
|
24
|
+
name?: string | null;
|
|
25
|
+
status?: string | null;
|
|
26
|
+
file_path?: string | null;
|
|
27
|
+
notebook_path?: string | null;
|
|
28
|
+
input_path?: string | null;
|
|
29
|
+
file_path_camel?: string | null;
|
|
30
|
+
changes?: string | null;
|
|
31
|
+
artifact_id?: string | null;
|
|
32
|
+
title?: string | null;
|
|
33
|
+
task_id?: string | null;
|
|
34
|
+
output_file?: string | null;
|
|
35
|
+
task_type?: string | null;
|
|
36
|
+
subagent_type?: string | null;
|
|
37
|
+
description?: string | null;
|
|
38
|
+
ambient?: number | null;
|
|
39
|
+
image_count?: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type Source =
|
|
43
|
+
| { type: "file"; path: string }
|
|
44
|
+
| { type: "page"; artifactId: string }
|
|
45
|
+
| { type: "attached"; itemId: string; index: number }
|
|
46
|
+
| { type: "log"; taskId: string; path?: string }
|
|
47
|
+
| { type: "plan"; path?: string; chatItemId?: string }
|
|
48
|
+
| { type: "pr" }
|
|
49
|
+
| { type: "run"; runId: number }
|
|
50
|
+
| { type: "session-log" };
|
|
51
|
+
|
|
52
|
+
// Before collect.ts has looked at the disk.
|
|
53
|
+
export interface Candidate {
|
|
54
|
+
key: string;
|
|
55
|
+
kind: ArtifactKind;
|
|
56
|
+
name: string;
|
|
57
|
+
at: number;
|
|
58
|
+
edits?: number;
|
|
59
|
+
by?: "you" | "shell";
|
|
60
|
+
detail?: string;
|
|
61
|
+
url?: string;
|
|
62
|
+
source: Source;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Entry {
|
|
66
|
+
artifact: SessionArtifact;
|
|
67
|
+
source: Source;
|
|
68
|
+
// The file to serve: the live one, or its kept copy. Never sent out.
|
|
69
|
+
read?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Tools that write the file they're given.
|
|
73
|
+
const EDIT_TOOLS = new Set([
|
|
74
|
+
"Write",
|
|
75
|
+
"Edit",
|
|
76
|
+
"MultiEdit",
|
|
77
|
+
"NotebookEdit",
|
|
78
|
+
"write",
|
|
79
|
+
"edit",
|
|
80
|
+
]);
|
|
81
|
+
// Other tools whose file argument is something they made (a screenshot).
|
|
82
|
+
const MAKES_FILE = /screenshot|capture|snapshot|save|export|download|render/i;
|
|
83
|
+
|
|
84
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|avif|svg)$/i;
|
|
85
|
+
const MARKDOWN_EXT = /\.(md|markdown|mdx)$/i;
|
|
86
|
+
const HTML_EXT = /\.html?$/i;
|
|
87
|
+
|
|
88
|
+
export const artifactId = (key: string) =>
|
|
89
|
+
createHash("sha1").update(key).digest("hex").slice(0, 16);
|
|
90
|
+
|
|
91
|
+
export function previewFor(file: string): ArtifactPreview {
|
|
92
|
+
if (IMAGE_EXT.test(file)) return "image";
|
|
93
|
+
if (MARKDOWN_EXT.test(file)) return "markdown";
|
|
94
|
+
if (HTML_EXT.test(file)) return "html";
|
|
95
|
+
return "text";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const kindForPath = (file: string): ArtifactKind =>
|
|
99
|
+
IMAGE_EXT.test(file) ? "image" : "file";
|
|
100
|
+
|
|
101
|
+
// Absolute, with ~ and relative paths resolved against the session's folder.
|
|
102
|
+
export function resolvePath(
|
|
103
|
+
p: string,
|
|
104
|
+
cwd: string | null,
|
|
105
|
+
home = os.homedir()
|
|
106
|
+
): string | null {
|
|
107
|
+
const s = p.trim();
|
|
108
|
+
if (!s || s.includes("\0")) return null;
|
|
109
|
+
const expanded = s === "~" || s.startsWith("~/") ? home + s.slice(1) : s;
|
|
110
|
+
if (path.isAbsolute(expanded)) return path.normalize(expanded);
|
|
111
|
+
return cwd ? path.resolve(cwd, expanded) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const tildePath = (p: string, home = os.homedir()) =>
|
|
115
|
+
p === home || p.startsWith(home + path.sep) ? "~" + p.slice(home.length) : p;
|
|
116
|
+
|
|
117
|
+
function changedPaths(changes: string | null | undefined): string[] {
|
|
118
|
+
if (!changes) return [];
|
|
119
|
+
try {
|
|
120
|
+
const list = JSON.parse(changes);
|
|
121
|
+
return Array.isArray(list)
|
|
122
|
+
? list.filter((p): p is string => typeof p === "string")
|
|
123
|
+
: [];
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The files a tool call wrote, by its name and input.
|
|
130
|
+
export function toolPaths(row: ItemRow): string[] {
|
|
131
|
+
const name = row.name ?? "";
|
|
132
|
+
if (EDIT_TOOLS.has(name))
|
|
133
|
+
return [
|
|
134
|
+
row.file_path,
|
|
135
|
+
row.notebook_path,
|
|
136
|
+
row.input_path,
|
|
137
|
+
row.file_path_camel,
|
|
138
|
+
...changedPaths(row.changes),
|
|
139
|
+
].filter((p): p is string => !!p);
|
|
140
|
+
if (MAKES_FILE.test(name))
|
|
141
|
+
return [row.file_path, row.file_path_camel, row.input_path].filter(
|
|
142
|
+
(p): p is string => !!p && IMAGE_EXT.test(p)
|
|
143
|
+
);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Every artifact the chat items name, grouped: one row per file however
|
|
148
|
+
// many times it was edited.
|
|
149
|
+
export function fromItems(
|
|
150
|
+
rows: ItemRow[],
|
|
151
|
+
cwd: string | null,
|
|
152
|
+
home = os.homedir()
|
|
153
|
+
): Candidate[] {
|
|
154
|
+
const out = new Map<string, Candidate>();
|
|
155
|
+
let chatPlan: Candidate | null = null;
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
const at = row.at ?? 0;
|
|
158
|
+
if (row.kind === "tool") {
|
|
159
|
+
if (row.status === "error") continue;
|
|
160
|
+
for (const raw of toolPaths(row)) {
|
|
161
|
+
const file = resolvePath(raw, cwd, home);
|
|
162
|
+
if (!file || isSecretPath(file)) continue;
|
|
163
|
+
const key = `file:${file}`;
|
|
164
|
+
const hit = out.get(key);
|
|
165
|
+
const edit = EDIT_TOOLS.has(row.name ?? "") ? 1 : 0;
|
|
166
|
+
if (hit) {
|
|
167
|
+
hit.at = Math.max(hit.at, at);
|
|
168
|
+
hit.edits = (hit.edits ?? 0) + edit;
|
|
169
|
+
} else
|
|
170
|
+
out.set(key, {
|
|
171
|
+
key,
|
|
172
|
+
kind: kindForPath(file),
|
|
173
|
+
name: path.basename(file),
|
|
174
|
+
at,
|
|
175
|
+
edits: edit || undefined,
|
|
176
|
+
source: { type: "file", path: file },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
} else if (row.kind === "artifact" && row.artifact_id) {
|
|
180
|
+
const key = `page:${row.artifact_id}`;
|
|
181
|
+
out.set(key, {
|
|
182
|
+
key,
|
|
183
|
+
kind: "page",
|
|
184
|
+
name: row.title || "Untitled page",
|
|
185
|
+
at,
|
|
186
|
+
detail: "rendered page",
|
|
187
|
+
source: { type: "page", artifactId: row.artifact_id },
|
|
188
|
+
});
|
|
189
|
+
} else if (row.kind === "task" && row.task_id) {
|
|
190
|
+
// A subagent's output file is its whole transcript, not a log.
|
|
191
|
+
if (row.ambient || row.subagent_type) continue;
|
|
192
|
+
if (row.task_type !== "local_bash") continue;
|
|
193
|
+
const key = `log:${row.task_id}`;
|
|
194
|
+
out.set(key, {
|
|
195
|
+
key,
|
|
196
|
+
kind: "log",
|
|
197
|
+
name: row.description || "Background job",
|
|
198
|
+
at,
|
|
199
|
+
detail: `background job · ${row.status ?? "running"}`,
|
|
200
|
+
source: {
|
|
201
|
+
type: "log",
|
|
202
|
+
taskId: row.task_id,
|
|
203
|
+
path: row.output_file ?? undefined,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
} else if (row.kind === "user" && row.image_count) {
|
|
207
|
+
for (let i = 0; i < row.image_count; i++) {
|
|
208
|
+
const key = `attached:${row.item_id}:${i}`;
|
|
209
|
+
out.set(key, {
|
|
210
|
+
key,
|
|
211
|
+
kind: "image",
|
|
212
|
+
name:
|
|
213
|
+
row.image_count > 1 ? `Attached image ${i + 1}` : "Attached image",
|
|
214
|
+
at,
|
|
215
|
+
by: "you",
|
|
216
|
+
source: { type: "attached", itemId: row.item_id, index: i },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
} else if (row.kind === "plan") {
|
|
220
|
+
chatPlan = {
|
|
221
|
+
key: "plan",
|
|
222
|
+
kind: "plan",
|
|
223
|
+
name: "Plan",
|
|
224
|
+
at,
|
|
225
|
+
source: { type: "plan", chatItemId: row.item_id },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (chatPlan) out.set("plan", chatPlan);
|
|
230
|
+
return [...out.values()];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Paths from `git status --porcelain -z` in the worktree: what a shell
|
|
234
|
+
// command made or changed there. Deleted files aren't artifacts.
|
|
235
|
+
export function parsePorcelain(out: string, max = 300): string[] {
|
|
236
|
+
const parts = out.split("\0");
|
|
237
|
+
const files: string[] = [];
|
|
238
|
+
for (let i = 0; i < parts.length && files.length < max; i++) {
|
|
239
|
+
const entry = parts[i];
|
|
240
|
+
if (entry.length < 4) continue;
|
|
241
|
+
const xy = entry.slice(0, 2);
|
|
242
|
+
// A rename's old path follows as its own field.
|
|
243
|
+
if (xy[0] === "R" || xy[0] === "C") i++;
|
|
244
|
+
if (xy.includes("D")) continue;
|
|
245
|
+
const rel = entry.slice(3);
|
|
246
|
+
if (rel.endsWith("/")) continue;
|
|
247
|
+
files.push(rel);
|
|
248
|
+
}
|
|
249
|
+
return files;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function fromStatus(
|
|
253
|
+
rels: string[],
|
|
254
|
+
worktree: string,
|
|
255
|
+
at: number,
|
|
256
|
+
known: Set<string>
|
|
257
|
+
): Candidate[] {
|
|
258
|
+
const out: Candidate[] = [];
|
|
259
|
+
for (const rel of rels) {
|
|
260
|
+
const file = path.resolve(worktree, rel);
|
|
261
|
+
const key = `file:${file}`;
|
|
262
|
+
if (known.has(key) || isSecretPath(file)) continue;
|
|
263
|
+
out.push({
|
|
264
|
+
key,
|
|
265
|
+
kind: kindForPath(file),
|
|
266
|
+
name: path.basename(file),
|
|
267
|
+
at,
|
|
268
|
+
by: "shell",
|
|
269
|
+
source: { type: "file", path: file },
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The plan: the worktree's plan file when there is one (its row replaces
|
|
276
|
+
// the file's), else the plan the chat proposed.
|
|
277
|
+
export function mergePlan(
|
|
278
|
+
candidates: Candidate[],
|
|
279
|
+
planFile: string | null
|
|
280
|
+
): Candidate[] {
|
|
281
|
+
if (!planFile) return candidates;
|
|
282
|
+
const fileKey = `file:${planFile}`;
|
|
283
|
+
const asFile = candidates.find((c) => c.key === fileKey);
|
|
284
|
+
const chat = candidates.find((c) => c.key === "plan");
|
|
285
|
+
const plan: Candidate = {
|
|
286
|
+
key: "plan",
|
|
287
|
+
kind: "plan",
|
|
288
|
+
name: "Plan",
|
|
289
|
+
at: Math.max(asFile?.at ?? 0, chat?.at ?? 0),
|
|
290
|
+
edits: asFile?.edits,
|
|
291
|
+
source: {
|
|
292
|
+
type: "plan",
|
|
293
|
+
path: planFile,
|
|
294
|
+
chatItemId:
|
|
295
|
+
chat?.source.type === "plan" ? chat.source.chatItemId : undefined,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
return [
|
|
299
|
+
plan,
|
|
300
|
+
...candidates.filter((c) => c.key !== fileKey && c.key !== "plan"),
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface PrInfo {
|
|
305
|
+
pr_url: string | null;
|
|
306
|
+
pr_number: number | null;
|
|
307
|
+
pr_status: string | null;
|
|
308
|
+
at: number;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function fromPr(pr: PrInfo): Candidate | null {
|
|
312
|
+
if (!pr.pr_url && !pr.pr_number) return null;
|
|
313
|
+
return {
|
|
314
|
+
key: "pr",
|
|
315
|
+
kind: "pr",
|
|
316
|
+
name: pr.pr_number ? `PR #${pr.pr_number}` : "Pull request",
|
|
317
|
+
at: pr.at,
|
|
318
|
+
detail: pr.pr_status ?? "open",
|
|
319
|
+
url: pr.pr_url ?? undefined,
|
|
320
|
+
source: { type: "pr" },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface RunRow {
|
|
325
|
+
id: number;
|
|
326
|
+
recipe_name: string | null;
|
|
327
|
+
status: string;
|
|
328
|
+
at: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export const fromRun = (run: RunRow): Candidate => ({
|
|
332
|
+
key: `run:${run.id}`,
|
|
333
|
+
kind: "run",
|
|
334
|
+
name: run.recipe_name ? `Recipe run: ${run.recipe_name}` : "Recipe run",
|
|
335
|
+
at: run.at,
|
|
336
|
+
detail: run.status,
|
|
337
|
+
source: { type: "run", runId: run.id },
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
export const fromSessionLog = (at: number): Candidate => ({
|
|
341
|
+
key: "session-log",
|
|
342
|
+
kind: "session-log",
|
|
343
|
+
name: "Session log",
|
|
344
|
+
at,
|
|
345
|
+
source: { type: "session-log" },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// What collect.ts found on disk for a file-backed candidate.
|
|
349
|
+
export interface FileFacts {
|
|
350
|
+
// Its size when it's there and a regular file.
|
|
351
|
+
size?: number;
|
|
352
|
+
// Inside the roots the app serves (lib/session-artifacts/access).
|
|
353
|
+
allowed: boolean;
|
|
354
|
+
// The kept copy, when the worktree went and one was made.
|
|
355
|
+
keptSize?: number;
|
|
356
|
+
// Committed when its worktree went (the kept manifest says so).
|
|
357
|
+
committed?: boolean;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function stateOf(facts: FileFacts): {
|
|
361
|
+
state: ArtifactState;
|
|
362
|
+
size?: number;
|
|
363
|
+
} {
|
|
364
|
+
if (facts.size !== undefined)
|
|
365
|
+
return facts.allowed
|
|
366
|
+
? { state: "live", size: facts.size }
|
|
367
|
+
: { state: "outside", size: facts.size };
|
|
368
|
+
if (facts.keptSize !== undefined)
|
|
369
|
+
return { state: "kept", size: facts.keptSize };
|
|
370
|
+
if (facts.committed) return { state: "in-pr" };
|
|
371
|
+
return { state: "gone" };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function toArtifact(
|
|
375
|
+
c: Candidate,
|
|
376
|
+
file: {
|
|
377
|
+
state: ArtifactState;
|
|
378
|
+
size?: number;
|
|
379
|
+
path?: string;
|
|
380
|
+
read?: string;
|
|
381
|
+
} | null,
|
|
382
|
+
home = os.homedir()
|
|
383
|
+
): Entry {
|
|
384
|
+
const filePath = file?.path;
|
|
385
|
+
const preview: ArtifactPreview =
|
|
386
|
+
c.kind === "page"
|
|
387
|
+
? "html"
|
|
388
|
+
: c.kind === "log"
|
|
389
|
+
? "text"
|
|
390
|
+
: c.kind === "plan" || c.kind === "run" || c.kind === "session-log"
|
|
391
|
+
? "markdown"
|
|
392
|
+
: c.kind === "pr"
|
|
393
|
+
? "markdown"
|
|
394
|
+
: c.source.type === "attached"
|
|
395
|
+
? "image"
|
|
396
|
+
: filePath
|
|
397
|
+
? previewFor(filePath)
|
|
398
|
+
: "none";
|
|
399
|
+
return {
|
|
400
|
+
source: c.source,
|
|
401
|
+
...(file?.read && { read: file.read }),
|
|
402
|
+
artifact: {
|
|
403
|
+
id: artifactId(c.key),
|
|
404
|
+
kind: c.kind,
|
|
405
|
+
name: c.name,
|
|
406
|
+
...(filePath && { path: tildePath(filePath, home) }),
|
|
407
|
+
at: c.at,
|
|
408
|
+
...(file?.size !== undefined && { size: file.size }),
|
|
409
|
+
...(c.edits && { edits: c.edits }),
|
|
410
|
+
state: file?.state ?? "live",
|
|
411
|
+
preview,
|
|
412
|
+
...(c.by && { by: c.by }),
|
|
413
|
+
...(c.detail && { detail: c.detail }),
|
|
414
|
+
...(c.url && { url: c.url }),
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const ORDER: ArtifactKind[] = [
|
|
420
|
+
"plan",
|
|
421
|
+
"pr",
|
|
422
|
+
"run",
|
|
423
|
+
"session-log",
|
|
424
|
+
"page",
|
|
425
|
+
"file",
|
|
426
|
+
"image",
|
|
427
|
+
"log",
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
// Grouped by kind, newest first within one.
|
|
431
|
+
export function sortEntries(entries: Entry[]): Entry[] {
|
|
432
|
+
return [...entries].sort(
|
|
433
|
+
(a, b) =>
|
|
434
|
+
ORDER.indexOf(a.artifact.kind) - ORDER.indexOf(b.artifact.kind) ||
|
|
435
|
+
b.artifact.at - a.artifact.at
|
|
436
|
+
);
|
|
437
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies of a session's uncommitted artifacts, made just before its
|
|
3
|
+
* worktree is removed: a plan or mockup under .agentos/ is ignored by git,
|
|
4
|
+
* so nothing else holds it once the worktree goes. Committed files need no
|
|
5
|
+
* copy; the manifest records them so the view can say "in the PR".
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { git } from "../git-exec";
|
|
12
|
+
import { isSecretPath } from "../secret-paths";
|
|
13
|
+
|
|
14
|
+
export const MAX_KEPT_FILE = 2 * 1024 * 1024;
|
|
15
|
+
export const MAX_KEPT_TOTAL = 20 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export interface Manifest {
|
|
18
|
+
worktree: string;
|
|
19
|
+
at: string;
|
|
20
|
+
// Paths relative to the worktree.
|
|
21
|
+
tracked: string[];
|
|
22
|
+
kept: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function keptDir(): string {
|
|
26
|
+
return (
|
|
27
|
+
process.env.AGENTOS_KEPT_DIR || path.join(os.homedir(), ".agent-os", "kept")
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const safeId = (sessionId: string) => sessionId.replace(/[^\w-]/g, "_");
|
|
32
|
+
|
|
33
|
+
export const sessionKeptDir = (sessionId: string) =>
|
|
34
|
+
path.join(keptDir(), safeId(sessionId));
|
|
35
|
+
|
|
36
|
+
export function readManifest(sessionId: string): Manifest | null {
|
|
37
|
+
try {
|
|
38
|
+
const m = JSON.parse(
|
|
39
|
+
fs.readFileSync(
|
|
40
|
+
path.join(sessionKeptDir(sessionId), "manifest.json"),
|
|
41
|
+
"utf8"
|
|
42
|
+
)
|
|
43
|
+
) as Manifest;
|
|
44
|
+
return Array.isArray(m.tracked) && Array.isArray(m.kept) ? m : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A worktree-relative path that stays inside it, else null.
|
|
51
|
+
export function relInside(worktree: string, file: string): string | null {
|
|
52
|
+
const rel = path.relative(worktree, file);
|
|
53
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
54
|
+
return rel;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keep what `files` (absolute paths in the worktree) holds that git doesn't.
|
|
59
|
+
* Never throws: a failure here must not stop the worktree's removal.
|
|
60
|
+
*/
|
|
61
|
+
export async function keepFiles(
|
|
62
|
+
sessionId: string,
|
|
63
|
+
worktree: string,
|
|
64
|
+
files: string[]
|
|
65
|
+
): Promise<Manifest | null> {
|
|
66
|
+
try {
|
|
67
|
+
const rels = [
|
|
68
|
+
...new Set(
|
|
69
|
+
files
|
|
70
|
+
.map((f) => relInside(worktree, f))
|
|
71
|
+
.filter((r): r is string => !!r && !isSecretPath(r))
|
|
72
|
+
),
|
|
73
|
+
];
|
|
74
|
+
if (!rels.length) return null;
|
|
75
|
+
const out = await git(worktree, ["ls-files", "-z", "--", ...rels], {
|
|
76
|
+
timeout: 10_000,
|
|
77
|
+
literalPathspecs: true,
|
|
78
|
+
}).catch(() => "");
|
|
79
|
+
const tracked = new Set(out.split("\0").filter(Boolean));
|
|
80
|
+
const dir = sessionKeptDir(sessionId);
|
|
81
|
+
const kept: string[] = [];
|
|
82
|
+
let total = 0;
|
|
83
|
+
for (const rel of rels) {
|
|
84
|
+
if (tracked.has(rel)) continue;
|
|
85
|
+
const src = path.join(worktree, rel);
|
|
86
|
+
try {
|
|
87
|
+
const stat = await fs.promises.lstat(src);
|
|
88
|
+
if (!stat.isFile() || stat.size > MAX_KEPT_FILE) continue;
|
|
89
|
+
if (total + stat.size > MAX_KEPT_TOTAL) continue;
|
|
90
|
+
const dest = path.join(dir, rel);
|
|
91
|
+
await fs.promises.mkdir(path.dirname(dest), {
|
|
92
|
+
recursive: true,
|
|
93
|
+
mode: 0o700,
|
|
94
|
+
});
|
|
95
|
+
await fs.promises.copyFile(src, dest);
|
|
96
|
+
total += stat.size;
|
|
97
|
+
kept.push(rel);
|
|
98
|
+
} catch {
|
|
99
|
+
// One file failing keeps the rest.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const manifest: Manifest = {
|
|
103
|
+
worktree,
|
|
104
|
+
at: new Date().toISOString(),
|
|
105
|
+
tracked: [...tracked],
|
|
106
|
+
kept,
|
|
107
|
+
};
|
|
108
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
109
|
+
await fs.promises.writeFile(
|
|
110
|
+
path.join(dir, "manifest.json"),
|
|
111
|
+
JSON.stringify(manifest),
|
|
112
|
+
{ mode: 0o600 }
|
|
113
|
+
);
|
|
114
|
+
return manifest;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error(`[session-artifacts] keeping ${sessionId}:`, error);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|