Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/agent-memory/archgate-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv
- **PowerShell 5.1 reads BOM-less `.ps1` files as ANSI — never use non-ASCII chars in `install.ps1`** — `install.ps1` has no UTF-8 BOM (first bytes are `# A...`). On Windows PowerShell 5.1, this means the file is decoded as the system codepage (Windows-1252), so multi-byte UTF-8 characters like em-dash (`—`, `\xE2\x80\x94`) get split into garbage bytes that break later string parsing — the parser reports cryptic errors like "string is missing the terminator" on lines that look fine. ALWAYS stick to ASCII (`-`, `--`, straight quotes) in comments and string literals in `install.ps1`. To verify after editing: `[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path .\install.ps1).Path, [ref]$null, [ref]$errs)`. Same caveat applies to any unsigned `.ps1` file the project distributes.
- **SLSA reusable workflow MUST be tag-pinned, not SHA-pinned** — `slsa-framework/slsa-github-generator/.github/workflows/*` looks like it should follow CI-001 (SHA pin), but the SLSA generator's `generate-builder.sh` reads the workflow ref to download the prebuilt builder from a GitHub release and rejects non-tag refs (`Invalid ref: ... Expected ref of the form refs/tags/vX.Y.Z`, exit 2). Pinning by SHA broke the v0.31.0 release ([run 25107195589](https://github.com/archgate/cli/actions/runs/25107195589)). The CI-001 rule allowlists this path so it does NOT block a SHA repin — meaning a future agent could "fix" the tag pin and the rule would be silent until the next release fails. ALWAYS keep `@v2.x.y` for `release-binaries.yml:165` and read the inline comment + CI-001 "Carved-out exceptions" before changing. Upstream issue: [slsa-framework/slsa-github-generator#150](https://github.com/slsa-framework/slsa-github-generator/issues/150).
- **Windows binary upgrade: never use detached child processes for `.old` cleanup** — On Windows, `replaceBinary()` renames the running exe to `.old` because the OS file-locks it. Cleaning up the `.old` via a detached `cmd /c ping -n 2 ... & del` process is unreliable (process may not spawn, `del` may fail silently, timing races). Instead, `cleanupStaleBinary()` runs at the next CLI startup as a fire-and-forget `unlink()` — the file is guaranteed unlocked by then. The cleanup is platform-agnostic (uses `getArtifactInfo()` to resolve the binary name), so it works on any supported platform even though only Windows currently creates `.old` files. The sync `unlinkSync` in `replaceBinary()` is kept as defense-in-depth for leftover `.old` files from previous upgrades. Do NOT reintroduce detached cleanup processes.
- **`bun:sqlite` file handles persist after `db.close()` on Windows — wrap test cleanup in try/catch** — Tests that create temp SQLite databases via `new Database(path)` will fail with `EBUSY: resource busy or locked` when `rmSync` tries to remove the temp directory in `afterEach`, even after calling `db.close()`. Windows holds the file handle briefly. Fix: (1) set `PRAGMA journal_mode = DELETE` in test DBs to avoid creating WAL/SHM files, and (2) wrap `rmSync` in `afterEach` with `try { rmSync(...) } catch { /* SQLite handles may persist */ }`. Each test must use a unique temp dir name so leftover files don't collide.
- **`GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflows — release.yml MUST use the GH App token** — When an Actions workflow pushes commits or opens PRs using `${{ github.token }}` / `secrets.GITHUB_TOKEN`, GitHub intentionally suppresses the resulting `push` / `pull_request` events to prevent recursion. Symptom on release PRs: the head SHA has no `pull_request`-event check runs, so `Validate Code` / `Lint, Test & Check` / `DCO Sign-off Check` are missing from the PR rollup and branch protection treats the PR as missing required checks. PR [#131](https://github.com/archgate/cli/pull/131) papered over this by manually `gh workflow run` + posting commit statuses, but `workflow_dispatch` runs land on `head_branch: release` with `pull_requests: []` — they are not associated with the PR ref, so `Lint, Test & Check` stayed orphaned and the bug recurred on PR [#251](https://github.com/archgate/cli/pull/251). Root-cause fix: in `release.yml` the `pull-request` job MUST generate a GitHub App installation token via `actions/create-github-app-token` (using `secrets.GH_APP_APP_ID` / `secrets.GH_APP_PRIVATE_KEY`) and pass it to BOTH `actions/checkout` and `simple-release-action`. App-token-authored pushes DO trigger `pull_request` events naturally. Apply the same pattern to any future workflow that pushes to a branch whose downstream CI must run.

## Validation Pipeline
Expand Down
16 changes: 16 additions & 0 deletions src/helpers/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,22 @@ export function opencodeStorageDir(): string {
return join(base, "opencode", "storage");
}

/**
* Resolve the opencode SQLite database path.
*
* Opencode stores session/message/part data in a SQLite database at
* `$XDG_DATA_HOME/opencode/opencode.db` (defaulting to
* `~/.local/share/opencode/opencode.db`).
*
* Resolved at call time (not cached) so tests can override HOME /
* XDG_DATA_HOME.
*/
export function opencodeDbPath(): string {
const xdg = usableEnv(Bun.env.XDG_DATA_HOME);
const base = xdg ?? join(archgateHomeDir(), ".local", "share");
return join(base, "opencode", "opencode.db");
}

export const paths = { cacheFolder: internalPath("cache") } as const;

export function projectPath(projectRoot: string, ...path: string[]) {
Expand Down
278 changes: 123 additions & 155 deletions src/helpers/session-context-opencode.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { Database } from "bun:sqlite";
import { existsSync } from "node:fs";
import { resolve } from "node:path";

import { logDebug } from "./log";
import { opencodeStorageDir } from "./paths";
import { opencodeDbPath } from "./paths";
import { isWindows } from "./platform";
import {
RELEVANT_ROLES,
Expand Down Expand Up @@ -36,190 +37,157 @@ function normalizePath(p: string): string {
return isWindows() ? resolved.toLowerCase() : resolved;
}

interface SessionMeta {
id: string;
path: string;
updatedAt: number;
projectHash: string;
}

/**
* Read an opencode session transcript for a project.
*
* Opencode stores data under `~/.local/share/opencode/storage/`:
* - `session/<projectHash>/<sessionID>.json` — session metadata
* - `message/<sessionID>/<messageID>.json` — individual messages
* Opencode stores data in a SQLite database at
* `$XDG_DATA_HOME/opencode/opencode.db` (default `~/.local/share/opencode/opencode.db`):
* - `session` table — session metadata with `directory` for project matching
* - `message` table — messages with `role` in the `data` JSON column
* - `part` table — content parts with `type` and `text` in the `data` JSON column
*
* Sessions are matched by comparing the `path` field in session metadata
* Sessions are matched by comparing the `directory` field in session rows
* to the provided project root.
*/
export async function readOpencodeSession(
export function readOpencodeSession(
projectRoot: string | null,
options?: ReadOpencodeSessionOptions
): Promise<OpencodeSessionResult> {
): OpencodeSessionResult {
const limit = options?.maxEntries ?? 200;
const storageDir = opencodeStorageDir();
const sessionsRoot = join(storageDir, "session");
const dbPath = opencodeDbPath();
const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd());

// 1. Scan session/<projectHash>/ directories for session JSON files
const allSessions: SessionMeta[] = [];
if (!existsSync(dbPath)) {
return { ok: false, error: "No opencode database found", path: dbPath };
}

let projectHashDirs: string[];
let db: Database;
try {
projectHashDirs = readdirSync(sessionsRoot).filter((name) => {
try {
return statSync(join(sessionsRoot, name)).isDirectory();
} catch {
return false;
}
});
db = new Database(dbPath, { readonly: true });
} catch {
return {
ok: false,
error: "No opencode session storage found",
path: sessionsRoot,
error: "Failed to open opencode database",
path: dbPath,
};
}

for (const hashDir of projectHashDirs) {
const hashPath = join(sessionsRoot, hashDir);
let sessionFiles: string[];
try {
sessionFiles = readdirSync(hashPath).filter((f) => f.endsWith(".json"));
} catch {
continue;
try {
// 1. Find all sessions, sorted by most recently updated first
interface SessionRow {
id: string;
directory: string;
time_updated: number;
}
const allSessions = db
.query<SessionRow, []>(
"SELECT id, directory, time_updated FROM session ORDER BY time_updated DESC"
)
.all();

if (allSessions.length === 0) {
return { ok: false, error: "No opencode sessions found", path: dbPath };
}

for (const file of sessionFiles) {
try {
// oxlint-disable-next-line no-await-in-loop -- sequential read needed: each session file determines project match
const raw = await Bun.file(join(hashPath, file)).json();
const meta = raw as Record<string, unknown>;
const id = typeof meta.id === "string" ? meta.id : null;
const sessionPath = typeof meta.path === "string" ? meta.path : null;
if (!id) continue;
// Parse updated_at — may be ISO string or camelCase variant
let updatedAt = 0;
if (typeof meta.updated_at === "string") {
updatedAt = new Date(meta.updated_at).getTime();
} else if (typeof meta.updatedAt === "string") {
updatedAt = new Date(meta.updatedAt as string).getTime();
} else if (typeof meta.updated_at === "number") {
updatedAt = meta.updated_at;
}

allSessions.push({
id,
path: sessionPath ?? "",
updatedAt,
projectHash: hashDir,
});
} catch {
logDebug(`Skipping session file ${file}: parse error`);
}
// 2. Filter sessions by project path
const matching = allSessions.filter(
(s) => s.directory && normalizePath(s.directory) === normalizedProjectRoot
);

if (matching.length === 0) {
return {
ok: false,
error: "No opencode sessions found for this project",
path: dbPath,
available: allSessions.map((s) => s.id),
};
}
}

if (allSessions.length === 0) {
return {
ok: false,
error: "No opencode sessions found",
path: sessionsRoot,
};
}
// 3. Select session by ID or most recent
const target = options?.sessionId
? matching.find((s) => s.id === options.sessionId)
: matching[0];

if (!target) {
return {
ok: false,
error: `Session not found: ${options?.sessionId}`,
available: matching.map((s) => s.id),
};
}

// 2. Filter sessions by project path
const matching = allSessions
.filter((s) => s.path && normalizePath(s.path) === normalizedProjectRoot)
.sort((a, b) => b.updatedAt - a.updatedAt);
// 4. Read messages for the session
interface MessageRow {
id: string;
role: string;
}
const messages = db
.query<MessageRow, [string]>(
"SELECT id, json_extract(data, '$.role') as role FROM message WHERE session_id = ? ORDER BY time_created"
)
.all(target.id);

if (messages.length === 0) {
return {
ok: false,
error: "Session exists but has no messages",
path: dbPath,
};
}

if (matching.length === 0) {
return {
ok: false,
error: "No opencode sessions found for this project",
path: sessionsRoot,
available: allSessions.map((s) => s.id),
};
}
// 5. Build transcript from text parts, skipping synthetic entries
interface PartRow {
type: string;
text: string | null;
tool: string | null;
}
const partsQuery = db.prepare<PartRow, [string]>(
"SELECT json_extract(data, '$.type') as type, json_extract(data, '$.text') as text, json_extract(data, '$.tool') as tool FROM part WHERE message_id = ? AND json_extract(data, '$.synthetic') IS NOT 1 ORDER BY time_created"
);

const relevant: OpencodeSessionSummary["transcript"] = [];
for (const msg of messages) {
if (!RELEVANT_ROLES.has(msg.role)) continue;

const parts = partsQuery.all(msg.id);

const contentParts: string[] = [];
for (const part of parts) {
if (part.type === "text" && part.text) {
contentParts.push(part.text);
} else if (part.type === "tool" && part.tool) {
contentParts.push(`[tool: ${part.tool}]`);
}
}

// 3. Select session by ID or most recent
const target = options?.sessionId
? matching.find((s) => s.id === options.sessionId)
: matching[0];
const content = contentParts.join("\n");
if (content.length === 0) continue;

if (!target) {
return {
ok: false,
error: `Session not found: ${options?.sessionId}`,
available: matching.map((s) => s.id),
};
}
const normalized: TranscriptEntry = { message: { content } };
relevant.push({
role: msg.role,
contentPreview: getContentPreview(normalized),
});
}

// 4. Read message files from message/<sessionID>/
const messagesDir = join(storageDir, "message", target.id);
let messageFiles: string[];
try {
messageFiles = readdirSync(messagesDir)
.filter((f) => f.endsWith(".json"))
.sort(); // Lexicographic sort — message IDs are ordered
} catch {
const trimmed = relevant.length > limit ? relevant.slice(-limit) : relevant;
return {
ok: false,
error: "Session exists but has no messages",
path: messagesDir,
ok: true,
data: {
sessionId: target.id,
totalEntries: messages.length,
relevantEntries: relevant.length,
transcript: trimmed,
},
};
}

if (messageFiles.length === 0) {
} catch (err) {
logDebug(`Failed to read opencode database: ${String(err)}`);
return {
ok: false,
error: "Session exists but message directory is empty",
path: messagesDir,
error: "Failed to read opencode database",
path: dbPath,
};
} finally {
db.close();
}

// 5. Parse messages, filter to user/assistant, extract previews
interface MessageData {
role?: string;
content?: unknown;
}
const allMessages: MessageData[] = [];
for (const file of messageFiles) {
try {
// oxlint-disable-next-line no-await-in-loop -- sequential read needed: message files must be read in order
const data = (await Bun.file(join(messagesDir, file)).json()) as Record<
string,
unknown
>;
allMessages.push({
role: typeof data.role === "string" ? data.role : undefined,
content: data.content,
});
} catch {
logDebug(`Skipping message file ${file}: parse error`);
}
}

const relevant: OpencodeSessionSummary["transcript"] = [];
for (const msg of allMessages) {
if (!RELEVANT_ROLES.has(msg.role ?? "")) continue;
// Normalize to TranscriptEntry shape for getContentPreview
const normalized: TranscriptEntry = { message: { content: msg.content } };
relevant.push({
role: msg.role!,
contentPreview: getContentPreview(normalized),
});
}

const trimmed = relevant.length > limit ? relevant.slice(-limit) : relevant;
return {
ok: true,
data: {
sessionId: target.id,
totalEntries: allMessages.length,
relevantEntries: relevant.length,
transcript: trimmed,
},
};
}
Loading
Loading