Skip to content

Commit 4d7f765

Browse files
jom-sqampcode-com
andcommitted
refactor(server): migrate thread storage from filesystem to Amp API
Amp stopped writing thread JSON files to disk (~March 31 2026). New threads exist only on the Amp server, causing our app to see 25 of 500+ threads. Changes: - Add threadProvider.ts with listAllThreads() (uses listThreads internal API) and readThreadFile() (local file → amp threads export fallback) - Rewrite getThreads() to use API instead of readdir+readFile scan - Migrate all 12 single-file readers to use readThreadFile() - Fix deleteThread auth bug: switch to 'amp threads delete' CLI - Fix search: use 'amp threads search --json' CLI - Remove 'free' agent mode (removed by Amp) - Handle visibility casing change (Private→private) - Guard truncate/undo for server-only threads with clear error - Update workspaces to use API tree data directly - Skip file watcher for server-only threads Co-authored-by: Amp <amp@ampcode.com>
1 parent 38580fe commit 4d7f765

21 files changed

+382
-560
lines changed

server/lib/amp-api.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,52 @@ export async function callAmpInternalAPI<T = unknown>(
130130
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TODO: wrap lastError in a proper Error
131131
throw lastError;
132132
}
133+
134+
// ── listThreads internal API ────────────────────────────────────────────
135+
136+
export interface AmpThreadTree {
137+
uri: string;
138+
displayName: string;
139+
repository?: { url: string; ref?: string; sha?: string; type?: string };
140+
}
141+
142+
export interface AmpThreadSummary {
143+
id: string;
144+
v: number;
145+
title: string;
146+
created: number;
147+
userLastInteractedAt: number;
148+
messageCount: number;
149+
agentMode: string;
150+
archived: boolean;
151+
creatorUserID: string;
152+
usesDtw: boolean;
153+
env: {
154+
initial: {
155+
trees: AmpThreadTree[];
156+
};
157+
};
158+
relationships: Array<{
159+
type: string;
160+
role: 'parent' | 'child';
161+
threadID: string;
162+
comment?: string;
163+
}>;
164+
summaryStats?: {
165+
diffStats?: { added: number; changed: number; deleted: number };
166+
messageCount: number;
167+
};
168+
meta: {
169+
visibility: string;
170+
sharedGroupIDs: string[];
171+
};
172+
}
173+
174+
interface ListThreadsResult {
175+
threads: AmpThreadSummary[];
176+
}
177+
178+
export async function listThreads(limit = 500): Promise<AmpThreadSummary[]> {
179+
const result = await callAmpInternalAPI<ListThreadsResult>('listThreads', { limit });
180+
return result.threads;
181+
}

