opencode-pilot 0.24.11 → 0.25.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/Formula/opencode-pilot.rb +2 -2
- package/bin/opencode-pilot +96 -1
- package/package.json +1 -1
- package/service/actions.js +32 -22
- package/service/session-context.js +29 -43
- package/test/integration/real-server.test.js +274 -0
- package/test/integration/session-reuse.test.js +29 -29
- package/test/unit/actions.test.js +20 -10
- package/test/unit/logs.test.js +120 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
class OpencodePilot < Formula
|
|
2
2
|
desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
|
|
3
3
|
homepage "https://github.com/athal7/opencode-pilot"
|
|
4
|
-
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.
|
|
5
|
-
sha256 "
|
|
4
|
+
url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.12.tar.gz"
|
|
5
|
+
sha256 "ea6f7ba7814225f9e312143f0222777bf4094da87cdbadad1e6589208c5323ab"
|
|
6
6
|
license "MIT"
|
|
7
7
|
|
|
8
8
|
depends_on "node"
|
package/bin/opencode-pilot
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { dirname, join } from "path";
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
17
|
-
import { execSync } from "child_process";
|
|
17
|
+
import { execSync, spawn } from "child_process";
|
|
18
18
|
import os from "os";
|
|
19
19
|
import YAML from "yaml";
|
|
20
20
|
|
|
@@ -179,6 +179,7 @@ Commands:
|
|
|
179
179
|
status Show service status and version
|
|
180
180
|
config Validate and show configuration
|
|
181
181
|
clear Clear processed state entries
|
|
182
|
+
logs Show debug log output
|
|
182
183
|
test-source NAME Test a source by fetching items and showing mappings
|
|
183
184
|
test-mapping MCP Test field mappings with sample JSON input
|
|
184
185
|
help Show this help message
|
|
@@ -189,6 +190,11 @@ Clear options:
|
|
|
189
190
|
--item ID Clear a specific item
|
|
190
191
|
--expired Clear only expired entries (uses configured TTL)
|
|
191
192
|
|
|
193
|
+
Logs options:
|
|
194
|
+
--path Print the log file path and exit
|
|
195
|
+
--lines N Print last N lines (default: 50)
|
|
196
|
+
--follow Follow the log file (like tail -f)
|
|
197
|
+
|
|
192
198
|
The service handles:
|
|
193
199
|
- Polling for GitHub/Linear issues to work on
|
|
194
200
|
- Spawning OpenCode sessions for ready items
|
|
@@ -200,6 +206,10 @@ Examples:
|
|
|
200
206
|
opencode-pilot config # Validate and show config
|
|
201
207
|
opencode-pilot clear --all # Clear all processed state
|
|
202
208
|
opencode-pilot clear --expired # Clear expired entries
|
|
209
|
+
opencode-pilot logs # Show last 50 log lines
|
|
210
|
+
opencode-pilot logs --lines 100 # Show last 100 log lines
|
|
211
|
+
opencode-pilot logs --follow # Follow the log in real time
|
|
212
|
+
opencode-pilot logs --path # Print the log file path
|
|
203
213
|
opencode-pilot test-source my-issues # Test a source
|
|
204
214
|
echo '{"url":"https://linear.app/team/issue/PROJ-123/title"}' | opencode-pilot test-mapping linear
|
|
205
215
|
`);
|
|
@@ -812,6 +822,87 @@ async function clearCommand(flags) {
|
|
|
812
822
|
}
|
|
813
823
|
}
|
|
814
824
|
|
|
825
|
+
// ============================================================================
|
|
826
|
+
// Logs Command
|
|
827
|
+
// ============================================================================
|
|
828
|
+
|
|
829
|
+
// Default log path (mirrors service/logger.js)
|
|
830
|
+
const DEFAULT_LOG_PATH = join(os.homedir(), ".local", "share", "opencode-pilot", "debug.log");
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Resolve the debug log file path.
|
|
834
|
+
* Can be overridden via PILOT_LOG_PATH env var (used in tests).
|
|
835
|
+
*/
|
|
836
|
+
function getLogPath() {
|
|
837
|
+
return process.env.PILOT_LOG_PATH || DEFAULT_LOG_PATH;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Read the last `n` lines from a file synchronously.
|
|
842
|
+
* Returns an empty array if the file does not exist.
|
|
843
|
+
*/
|
|
844
|
+
function tailLines(filePath, n) {
|
|
845
|
+
if (!existsSync(filePath)) return [];
|
|
846
|
+
const content = readFileSync(filePath, "utf8");
|
|
847
|
+
const lines = content.split("\n");
|
|
848
|
+
// Remove trailing empty line produced by a file ending with \n
|
|
849
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
850
|
+
return lines.slice(-n);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Parse a --lines flag value, falling back to `defaultN` for missing/invalid input.
|
|
855
|
+
*/
|
|
856
|
+
function parseLineCount(value, defaultN) {
|
|
857
|
+
if (value === undefined || value === true || value === false) return defaultN;
|
|
858
|
+
const n = parseInt(value, 10);
|
|
859
|
+
return Number.isFinite(n) && n > 0 ? n : defaultN;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function logsCommand(flags) {
|
|
863
|
+
const logPath = getLogPath();
|
|
864
|
+
|
|
865
|
+
// --path: just print the path and exit
|
|
866
|
+
if (flags.path) {
|
|
867
|
+
console.log(logPath);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// --follow: stream the file using tail -f
|
|
872
|
+
if (flags.follow) {
|
|
873
|
+
if (!existsSync(logPath)) {
|
|
874
|
+
console.log(`No log file yet. Enable debug logging with PILOT_DEBUG=true.`);
|
|
875
|
+
console.log(`Log path: ${logPath}`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const lineCount = parseLineCount(flags.lines, 50);
|
|
879
|
+
// Use system `tail -f` for reliable follow behaviour
|
|
880
|
+
const tail = spawn("tail", ["-f", "-n", String(lineCount), logPath], {
|
|
881
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
882
|
+
});
|
|
883
|
+
await new Promise((resolve, reject) => {
|
|
884
|
+
tail.on("error", reject);
|
|
885
|
+
tail.on("close", resolve);
|
|
886
|
+
});
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Default: print last N lines
|
|
891
|
+
const n = parseLineCount(flags.lines, 50);
|
|
892
|
+
if (!existsSync(logPath)) {
|
|
893
|
+
console.log(`No log file found. Debug logging is enabled via PILOT_DEBUG=true.`);
|
|
894
|
+
console.log(`Log path: ${logPath}`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const lines = tailLines(logPath, n);
|
|
899
|
+
if (lines.length === 0) {
|
|
900
|
+
console.log(`Log file is empty: ${logPath}`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
console.log(lines.join("\n"));
|
|
904
|
+
}
|
|
905
|
+
|
|
815
906
|
// ============================================================================
|
|
816
907
|
// Main
|
|
817
908
|
// ============================================================================
|
|
@@ -846,6 +937,10 @@ async function main() {
|
|
|
846
937
|
await clearCommand(parseArgs(args).flags);
|
|
847
938
|
break;
|
|
848
939
|
|
|
940
|
+
case "logs":
|
|
941
|
+
await logsCommand(parseArgs(args).flags);
|
|
942
|
+
break;
|
|
943
|
+
|
|
849
944
|
case "test-source":
|
|
850
945
|
await testSourceCommand(subcommand);
|
|
851
946
|
break;
|
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
* Starts OpenCode sessions with configurable prompts.
|
|
5
5
|
* Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
|
|
6
6
|
*
|
|
7
|
-
* ## Session
|
|
7
|
+
* ## Session directory handling
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* POST /session?directory=X sets both session.directory (working dir) and
|
|
10
|
+
* session.projectID (derived from the git root of X). Sandbox directories
|
|
11
|
+
* are git worktrees of the parent repo — they share the same root commit,
|
|
12
|
+
* so OpenCode resolves the correct projectID automatically.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* This means we always POST with the workingDirectory. No PATCH-based
|
|
15
|
+
* "project re-scoping" is needed. See test/integration/real-server.test.js
|
|
16
|
+
* for verification against a real OpenCode server.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* creation logic, verify all three invariants in the integration tests under
|
|
19
|
-
* "session creation invariants".
|
|
18
|
+
* Session isolation: worktree sessions skip findReusableSession entirely
|
|
19
|
+
* to prevent cross-PR contamination (each PR gets its own session).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { execSync } from "child_process";
|
|
@@ -688,11 +688,12 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
|
|
|
688
688
|
/**
|
|
689
689
|
* Create a session via the OpenCode HTTP API
|
|
690
690
|
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
691
|
+
* Creates the session with sessionCtx.workingDirectory. OpenCode resolves
|
|
692
|
+
* the correct projectID from the git root of that directory — sandbox dirs
|
|
693
|
+
* (git worktrees) resolve to the same project as the parent repo, so no
|
|
694
|
+
* separate "project scoping" step is needed.
|
|
695
|
+
*
|
|
696
|
+
* Verified against real OpenCode server in test/integration/real-server.test.js.
|
|
696
697
|
*
|
|
697
698
|
* @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
|
|
698
699
|
* @param {SessionContext} sessionCtx - Carries both directories (see session-context.js)
|
|
@@ -707,17 +708,24 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
|
|
|
707
708
|
export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options = {}) {
|
|
708
709
|
const fetchFn = options.fetch || fetch;
|
|
709
710
|
const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
|
|
710
|
-
//
|
|
711
|
-
//
|
|
712
|
-
|
|
711
|
+
// POST /session?directory=X sets both session.directory and projectID.
|
|
712
|
+
// OpenCode resolves projectID from the git root of X — sandbox directories
|
|
713
|
+
// (git worktrees) share the same root commit as the parent repo, so they
|
|
714
|
+
// get the correct projectID automatically. No PATCH re-scoping needed.
|
|
715
|
+
//
|
|
716
|
+
// PATCH /session/:id only updates title/archived — the ?directory param
|
|
717
|
+
// is a routing parameter (determines which project to look in), NOT a
|
|
718
|
+
// mutation of session.directory.
|
|
713
719
|
const directory = sessionCtx.workingDirectory;
|
|
714
720
|
|
|
715
721
|
let session = null;
|
|
716
722
|
|
|
717
723
|
try {
|
|
718
|
-
// Step 1: Create session
|
|
724
|
+
// Step 1: Create session with the working directory.
|
|
725
|
+
// This is what determines where the agent actually operates (file reads,
|
|
726
|
+
// writes, tool execution). For worktree sessions this is the sandbox path.
|
|
719
727
|
const sessionUrl = new URL('/session', serverUrl);
|
|
720
|
-
sessionUrl.searchParams.set('directory',
|
|
728
|
+
sessionUrl.searchParams.set('directory', directory);
|
|
721
729
|
|
|
722
730
|
const createResponse = await fetchFn(sessionUrl.toString(), {
|
|
723
731
|
method: 'POST',
|
|
@@ -733,10 +741,12 @@ export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options
|
|
|
733
741
|
session = await createResponse.json();
|
|
734
742
|
debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
|
|
735
743
|
|
|
736
|
-
// Step 2:
|
|
744
|
+
// Step 2: Set session title if provided.
|
|
745
|
+
// PATCH ?directory must match the session's directory so the server can
|
|
746
|
+
// find it (it's a routing param, not a mutation).
|
|
737
747
|
if (options.title) {
|
|
738
748
|
const updateUrl = new URL(`/session/${session.id}`, serverUrl);
|
|
739
|
-
updateUrl.searchParams.set('directory',
|
|
749
|
+
updateUrl.searchParams.set('directory', directory);
|
|
740
750
|
await fetchFn(updateUrl.toString(), {
|
|
741
751
|
method: 'PATCH',
|
|
742
752
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -1,49 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* session-context.js - SessionContext value object
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Tracks both the project directory (main git repo) and the working directory
|
|
5
|
+
* (which may be a sandbox/worktree) for session creation.
|
|
6
6
|
*
|
|
7
|
-
* ##
|
|
7
|
+
* ## How OpenCode's API actually works
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* conflates two distinct concerns:
|
|
9
|
+
* Verified against a real OpenCode server (see test/integration/real-server.test.js):
|
|
11
10
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* - POST /session?directory=X sets `session.directory = X` and derives
|
|
12
|
+
* `session.projectID` from the git root of X. Sandbox directories are
|
|
13
|
+
* git worktrees that share the same root commit as the parent repo, so
|
|
14
|
+
* they get the correct projectID automatically. There is NO need to
|
|
15
|
+
* create with the project directory for "project scoping".
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* - PATCH /session/:id only updates title/archived. The ?directory param
|
|
18
|
+
* is a routing parameter (determines which project to look in), NOT a
|
|
19
|
+
* mutation of session.directory.
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* - GET /session?directory=X uses ?directory for both project routing
|
|
22
|
+
* (middleware) and as an exact filter on session.directory (route handler).
|
|
23
|
+
* This means sessions created with a sandbox dir are only visible when
|
|
24
|
+
* querying with that sandbox dir — natural isolation per worktree.
|
|
20
25
|
*
|
|
21
|
-
* ##
|
|
26
|
+
* ## Why SessionContext still carries both directories
|
|
22
27
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* B. **Working directory**: Agent operates in the correct location.
|
|
30
|
-
* In worktree mode the session's working dir must be the worktree path,
|
|
31
|
-
* so file reads/writes go to the right branch.
|
|
32
|
-
* → Requires per-message directory=<workingDirectory>
|
|
33
|
-
* (or PATCH /session/:id to update the session working dir)
|
|
34
|
-
*
|
|
35
|
-
* C. **Session isolation**: Session reuse only finds sessions for the
|
|
36
|
-
* *same work item* (same PR/issue), not other PRs sharing the project.
|
|
37
|
-
* → Worktree sessions must NOT be reused across items; each PR gets
|
|
38
|
-
* its own session.
|
|
39
|
-
*
|
|
40
|
-
* ## The Solution
|
|
41
|
-
*
|
|
42
|
-
* - Create sessions with `projectDirectory` (satisfies A).
|
|
43
|
-
* - Send messages with `workingDirectory` (satisfies B).
|
|
44
|
-
* - Skip `findReusableSession` entirely when in a worktree (satisfies C),
|
|
45
|
-
* because neither the worktree path nor the project path can safely scope
|
|
46
|
-
* reuse to a single PR.
|
|
28
|
+
* Even though createSessionViaApi only needs workingDirectory, the project
|
|
29
|
+
* directory is still needed for:
|
|
30
|
+
* - Worktree detection (isWorktree) — to skip session reuse for sandbox
|
|
31
|
+
* sessions and prevent cross-PR contamination
|
|
32
|
+
* - resolveWorktreeDirectory — needs the base project dir to create/list
|
|
33
|
+
* worktrees via GET/POST /experimental/worktree?directory=<projectDir>
|
|
47
34
|
*
|
|
48
35
|
* ## Worktree Detection
|
|
49
36
|
*
|
|
@@ -54,13 +41,13 @@
|
|
|
54
41
|
export class SessionContext {
|
|
55
42
|
/**
|
|
56
43
|
* @param {string} projectDirectory - Base git repo path. Used for:
|
|
57
|
-
* -
|
|
58
|
-
* -
|
|
59
|
-
* - listSessions query (for session reuse lookup in non-worktree mode)
|
|
44
|
+
* - Worktree detection (isWorktree check for session isolation)
|
|
45
|
+
* - Worktree API calls (GET/POST /experimental/worktree?directory=...)
|
|
60
46
|
*
|
|
61
47
|
* @param {string} workingDirectory - Directory where the agent does work. Used for:
|
|
48
|
+
* - POST /session?directory=... (sets session.directory AND projectID)
|
|
62
49
|
* - POST /session/:id/message?directory=... (file operations)
|
|
63
|
-
* -
|
|
50
|
+
* - PATCH /session/:id?directory=... (routing for title updates)
|
|
64
51
|
* Equals projectDirectory when not using worktrees.
|
|
65
52
|
*/
|
|
66
53
|
constructor(projectDirectory, workingDirectory) {
|
|
@@ -73,9 +60,8 @@ export class SessionContext {
|
|
|
73
60
|
|
|
74
61
|
/**
|
|
75
62
|
* True when the session runs in a worktree separate from the main repo.
|
|
76
|
-
* Worktree sessions
|
|
77
|
-
*
|
|
78
|
-
* - Querying by projectDirectory finds sessions for other PRs in the same project
|
|
63
|
+
* Worktree sessions skip findReusableSession to prevent cross-PR
|
|
64
|
+
* contamination — each PR/issue in its own sandbox gets its own session.
|
|
79
65
|
*/
|
|
80
66
|
get isWorktree() {
|
|
81
67
|
return this.projectDirectory !== this.workingDirectory;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests against a REAL OpenCode server.
|
|
3
|
+
*
|
|
4
|
+
* These tests verify actual API behavior — not mocked assumptions.
|
|
5
|
+
* They require a running OpenCode instance (the desktop app) with access
|
|
6
|
+
* to this repo's project. Tests are skipped when no server is available.
|
|
7
|
+
*
|
|
8
|
+
* What these tests prove:
|
|
9
|
+
*
|
|
10
|
+
* 1. Creating a session with a sandbox directory sets `session.directory`
|
|
11
|
+
* to the sandbox path AND resolves the correct `projectID` (same as
|
|
12
|
+
* the parent repo). This disproves the assumption that sandbox
|
|
13
|
+
* directories produce `projectID = 'global'`.
|
|
14
|
+
*
|
|
15
|
+
* 2. PATCH /session/:id does NOT change `session.directory`. The
|
|
16
|
+
* `?directory` query param on PATCH is a routing parameter only.
|
|
17
|
+
*
|
|
18
|
+
* These facts mean createSessionViaApi only needs to POST with the
|
|
19
|
+
* working directory — no PATCH-based "re-scoping" is needed.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, before, after } from "node:test";
|
|
22
|
+
import assert from "node:assert";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
// ─── Server discovery ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const PROJECT_DIR = path.resolve(import.meta.dirname, "../..");
|
|
28
|
+
const SERVER_URL = "http://localhost:4096";
|
|
29
|
+
const SANDBOX_NAME = "test-real-server";
|
|
30
|
+
|
|
31
|
+
let serverAvailable = false;
|
|
32
|
+
let projectID = null;
|
|
33
|
+
let sandboxDir = null;
|
|
34
|
+
const createdSessionIds = [];
|
|
35
|
+
|
|
36
|
+
async function checkServer() {
|
|
37
|
+
try {
|
|
38
|
+
const encoded = encodeURIComponent(PROJECT_DIR);
|
|
39
|
+
const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`);
|
|
40
|
+
if (!res.ok) return false;
|
|
41
|
+
|
|
42
|
+
// Also verify this project is known
|
|
43
|
+
const projRes = await fetch(`${SERVER_URL}/project`);
|
|
44
|
+
if (!projRes.ok) return false;
|
|
45
|
+
const projects = await projRes.json();
|
|
46
|
+
const match = projects.find((p) => p.worktree === PROJECT_DIR);
|
|
47
|
+
if (!match) return false;
|
|
48
|
+
projectID = match.id;
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function createSandbox() {
|
|
56
|
+
const encoded = encodeURIComponent(PROJECT_DIR);
|
|
57
|
+
const res = await fetch(
|
|
58
|
+
`${SERVER_URL}/experimental/worktree?directory=${encoded}`,
|
|
59
|
+
{
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ name: SANDBOX_NAME }),
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
if (!res.ok) return null;
|
|
66
|
+
const wt = await res.json();
|
|
67
|
+
return wt.directory;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function findOrCreateSandbox() {
|
|
71
|
+
const encoded = encodeURIComponent(PROJECT_DIR);
|
|
72
|
+
const res = await fetch(
|
|
73
|
+
`${SERVER_URL}/experimental/worktree?directory=${encoded}`
|
|
74
|
+
);
|
|
75
|
+
if (res.ok) {
|
|
76
|
+
const worktrees = await res.json();
|
|
77
|
+
const existing = worktrees.find((w) => w.endsWith(`/${SANDBOX_NAME}`));
|
|
78
|
+
if (existing) return existing;
|
|
79
|
+
}
|
|
80
|
+
return createSandbox();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function archiveSession(id, directory) {
|
|
84
|
+
const encoded = encodeURIComponent(directory);
|
|
85
|
+
await fetch(`${SERVER_URL}/session/${id}?directory=${encoded}`, {
|
|
86
|
+
method: "PATCH",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ time: { archived: Date.now() } }),
|
|
89
|
+
}).catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Test suite ─────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
describe("real server: session directory behavior", { skip: false }, () => {
|
|
95
|
+
before(async () => {
|
|
96
|
+
serverAvailable = await checkServer();
|
|
97
|
+
if (!serverAvailable) return;
|
|
98
|
+
sandboxDir = await findOrCreateSandbox();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
after(async () => {
|
|
102
|
+
if (!serverAvailable) return;
|
|
103
|
+
// Archive test sessions so they don't clutter the UI
|
|
104
|
+
for (const { id, directory } of createdSessionIds) {
|
|
105
|
+
await archiveSession(id, directory);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("skip: no OpenCode server running", { skip: !false }, function () {
|
|
110
|
+
// This is a sentinel — replaced dynamically in before()
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("POST /session with sandbox dir → correct directory AND projectID", async (t) => {
|
|
114
|
+
if (!serverAvailable) return t.skip("no OpenCode server");
|
|
115
|
+
if (!sandboxDir) return t.skip("could not create sandbox");
|
|
116
|
+
|
|
117
|
+
const encoded = encodeURIComponent(sandboxDir);
|
|
118
|
+
const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.ok(res.ok, `POST /session should succeed (got ${res.status})`);
|
|
125
|
+
const session = await res.json();
|
|
126
|
+
createdSessionIds.push({ id: session.id, directory: sandboxDir });
|
|
127
|
+
|
|
128
|
+
// The session's directory must be the sandbox path — this is where the
|
|
129
|
+
// agent will operate. This was the bug: prior code created with the
|
|
130
|
+
// project dir, so the agent worked in the wrong directory.
|
|
131
|
+
assert.strictEqual(
|
|
132
|
+
session.directory,
|
|
133
|
+
sandboxDir,
|
|
134
|
+
"session.directory must be the sandbox path (where agent operates)"
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// The projectID must match the parent repo's project — NOT 'global'.
|
|
138
|
+
// This disproves the assumption that led to 4 regression-fix cycles.
|
|
139
|
+
assert.strictEqual(
|
|
140
|
+
session.projectID,
|
|
141
|
+
projectID,
|
|
142
|
+
"session.projectID must match the parent repo (sandbox is a git worktree of the same repo)"
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("POST /session with project dir → project directory and same projectID", async (t) => {
|
|
147
|
+
if (!serverAvailable) return t.skip("no OpenCode server");
|
|
148
|
+
|
|
149
|
+
const encoded = encodeURIComponent(PROJECT_DIR);
|
|
150
|
+
const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "Content-Type": "application/json" },
|
|
153
|
+
body: JSON.stringify({}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
assert.ok(res.ok, `POST /session should succeed (got ${res.status})`);
|
|
157
|
+
const session = await res.json();
|
|
158
|
+
createdSessionIds.push({ id: session.id, directory: PROJECT_DIR });
|
|
159
|
+
|
|
160
|
+
assert.strictEqual(
|
|
161
|
+
session.directory,
|
|
162
|
+
PROJECT_DIR,
|
|
163
|
+
"session.directory must be the project path"
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
assert.strictEqual(
|
|
167
|
+
session.projectID,
|
|
168
|
+
projectID,
|
|
169
|
+
"session.projectID must be the same whether created from sandbox or project dir"
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("PATCH /session/:id does NOT change session.directory", async (t) => {
|
|
174
|
+
if (!serverAvailable) return t.skip("no OpenCode server");
|
|
175
|
+
if (!sandboxDir) return t.skip("could not create sandbox");
|
|
176
|
+
|
|
177
|
+
// Create session with sandbox dir
|
|
178
|
+
const encoded = encodeURIComponent(sandboxDir);
|
|
179
|
+
const createRes = await fetch(
|
|
180
|
+
`${SERVER_URL}/session?directory=${encoded}`,
|
|
181
|
+
{
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json" },
|
|
184
|
+
body: JSON.stringify({}),
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
const session = await createRes.json();
|
|
188
|
+
createdSessionIds.push({ id: session.id, directory: sandboxDir });
|
|
189
|
+
|
|
190
|
+
// PATCH with project dir (this is what the "re-scoping" code tried to do)
|
|
191
|
+
const projectEncoded = encodeURIComponent(PROJECT_DIR);
|
|
192
|
+
const patchRes = await fetch(
|
|
193
|
+
`${SERVER_URL}/session/${session.id}?directory=${projectEncoded}`,
|
|
194
|
+
{
|
|
195
|
+
method: "PATCH",
|
|
196
|
+
headers: { "Content-Type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ title: "patched-test" }),
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
assert.ok(patchRes.ok, `PATCH should succeed (got ${patchRes.status})`);
|
|
201
|
+
const patched = await patchRes.json();
|
|
202
|
+
|
|
203
|
+
// The directory must NOT have changed — PATCH only updates title/archived
|
|
204
|
+
assert.strictEqual(
|
|
205
|
+
patched.directory,
|
|
206
|
+
sandboxDir,
|
|
207
|
+
"PATCH must NOT change session.directory (it only updates title/archived)"
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Title should have been updated
|
|
211
|
+
assert.strictEqual(patched.title, "patched-test", "title should be updated");
|
|
212
|
+
|
|
213
|
+
// Read it back to be sure
|
|
214
|
+
const readRes = await fetch(
|
|
215
|
+
`${SERVER_URL}/session?directory=${encoded}`
|
|
216
|
+
);
|
|
217
|
+
const sessions = await readRes.json();
|
|
218
|
+
const readBack = sessions.find((s) => s.id === session.id);
|
|
219
|
+
assert.ok(readBack, "session should be readable from sandbox dir");
|
|
220
|
+
assert.strictEqual(
|
|
221
|
+
readBack.directory,
|
|
222
|
+
sandboxDir,
|
|
223
|
+
"read-back confirms directory unchanged after PATCH"
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("GET /session?directory filters by exact session.directory match", async (t) => {
|
|
228
|
+
if (!serverAvailable) return t.skip("no OpenCode server");
|
|
229
|
+
if (!sandboxDir) return t.skip("could not create sandbox");
|
|
230
|
+
|
|
231
|
+
// Create session with sandbox dir
|
|
232
|
+
const encoded = encodeURIComponent(sandboxDir);
|
|
233
|
+
const createRes = await fetch(
|
|
234
|
+
`${SERVER_URL}/session?directory=${encoded}`,
|
|
235
|
+
{
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: { "Content-Type": "application/json" },
|
|
238
|
+
body: JSON.stringify({}),
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
const session = await createRes.json();
|
|
242
|
+
createdSessionIds.push({ id: session.id, directory: sandboxDir });
|
|
243
|
+
|
|
244
|
+
// Query with sandbox dir — should find it (exact match on session.directory)
|
|
245
|
+
const fromSandbox = await fetch(
|
|
246
|
+
`${SERVER_URL}/session?directory=${encoded}`
|
|
247
|
+
);
|
|
248
|
+
const sandboxSessions = await fromSandbox.json();
|
|
249
|
+
const foundFromSandbox = sandboxSessions.find((s) => s.id === session.id);
|
|
250
|
+
assert.ok(
|
|
251
|
+
foundFromSandbox,
|
|
252
|
+
"session should be found when querying with sandbox dir (exact match)"
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Query with project dir — should NOT find it because session.directory
|
|
256
|
+
// is the sandbox path, not the project path. The ?directory param on
|
|
257
|
+
// GET /session is both a project-routing param (middleware) AND an exact
|
|
258
|
+
// filter on session.directory (route handler). Since session.directory
|
|
259
|
+
// is the sandbox path, it won't match the project path filter.
|
|
260
|
+
// This is actually correct behavior: it means session reuse via
|
|
261
|
+
// findReusableSession naturally isolates sandbox sessions from project
|
|
262
|
+
// sessions — each sandbox only sees its own sessions.
|
|
263
|
+
const projectEncoded = encodeURIComponent(PROJECT_DIR);
|
|
264
|
+
const fromProject = await fetch(
|
|
265
|
+
`${SERVER_URL}/session?directory=${projectEncoded}`
|
|
266
|
+
);
|
|
267
|
+
const projectSessions = await fromProject.json();
|
|
268
|
+
const foundFromProject = projectSessions.find((s) => s.id === session.id);
|
|
269
|
+
assert.ok(
|
|
270
|
+
!foundFromProject,
|
|
271
|
+
"session should NOT appear in project dir listing (directory filter is an exact match on session.directory)"
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -564,9 +564,9 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
564
564
|
assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
|
|
565
565
|
assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
|
|
566
566
|
assert.ok(sessionCreated, "Should create session");
|
|
567
|
-
// Session creation uses the
|
|
568
|
-
//
|
|
569
|
-
assert.strictEqual(sessionDirectory, "/
|
|
567
|
+
// Session creation uses the worktree directory (sets actual working dir)
|
|
568
|
+
// PATCH with project dir handles UI scoping (best-effort)
|
|
569
|
+
assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be created with worktree as working directory");
|
|
570
570
|
});
|
|
571
571
|
|
|
572
572
|
it("reuses stored directory when reprocessing same item", async () => {
|
|
@@ -618,8 +618,8 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
618
618
|
assert.ok(result.success, "Action should succeed");
|
|
619
619
|
// Should NOT create a new worktree since we have existing_directory
|
|
620
620
|
assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
|
|
621
|
-
// Session creation uses the
|
|
622
|
-
assert.strictEqual(sessionDirectory,
|
|
621
|
+
// Session creation uses the worktree directory (sets actual working dir)
|
|
622
|
+
assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should be created with worktree as working directory");
|
|
623
623
|
});
|
|
624
624
|
|
|
625
625
|
it("skips session reuse when working in a worktree", async () => {
|
|
@@ -674,10 +674,10 @@ describe("integration: worktree creation with worktree_name", () => {
|
|
|
674
674
|
// Should NOT query for existing sessions when in a worktree
|
|
675
675
|
assert.strictEqual(sessionListQueried, false,
|
|
676
676
|
"Should skip session reuse entirely when in a worktree");
|
|
677
|
-
// Should create a new session
|
|
677
|
+
// Should create a new session with the worktree as working directory
|
|
678
678
|
assert.ok(sessionCreated, "Should create a new session");
|
|
679
|
-
assert.strictEqual(sessionCreateDirectory,
|
|
680
|
-
"New session should be
|
|
679
|
+
assert.strictEqual(sessionCreateDirectory, existingWorktreeDir,
|
|
680
|
+
"New session should be created with worktree as working directory");
|
|
681
681
|
});
|
|
682
682
|
});
|
|
683
683
|
|
|
@@ -1032,6 +1032,10 @@ describe("session creation invariants", () => {
|
|
|
1032
1032
|
calls.sessionCreateDirectory = u.searchParams.get("directory");
|
|
1033
1033
|
calls.sessionCreated = true;
|
|
1034
1034
|
}
|
|
1035
|
+
if (method === "PATCH" && /^\/session\/[^/]+$/.test(u.pathname)) {
|
|
1036
|
+
calls.patchDirectory = u.searchParams.get("directory");
|
|
1037
|
+
calls.patchSessionId = u.pathname.split("/")[2];
|
|
1038
|
+
}
|
|
1035
1039
|
if (method === "GET" && u.pathname === "/session") {
|
|
1036
1040
|
calls.sessionListQueried = true;
|
|
1037
1041
|
}
|
|
@@ -1039,11 +1043,11 @@ describe("session creation invariants", () => {
|
|
|
1039
1043
|
};
|
|
1040
1044
|
}
|
|
1041
1045
|
|
|
1042
|
-
it("
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1046
|
+
it("session created with worktree directory, message sent to worktree", async () => {
|
|
1047
|
+
// POST /session must use the worktree directory — this sets session.directory
|
|
1048
|
+
// and determines where the agent operates. OpenCode derives the correct
|
|
1049
|
+
// projectID from the git root (sandbox worktrees share the same root commit).
|
|
1050
|
+
// Verified against a real server in test/integration/real-server.test.js.
|
|
1047
1051
|
|
|
1048
1052
|
const calls = {};
|
|
1049
1053
|
|
|
@@ -1069,22 +1073,18 @@ describe("session creation invariants", () => {
|
|
|
1069
1073
|
|
|
1070
1074
|
assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
|
|
1071
1075
|
|
|
1072
|
-
//
|
|
1073
|
-
assert.strictEqual(calls.sessionCreateDirectory, "/
|
|
1074
|
-
"
|
|
1076
|
+
// Session creation uses the worktree directory
|
|
1077
|
+
assert.strictEqual(calls.sessionCreateDirectory, "/worktree/pr-99",
|
|
1078
|
+
"POST /session must use workingDirectory so agent operates in worktree");
|
|
1075
1079
|
|
|
1076
|
-
//
|
|
1080
|
+
// Message also uses the worktree directory
|
|
1077
1081
|
assert.strictEqual(calls.messageDirectory, "/worktree/pr-99",
|
|
1078
|
-
"
|
|
1079
|
-
|
|
1080
|
-
// Sanity: the two directories must be different in the worktree case
|
|
1081
|
-
assert.notStrictEqual(calls.sessionCreateDirectory, calls.messageDirectory,
|
|
1082
|
-
"In worktree mode, session creation dir and message dir must differ");
|
|
1082
|
+
"POST /message must use workingDirectory for correct file operations");
|
|
1083
1083
|
});
|
|
1084
1084
|
|
|
1085
|
-
it("
|
|
1085
|
+
it("reprocessing uses existing worktree directory for session and message", async () => {
|
|
1086
1086
|
// When reprocessing an item (e.g., new feedback on a PR), the existing worktree
|
|
1087
|
-
// directory is passed.
|
|
1087
|
+
// directory is passed. Session creation and messages must both use it.
|
|
1088
1088
|
|
|
1089
1089
|
const calls = {};
|
|
1090
1090
|
|
|
@@ -1111,13 +1111,13 @@ describe("session creation invariants", () => {
|
|
|
1111
1111
|
|
|
1112
1112
|
assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
|
|
1113
1113
|
|
|
1114
|
-
//
|
|
1115
|
-
assert.strictEqual(calls.sessionCreateDirectory, "/
|
|
1116
|
-
"
|
|
1114
|
+
// Session creation uses the existing worktree directory
|
|
1115
|
+
assert.strictEqual(calls.sessionCreateDirectory, "/worktree/calm-wizard",
|
|
1116
|
+
"POST /session must use workingDirectory for correct session directory");
|
|
1117
1117
|
|
|
1118
|
-
//
|
|
1118
|
+
// Message uses the existing worktree directory
|
|
1119
1119
|
assert.strictEqual(calls.messageDirectory, "/worktree/calm-wizard",
|
|
1120
|
-
"
|
|
1120
|
+
"POST /message must use workingDirectory for correct file operations");
|
|
1121
1121
|
});
|
|
1122
1122
|
|
|
1123
1123
|
it("invariant C: second PR in same project gets its own session", async () => {
|
|
@@ -982,9 +982,9 @@ Check for bugs and security issues.`;
|
|
|
982
982
|
// Should NOT call worktree endpoints when existing_directory is provided
|
|
983
983
|
assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
|
|
984
984
|
assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
|
|
985
|
-
// Session creation uses
|
|
986
|
-
assert.strictEqual(sessionDirectory, '/data/
|
|
987
|
-
'Session creation should use
|
|
985
|
+
// Session creation uses worktree directory (sets actual working dir)
|
|
986
|
+
assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
|
|
987
|
+
'Session creation should use worktree as working directory');
|
|
988
988
|
// Result directory is the worktree (where file operations happen)
|
|
989
989
|
assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
|
|
990
990
|
'Result should include worktree directory');
|
|
@@ -1043,11 +1043,12 @@ Check for bugs and security issues.`;
|
|
|
1043
1043
|
assert.ok(messageUrl.includes('%2Fpath%2Fto%2Fproject'), 'Message URL should include encoded directory path');
|
|
1044
1044
|
});
|
|
1045
1045
|
|
|
1046
|
-
test('uses
|
|
1046
|
+
test('uses workingDirectory for session creation, no PATCH when no title', async () => {
|
|
1047
1047
|
const { createSessionViaApi } = await import('../../service/actions.js');
|
|
1048
1048
|
|
|
1049
1049
|
const mockSessionId = 'ses_test_proj';
|
|
1050
1050
|
let createUrl = null;
|
|
1051
|
+
let patchCalled = false;
|
|
1051
1052
|
let messageUrl = null;
|
|
1052
1053
|
|
|
1053
1054
|
const mockFetch = async (url, opts) => {
|
|
@@ -1058,6 +1059,11 @@ Check for bugs and security issues.`;
|
|
|
1058
1059
|
return { ok: true, json: async () => ({ id: mockSessionId }) };
|
|
1059
1060
|
}
|
|
1060
1061
|
|
|
1062
|
+
if (opts?.method === 'PATCH') {
|
|
1063
|
+
patchCalled = true;
|
|
1064
|
+
return { ok: true, json: async () => ({}) };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1061
1067
|
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1062
1068
|
messageUrl = url;
|
|
1063
1069
|
return { ok: true, json: async () => ({ success: true }) };
|
|
@@ -1076,13 +1082,17 @@ Check for bugs and security issues.`;
|
|
|
1076
1082
|
{ fetch: mockFetch }
|
|
1077
1083
|
);
|
|
1078
1084
|
|
|
1079
|
-
// Session creation should use the
|
|
1080
|
-
assert.ok(createUrl.includes('
|
|
1081
|
-
'Session creation should use
|
|
1082
|
-
assert.ok(
|
|
1083
|
-
'Session creation should
|
|
1085
|
+
// Session creation should use the worktree directory
|
|
1086
|
+
assert.ok(createUrl.includes('worktree'),
|
|
1087
|
+
'Session creation should use workingDirectory (worktree path)');
|
|
1088
|
+
assert.ok(createUrl.includes('pr-415'),
|
|
1089
|
+
'Session creation should use workingDirectory (worktree path)');
|
|
1090
|
+
|
|
1091
|
+
// No PATCH when no title is provided — no "project re-scoping" needed
|
|
1092
|
+
assert.strictEqual(patchCalled, false,
|
|
1093
|
+
'PATCH should NOT be called when no title (no project re-scoping needed)');
|
|
1084
1094
|
|
|
1085
|
-
// Message should use the working directory
|
|
1095
|
+
// Message should use the working directory
|
|
1086
1096
|
assert.ok(messageUrl.includes('worktree'),
|
|
1087
1097
|
'Message should use the worktree working directory');
|
|
1088
1098
|
assert.ok(messageUrl.includes('pr-415'),
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `logs` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* The command prints the debug log path and optionally tails/follows it.
|
|
5
|
+
* Flags:
|
|
6
|
+
* --path Print only the log file path
|
|
7
|
+
* --lines N Print last N lines (default 50)
|
|
8
|
+
* --follow Follow the log file (tail -f)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test, describe } from "node:test";
|
|
12
|
+
import assert from "node:assert";
|
|
13
|
+
import { spawnSync } from "child_process";
|
|
14
|
+
import { join, dirname } from "path";
|
|
15
|
+
import { fileURLToPath } from "url";
|
|
16
|
+
import {
|
|
17
|
+
writeFileSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
} from "fs";
|
|
22
|
+
import { tmpdir } from "os";
|
|
23
|
+
|
|
24
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
25
|
+
const __dirname = dirname(__filename);
|
|
26
|
+
const CLI = join(__dirname, "..", "..", "bin", "opencode-pilot");
|
|
27
|
+
|
|
28
|
+
// Helper: run opencode-pilot with overridden log path via env
|
|
29
|
+
function runLogs(args = [], env = {}) {
|
|
30
|
+
return spawnSync(process.execPath, [CLI, "logs", ...args], {
|
|
31
|
+
encoding: "utf8",
|
|
32
|
+
timeout: 5000,
|
|
33
|
+
env: { ...process.env, ...env },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("logs command", () => {
|
|
38
|
+
let tmpDir;
|
|
39
|
+
let logFile;
|
|
40
|
+
|
|
41
|
+
// Create a temp log file for each test group
|
|
42
|
+
function setupLogFile(lines = []) {
|
|
43
|
+
tmpDir = join(tmpdir(), `opencode-pilot-test-${Date.now()}`);
|
|
44
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
45
|
+
logFile = join(tmpDir, "debug.log");
|
|
46
|
+
if (lines.length) {
|
|
47
|
+
writeFileSync(logFile, lines.join("\n") + "\n");
|
|
48
|
+
}
|
|
49
|
+
return logFile;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cleanup() {
|
|
53
|
+
if (tmpDir && existsSync(tmpDir)) {
|
|
54
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("--path flag", () => {
|
|
59
|
+
test("prints the log file path", () => {
|
|
60
|
+
const path = setupLogFile();
|
|
61
|
+
const result = runLogs(["--path"], { PILOT_LOG_PATH: path });
|
|
62
|
+
cleanup();
|
|
63
|
+
assert.strictEqual(result.status, 0);
|
|
64
|
+
assert.match(result.stdout.trim(), /debug\.log/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("prints path even when log file does not exist", () => {
|
|
68
|
+
const nonExistentPath = join(tmpdir(), "no-such-dir", "debug.log");
|
|
69
|
+
const result = runLogs(["--path"], { PILOT_LOG_PATH: nonExistentPath });
|
|
70
|
+
assert.strictEqual(result.status, 0);
|
|
71
|
+
assert.match(result.stdout.trim(), /debug\.log/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("default output (last N lines)", () => {
|
|
76
|
+
test("prints last 50 lines by default when log exists", () => {
|
|
77
|
+
const lines = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`);
|
|
78
|
+
const path = setupLogFile(lines);
|
|
79
|
+
const result = runLogs([], { PILOT_LOG_PATH: path });
|
|
80
|
+
cleanup();
|
|
81
|
+
assert.strictEqual(result.status, 0);
|
|
82
|
+
// Should contain lines 11-60 (last 50)
|
|
83
|
+
assert.match(result.stdout, /line 60/);
|
|
84
|
+
// Lines 1-10 should be excluded (only last 50 of 60 are shown)
|
|
85
|
+
assert.doesNotMatch(result.stdout, /^line [1-9]\b/m);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("--lines N prints last N lines", () => {
|
|
89
|
+
const lines = Array.from({ length: 20 }, (_, i) => `entry ${i + 1}`);
|
|
90
|
+
const path = setupLogFile(lines);
|
|
91
|
+
const result = runLogs(["--lines", "5"], { PILOT_LOG_PATH: path });
|
|
92
|
+
cleanup();
|
|
93
|
+
assert.strictEqual(result.status, 0);
|
|
94
|
+
assert.match(result.stdout, /entry 20/);
|
|
95
|
+
assert.doesNotMatch(result.stdout, /entry 1\n/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("exits 0 with informational message when log does not exist", () => {
|
|
99
|
+
const nonExistentPath = join(
|
|
100
|
+
tmpdir(),
|
|
101
|
+
`no-log-${Date.now()}`,
|
|
102
|
+
"debug.log"
|
|
103
|
+
);
|
|
104
|
+
const result = runLogs([], { PILOT_LOG_PATH: nonExistentPath });
|
|
105
|
+
assert.strictEqual(result.status, 0);
|
|
106
|
+
assert.match(result.stdout, /no log/i);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("help text", () => {
|
|
111
|
+
test("opencode-pilot help includes logs command", () => {
|
|
112
|
+
const result = spawnSync(process.execPath, [CLI, "help"], {
|
|
113
|
+
encoding: "utf8",
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
});
|
|
116
|
+
assert.strictEqual(result.status, 0);
|
|
117
|
+
assert.match(result.stdout, /logs/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|