293 lines
11 KiB
Markdown
293 lines
11 KiB
Markdown
---
|
|
phase: 25-file-system
|
|
plan: 06
|
|
type: execute
|
|
wave: 1
|
|
depends_on: ["25-01"]
|
|
files_modified:
|
|
- server/src/services/git-file-service.ts
|
|
- server/src/routes/chat-files.ts
|
|
- server/src/services/chat-files.ts
|
|
- packages/shared/src/types/chat.ts
|
|
- .planning/REQUIREMENTS.md
|
|
autonomous: true
|
|
gap_closure: true
|
|
requirements: [FILE-09, FILE-10]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Every file upload creates a git commit in the storage directory"
|
|
- "User can view the git log (version history) for any file"
|
|
artifacts:
|
|
- path: "server/src/services/git-file-service.ts"
|
|
provides: "Git operations: init, commit, log for file versioning"
|
|
min_lines: 50
|
|
- path: "server/src/routes/chat-files.ts"
|
|
provides: "GET /files/:fileId/history endpoint"
|
|
- path: "packages/shared/src/types/chat.ts"
|
|
provides: "ChatFileHistoryEntry type"
|
|
key_links:
|
|
- from: "server/src/routes/chat-files.ts"
|
|
to: "server/src/services/git-file-service.ts"
|
|
via: "gitFileService.commitFile and gitFileService.getLog"
|
|
pattern: "gitFileService"
|
|
- from: "server/src/routes/chat-files.ts"
|
|
to: "server/src/services/chat-files.ts"
|
|
via: "fileSvc.getById for file lookup"
|
|
pattern: "fileSvc.getById"
|
|
---
|
|
|
|
<objective>
|
|
Add git versioning for file operations and version history viewing.
|
|
|
|
Purpose: FILE-09 requires every file operation to produce a git commit; FILE-10 requires users to view git log for any file. The current StorageService stores files as object-keyed blobs with no git tracking. This plan creates a gitFileService that wraps git CLI commands (init, add, commit, log) operating on the storage directory, and hooks it into the upload flow. A new GET /files/:fileId/history endpoint returns the git log for a file's object key.
|
|
|
|
Output: gitFileService, updated upload route with git commit, history endpoint, ChatFileHistoryEntry type
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/25-file-system/25-01-SUMMARY.md
|
|
|
|
<interfaces>
|
|
From server/src/storage/types.ts:
|
|
```typescript
|
|
export interface StorageService {
|
|
provider: StorageProviderId;
|
|
putFile(input: PutFileInput): Promise<PutFileResult>;
|
|
getObject(companyId: string, objectKey: string): Promise<GetObjectResult>;
|
|
}
|
|
```
|
|
|
|
From server/src/routes/chat-files.ts:
|
|
```typescript
|
|
export function chatFileRoutes(db: Db, storage: StorageService) {
|
|
// POST /conversations/:id/files -- upload
|
|
// GET /files/:fileId/content -- download
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create gitFileService and ChatFileHistoryEntry type</name>
|
|
<files>server/src/services/git-file-service.ts, packages/shared/src/types/chat.ts, packages/shared/src/index.ts</files>
|
|
<read_first>
|
|
- server/src/storage/local-disk-provider.ts
|
|
- server/src/home-paths.ts
|
|
- packages/shared/src/types/chat.ts
|
|
- packages/shared/src/index.ts
|
|
</read_first>
|
|
<action>
|
|
1. Add `ChatFileHistoryEntry` type to `packages/shared/src/types/chat.ts`:
|
|
```typescript
|
|
export interface ChatFileHistoryEntry {
|
|
hash: string;
|
|
date: string;
|
|
message: string;
|
|
author: string;
|
|
}
|
|
```
|
|
Export it from `packages/shared/src/index.ts` alongside other chat types.
|
|
|
|
2. Create `server/src/services/git-file-service.ts`:
|
|
|
|
Use `node:child_process` `execFile` (NOT `exec`) to prevent shell injection. All arguments are passed as array elements, never interpolated into a shell string. The `objectKey` is always a StorageService-generated path (companyId/namespace/date/uuid-filename) so it contains no shell metacharacters, but using execFile provides defense-in-depth.
|
|
|
|
```typescript
|
|
import { execFile as execFileCb } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const execFile = promisify(execFileCb);
|
|
|
|
export interface GitFileService {
|
|
ensureRepo(storageDir: string): Promise<void>;
|
|
commitFile(storageDir: string, objectKey: string, message: string): Promise<string | null>;
|
|
getLog(storageDir: string, objectKey: string, limit?: number): Promise<Array<{ hash: string; date: string; message: string; author: string }>>;
|
|
}
|
|
|
|
export function gitFileService(): GitFileService {
|
|
async function git(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
|
|
return execFile("git", args, { cwd, timeout: 10000 });
|
|
}
|
|
|
|
return {
|
|
async ensureRepo(storageDir: string) {
|
|
const gitDir = path.join(storageDir, ".git");
|
|
if (existsSync(gitDir)) return;
|
|
await git(storageDir, ["init"]);
|
|
await git(storageDir, ["config", "user.email", "nexus@local"]);
|
|
await git(storageDir, ["config", "user.name", "Nexus File System"]);
|
|
},
|
|
|
|
async commitFile(storageDir: string, objectKey: string, message: string): Promise<string | null> {
|
|
const filePath = path.join(storageDir, objectKey);
|
|
if (!existsSync(filePath)) return null;
|
|
|
|
await this.ensureRepo(storageDir);
|
|
|
|
try {
|
|
await git(storageDir, ["add", "--", objectKey]);
|
|
const { stdout } = await git(storageDir, ["commit", "-m", message, "--", objectKey]);
|
|
const match = /\[[\w\s]+\s([a-f0-9]+)\]/.exec(stdout);
|
|
return match?.[1] ?? null;
|
|
} catch (err) {
|
|
const errMsg = String((err as { stderr?: string }).stderr ?? (err as Error).message ?? "");
|
|
if (errMsg.includes("nothing to commit") || errMsg.includes("no changes added")) {
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
|
|
async getLog(storageDir: string, objectKey: string, limit = 50) {
|
|
try {
|
|
await this.ensureRepo(storageDir);
|
|
const { stdout } = await git(storageDir, [
|
|
"log",
|
|
`--max-count=${limit}`,
|
|
"--format=%H|%aI|%s|%an",
|
|
"--",
|
|
objectKey,
|
|
]);
|
|
|
|
return stdout
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [hash, date, message, author] = line.split("|");
|
|
return { hash: hash!, date: date!, message: message!, author: author! };
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
};
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && npx tsc --noEmit -p server/tsconfig.json 2>&1 | head -10 && grep "ChatFileHistoryEntry" packages/shared/src/types/chat.ts packages/shared/src/index.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- File `server/src/services/git-file-service.ts` exists
|
|
- Contains `export function gitFileService(): GitFileService`
|
|
- Contains `ensureRepo`, `commitFile`, `getLog` methods
|
|
- Uses `execFile` from `node:child_process` (NOT `exec`) -- promisified via `node:util`
|
|
- Does NOT use template string interpolation for shell commands
|
|
- `packages/shared/src/types/chat.ts` contains `export interface ChatFileHistoryEntry`
|
|
- `packages/shared/src/index.ts` exports `ChatFileHistoryEntry`
|
|
</acceptance_criteria>
|
|
<done>gitFileService created with init/commit/log operations using safe execFile; ChatFileHistoryEntry type exported from shared</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Wire git commits into upload flow and add history endpoint</name>
|
|
<files>server/src/routes/chat-files.ts, .planning/REQUIREMENTS.md</files>
|
|
<read_first>
|
|
- server/src/routes/chat-files.ts
|
|
- server/src/services/git-file-service.ts
|
|
- server/src/storage/local-disk-provider.ts
|
|
- server/src/home-paths.ts
|
|
- .planning/REQUIREMENTS.md
|
|
</read_first>
|
|
<action>
|
|
1. Update `server/src/routes/chat-files.ts`:
|
|
|
|
a. Import gitFileService:
|
|
```typescript
|
|
import { gitFileService } from "../services/git-file-service.js";
|
|
```
|
|
Also import `path` from `node:path`.
|
|
|
|
b. Determine the storage directory. Read `server/src/storage/local-disk-provider.ts` to find how it resolves the base directory. Read `server/src/home-paths.ts` to understand how the root is resolved. Inside `chatFileRoutes` function body, compute the storage root path that matches where LocalDiskProvider writes files. For example:
|
|
```typescript
|
|
import { getHomePath } from "../home-paths.js";
|
|
// Adapt to match local-disk-provider.ts directory construction
|
|
const storageDir = path.join(getHomePath(), "data", "storage");
|
|
```
|
|
**IMPORTANT**: Read local-disk-provider.ts first and use the EXACT same path construction it uses. Do not guess.
|
|
|
|
Inside `chatFileRoutes`:
|
|
```typescript
|
|
const gitSvc = gitFileService();
|
|
```
|
|
|
|
c. After the `storage.putFile()` call and DB record creation in POST /conversations/:id/files, add a git commit (fire-and-forget to not block the response):
|
|
```typescript
|
|
// Git-track the uploaded file (non-blocking)
|
|
gitSvc.commitFile(storageDir, stored.objectKey, `Upload: ${file.originalname ?? "file"}`).catch(() => {});
|
|
```
|
|
|
|
d. Add a new route for version history, placed near the GET /files/:fileId/content route:
|
|
```typescript
|
|
// GET /files/:fileId/history -- Git version history for a file
|
|
router.get("/files/:fileId/history", async (req, res) => {
|
|
assertBoard(req);
|
|
|
|
const fileId = req.params.fileId as string;
|
|
const chatFile = await fileSvc.getById(fileId);
|
|
if (!chatFile) {
|
|
res.status(404).json({ error: "File not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, chatFile.companyId);
|
|
|
|
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
|
const entries = await gitSvc.getLog(storageDir, chatFile.objectKey, limit);
|
|
res.json({ items: entries });
|
|
});
|
|
```
|
|
Place this BEFORE the `/files/:fileId/content` route so Express matches `/files/:fileId/history` before any catch-all.
|
|
|
|
2. Update `.planning/REQUIREMENTS.md`:
|
|
- Change FILE-09 from `- [ ] **FILE-09**` to `- [x] **FILE-09**`
|
|
- Change FILE-10 from `- [ ] **FILE-10**` to `- [x] **FILE-10**`
|
|
- In Traceability table, change FILE-09 and FILE-10 status from `Pending` to `Complete`
|
|
</action>
|
|
<verify>
|
|
<automated>cd /opt/nexus && grep -n "gitFileService\|gitSvc\|commitFile\|getLog\|/history" server/src/routes/chat-files.ts && grep "FILE-09\|FILE-10" .planning/REQUIREMENTS.md | head -6</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `server/src/routes/chat-files.ts` imports `gitFileService`
|
|
- Contains `gitSvc.commitFile(storageDir` call after `storage.putFile`
|
|
- Contains `router.get("/files/:fileId/history"` route
|
|
- History route calls `gitSvc.getLog` and returns `{ items: entries }`
|
|
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-09**`
|
|
- `.planning/REQUIREMENTS.md` contains `- [x] **FILE-10**`
|
|
</acceptance_criteria>
|
|
<done>File uploads create git commits; version history available via GET /files/:fileId/history; FILE-09 and FILE-10 marked Complete</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `npx tsc --noEmit -p server/tsconfig.json` passes
|
|
- `grep "commitFile" server/src/routes/chat-files.ts` matches
|
|
- `grep "/history" server/src/routes/chat-files.ts` matches
|
|
- `grep "\[x\].*FILE-09" .planning/REQUIREMENTS.md` matches
|
|
- `grep "\[x\].*FILE-10" .planning/REQUIREMENTS.md` matches
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- File upload creates a git commit in the storage directory
|
|
- GET /files/:fileId/history returns git log entries for a file
|
|
- TypeScript compiles without errors
|
|
- FILE-09 and FILE-10 marked Complete in REQUIREMENTS.md
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/25-file-system/25-06-SUMMARY.md`
|
|
</output>
|