server/lib/contextAnalyze.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { readFile } from 'fs/promises';
2-
import { join } from 'path';
3-
import { THREADS_DIR, type ThreadFile, isToolUseContent } from './threadTypes.js';
1+
import { isToolUseContent } from './threadTypes.js';
2+
import { readThreadFile } from './threadProvider.js';
43
import {
54
calculateCost,
65
isHiddenCostTool,
@@ -53,9 +52,7 @@ export interface ContextAnalysis {
5352
}
5453

5554
export async function analyzeContext(threadId: string): Promise<ContextAnalysis> {
56-
const threadPath = join(THREADS_DIR, `${threadId}.json`);
57-
const content = await readFile(threadPath, 'utf-8');
58-
const data = JSON.parse(content) as ThreadFile;
55+
const data = await readThreadFile(threadId);
5956

6057
const tags = data.env?.initial?.tags || [];
6158
const modelTag = tags.find((t: string) => t.startsWith('model:'));

server/lib/git-activity.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { spawn, ChildProcess } from 'child_process';
22
import { readFile, writeFile, mkdir, stat } from 'fs/promises';
3-
import type { Stats } from 'fs';
43
import { join, dirname, isAbsolute, relative, basename } from 'path';
54
import { AMP_HOME } from './constants.js';
65
import { THREADS_DIR } from './threadTypes.js';
6+
import { readThreadFile } from './threadProvider.js';
77
import type {
88
ThreadGitActivity,
99
WorkspaceGitActivity,
@@ -578,12 +578,18 @@ export async function getThreadGitActivity(
578578
threadId: string,
579579
forceRefresh = false,
580580
): Promise<ThreadGitActivity> {
581-
const threadPath = join(THREADS_DIR, `${threadId}.json`);
582-
583581
try {
584-
const content = await readFile(threadPath, 'utf-8');
585-
const data = JSON.parse(content) as ThreadData;
586-
const threadStat: Stats = await stat(threadPath);
582+
const data = (await readThreadFile(threadId)) as unknown as ThreadData;
583+
584+
// stat the local file for cache invalidation — may not exist for server-only threads
585+
let threadMtimeMs = 0;
586+
try {
587+
const threadPath = join(THREADS_DIR, `${threadId}.json`);
588+
const threadStat = await stat(threadPath);
589+
threadMtimeMs = threadStat.mtimeMs;
590+
} catch {
591+
// No local file — skip cache check (always recompute)
592+
}
587593

588594
const trees = data.env?.initial?.trees || [];
589595
if (trees.length === 0) {
@@ -631,7 +637,8 @@ export async function getThreadGitActivity(
631637

632638
const cacheValid =
633639
cachedWorkspace &&
634-
cache?.threadMtimeMs === threadStat.mtimeMs &&
640+
cache?.threadMtimeMs === threadMtimeMs &&
641+
threadMtimeMs > 0 &&
635642
cachedWorkspace.gitHeadSha === headSha &&
636643
!forceRefresh;
637644

@@ -698,7 +705,7 @@ export async function getThreadGitActivity(
698705

699706
const result: CacheData = {
700707
threadId,
701-
threadMtimeMs: threadStat.mtimeMs,
708+
threadMtimeMs,
702709
computedAtMs: Date.now(),
703710
workspaces,
704711
};

server/lib/git.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { spawn, ChildProcess } from 'child_process';
22
import { readFile, stat, unlink } from 'fs/promises';
33
import { join, resolve, isAbsolute } from 'path';
44
import type { GitStatus, GitFileStatus, FileDiff } from '../../shared/types.js';
5-
import { THREADS_DIR } from './threadTypes.js';
65
import { parseFileUri } from './utils.js';
6+
import { readThreadFile } from './threadProvider.js';
77

88
/**
99
* Validates and sanitizes a workspace path to prevent path traversal attacks.
@@ -189,11 +189,8 @@ interface GitStatusError {
189189
}
190190

191191
export async function getWorkspaceGitStatus(threadId: string): Promise<GitStatus | GitStatusError> {
192-
const threadPath = join(THREADS_DIR, `${threadId}.json`);
193-
194192
try {
195-
const content = await readFile(threadPath, 'utf-8');
196-
const data = JSON.parse(content) as ThreadData;
193+
const data = (await readThreadFile(threadId)) as unknown as ThreadData;
197194

198195
const trees = data.env?.initial?.trees || [];
199196
if (trees.length === 0) {

server/lib/mentionResolver.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile } from 'fs/promises';
2-
import { join, resolve } from 'path';
3-
import { THREADS_DIR, isTextContent, type ThreadFile } from './threadTypes.js';
2+
import { resolve } from 'path';
3+
import { isTextContent } from './threadTypes.js';
4+
import { readThreadFile } from './threadProvider.js';
45

56
const MAX_FILE_SIZE = 100 * 1024; // 100KB cap for injected file contents
67
const MAX_FILE_LINES = 2000; // Max lines to inject
@@ -79,9 +80,7 @@ async function readFileContents(
7980

8081
async function getThreadTitle(threadId: string): Promise<string | null> {
8182
try {
82-
const filePath = join(THREADS_DIR, `${threadId}.json`);
83-
const content = await readFile(filePath, 'utf-8');
84-
const data = JSON.parse(content) as ThreadFile;
83+
const data = await readThreadFile(threadId);
8584
if (data.title) return data.title;
8685

8786
const firstUser = data.messages?.find((m) => m.role === 'user');

server/lib/promptHistory.ts

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { readFile, readdir, stat } from 'fs/promises';
2-
import { join } from 'path';
3-
import { THREADS_DIR, isTextContent, type ThreadFile } from './threadTypes.js';
1+
import { isTextContent } from './threadTypes.js';
42
import {
53
recordPrompt,
64
searchPromptHistory,
75
getPromptHistoryCount,
86
type PromptHistoryRow,
97
} from './database.js';
8+
import { listAllThreads, readThreadFile } from './threadProvider.js';
109

1110
let backfillPromise: Promise<void> | null = null;
1211

@@ -20,38 +19,27 @@ async function backfillPromptHistory(): Promise<void> {
2019
// Skip if already backfilled (data persists across restarts)
2120
if (getPromptHistoryCount() > 0) return;
2221

23-
const files = await readdir(THREADS_DIR);
24-
const threadFiles = files.filter((f) => f.startsWith('T-') && f.endsWith('.json'));
22+
const threads = await listAllThreads();
2523

26-
// Stat all files and sort oldest-first so duplicate prompts keep the most recent timestamp
27-
const fileStats = (
28-
await Promise.all(
29-
threadFiles.map(async (file) => {
30-
try {
31-
const fileStat = await stat(join(THREADS_DIR, file));
32-
return { file, mtimeMs: fileStat.mtimeMs };
33-
} catch {
34-
return null;
35-
}
36-
}),
37-
)
38-
)
39-
.filter((s): s is { file: string; mtimeMs: number } => s !== null)
40-
.sort((a, b) => a.mtimeMs - b.mtimeMs);
24+
// Sort oldest-first so duplicate prompts keep the most recent timestamp
25+
const sorted = [...threads].sort((a, b) => {
26+
const aDate = a.lastUpdatedDate ? new Date(a.lastUpdatedDate).getTime() : 0;
27+
const bDate = b.lastUpdatedDate ? new Date(b.lastUpdatedDate).getTime() : 0;
28+
return aDate - bDate;
29+
});
4130

4231
// Process in parallel batches to avoid overwhelming the filesystem
4332
const BATCH_SIZE = 20;
44-
for (let i = 0; i < fileStats.length; i += BATCH_SIZE) {
45-
const batch = fileStats.slice(i, i + BATCH_SIZE);
33+
for (let i = 0; i < sorted.length; i += BATCH_SIZE) {
34+
const batch = sorted.slice(i, i + BATCH_SIZE);
4635
await Promise.all(
47-
batch.map(async ({ file, mtimeMs }) => {
36+
batch.map(async (thread) => {
4837
try {
49-
const filePath = join(THREADS_DIR, file);
50-
const fileMtime = Math.floor(mtimeMs / 1000);
51-
const content = await readFile(filePath, 'utf-8');
52-
const data = JSON.parse(content) as ThreadFile;
53-
const threadId = file.replace('.json', '');
38+
const data = await readThreadFile(thread.id);
5439
const messages = data.messages || [];
40+
const fileMtime = thread.lastUpdatedDate
41+
? Math.floor(new Date(thread.lastUpdatedDate).getTime() / 1000)
42+
: Math.floor(Date.now() / 1000);
5543

5644
for (const msg of messages) {
5745
if (msg.role !== 'user') continue;
@@ -65,19 +53,19 @@ async function backfillPromptHistory(): Promise<void> {
6553
}
6654

6755
if (text.trim()) {
68-
// Use message sentAt if available, otherwise fall back to file mtime
56+
// Use message sentAt if available, otherwise fall back to thread lastUpdated
6957
const createdAt = msg.meta?.sentAt ? Math.floor(msg.meta.sentAt / 1000) : fileMtime;
70-
recordPrompt(text, threadId, createdAt);
58+
recordPrompt(text, thread.id, createdAt);
7159
}
7260
}
7361
} catch (err) {
74-
console.warn(`[prompt-history] Failed to parse ${file}:`, err);
62+
console.warn(`[prompt-history] Failed to parse ${thread.id}:`, err);
7563
}
7664
}),
7765
);
7866
}
7967

80-
console.warn(`📋 Prompt history backfill complete (scanned ${threadFiles.length} threads)`);
68+
console.warn(`📋 Prompt history backfill complete (scanned ${sorted.length} threads)`);
8169
} catch (err) {
8270
console.error('[prompt-history] Backfill failed:', err);
8371
}
@@ -132,25 +120,15 @@ export function addPromptToHistory(text: string, threadId: string): void {
132120
*/
133121
export async function getRecentThreadIds(limit = 100): Promise<string[]> {
134122
try {
135-
const files = await readdir(THREADS_DIR);
136-
const threadFiles = files.filter((f) => f.startsWith('T-') && f.endsWith('.json'));
137-
138-
const stats = await Promise.all(
139-
threadFiles.map(async (file) => {
140-
try {
141-
const fileStat = await stat(join(THREADS_DIR, file));
142-
return { file, mtime: fileStat.mtime.getTime() };
143-
} catch {
144-
return null;
145-
}
146-
}),
147-
);
148-
149-
return stats
150-
.filter((s): s is { file: string; mtime: number } => s !== null)
151-
.sort((a, b) => b.mtime - a.mtime)
123+
const threads = await listAllThreads();
124+
return threads
125+
.sort((a, b) => {
126+
const aDate = a.lastUpdatedDate ? new Date(a.lastUpdatedDate).getTime() : 0;
127+
const bDate = b.lastUpdatedDate ? new Date(b.lastUpdatedDate).getTime() : 0;
128+
return bDate - aDate;
129+
})
152130
.slice(0, limit)
153-
.map((s) => s.file.replace('.json', ''));
131+
.map((t) => t.id);
154132
} catch {
155133
return [];
156134
}

0 commit comments

Comments
 (0)