@bridge4dev/runner 0.11.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/adapters/claude.d.ts +19 -0
- package/dist/adapters/claude.js +631 -0
- package/dist/adapters/codex-home.d.ts +61 -0
- package/dist/adapters/codex-home.js +234 -0
- package/dist/adapters/codex-protocol.d.ts +59 -0
- package/dist/adapters/codex-protocol.js +204 -0
- package/dist/adapters/codex.d.ts +61 -0
- package/dist/adapters/codex.js +1406 -0
- package/dist/adapters/types.d.ts +183 -0
- package/dist/adapters/types.js +5 -0
- package/dist/async-queue.d.ts +11 -0
- package/dist/async-queue.js +50 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +149 -0
- package/dist/auth-relay.d.ts +57 -0
- package/dist/auth-relay.js +289 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +73 -0
- package/dist/fsview.d.ts +20 -0
- package/dist/fsview.js +122 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +168 -0
- package/dist/gitops.d.ts +136 -0
- package/dist/gitops.js +596 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +352 -0
- package/dist/journal.d.ts +118 -0
- package/dist/journal.js +300 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +33 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +272 -0
- package/dist/protocol.d.ts +754 -0
- package/dist/protocol.js +154 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +221 -0
- package/dist/status-file.d.ts +14 -0
- package/dist/status-file.js +29 -0
- package/dist/supervisor.d.ts +216 -0
- package/dist/supervisor.js +1648 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/ws-client.d.ts +30 -0
- package/dist/ws-client.js +171 -0
- package/package.json +52 -0
package/dist/git.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { worktreesDir } from './paths.js';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
8
|
+
async function git(cwd, ...args) {
|
|
9
|
+
const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
|
|
10
|
+
return stdout.trim();
|
|
11
|
+
}
|
|
12
|
+
export async function validateWorkspacePath(workspacePath) {
|
|
13
|
+
if (!fs.existsSync(workspacePath) || !fs.statSync(workspacePath).isDirectory()) {
|
|
14
|
+
return { ok: false, exists: false, isGitRepo: false, error: 'Directory does not exist' };
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const inside = await git(workspacePath, 'rev-parse', '--is-inside-work-tree');
|
|
18
|
+
if (inside !== 'true') {
|
|
19
|
+
return { ok: false, exists: true, isGitRepo: false, error: 'Not a git work tree' };
|
|
20
|
+
}
|
|
21
|
+
const branch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
22
|
+
return { ok: true, exists: true, isGitRepo: true, branch };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
exists: true,
|
|
28
|
+
isGitRepo: false,
|
|
29
|
+
error: `git check failed: ${String(error instanceof Error ? error.message : error).slice(0, 300)}`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function sessionShortId(sessionId) {
|
|
34
|
+
return sessionId.replace(/-/g, '').slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
export function sessionWorktreePath(sessionId) {
|
|
37
|
+
return path.join(worktreesDir(), sessionShortId(sessionId));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A stable key identifying the *shared* repository behind any path inside it.
|
|
41
|
+
*
|
|
42
|
+
* A linked worktree and its main checkout are the same repo: they share one
|
|
43
|
+
* object store, one index lock namespace and one branch namespace. Locking a
|
|
44
|
+
* commit by worktree path while locking a squash-merge by workspace path gave
|
|
45
|
+
* two different keys and therefore no mutual exclusion at all — a commit inside
|
|
46
|
+
* the worktree could interleave with a merge reading that same branch tip.
|
|
47
|
+
* `git rev-parse --git-common-dir` collapses both to the main `.git` directory.
|
|
48
|
+
*/
|
|
49
|
+
export async function repoKeyFor(pathInsideRepo) {
|
|
50
|
+
try {
|
|
51
|
+
const common = await git(pathInsideRepo, 'rev-parse', '--git-common-dir');
|
|
52
|
+
return fs.realpathSync(path.resolve(pathInsideRepo, common));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Not a repo (yet), or git is unavailable: fall back to the path itself so
|
|
56
|
+
// callers still serialise against themselves rather than not at all.
|
|
57
|
+
try {
|
|
58
|
+
return fs.realpathSync(pathInsideRepo);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return path.resolve(pathInsideRepo);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Accept a branch name from the API only if git would accept it too. The value
|
|
67
|
+
* reaches a `git worktree add -b` argument, so anything odd — a leading dash, a
|
|
68
|
+
* `..`, a control character, a trailing `.lock` — is rejected in favour of the
|
|
69
|
+
* id-derived default rather than passed through.
|
|
70
|
+
*/
|
|
71
|
+
export function sanitizeBranch(hint) {
|
|
72
|
+
if (!hint)
|
|
73
|
+
return null;
|
|
74
|
+
const trimmed = hint.trim();
|
|
75
|
+
if (trimmed.length === 0 || trimmed.length > 120)
|
|
76
|
+
return null;
|
|
77
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._\-/]*$/.test(trimmed))
|
|
78
|
+
return null;
|
|
79
|
+
if (trimmed.includes('..') || trimmed.includes('//') || trimmed.endsWith('.lock'))
|
|
80
|
+
return null;
|
|
81
|
+
if (trimmed.endsWith('/') || trimmed.endsWith('.'))
|
|
82
|
+
return null;
|
|
83
|
+
return trimmed;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* One branch + worktree per session (plan §7). The worktree lives under the
|
|
87
|
+
* runner's state dir so the user's checkout stays untouched; the branch lives
|
|
88
|
+
* in the workspace repo, so the work survives worktree cleanup.
|
|
89
|
+
*/
|
|
90
|
+
export async function ensureSessionWorktree(workspacePath, sessionId, branchHint, options = {}) {
|
|
91
|
+
const short = sessionShortId(sessionId);
|
|
92
|
+
// A ticket group gets a branch named after its tickets so the work reads as
|
|
93
|
+
// one thing in git history. The worktree DIRECTORY stays id-derived — it is
|
|
94
|
+
// the collision guard below, and two sessions must never share one.
|
|
95
|
+
const branch = sanitizeBranch(branchHint) ?? `devbridge/s-${short}`;
|
|
96
|
+
const worktreePath = sessionWorktreePath(sessionId);
|
|
97
|
+
if (fs.existsSync(path.join(worktreePath, '.git'))) {
|
|
98
|
+
// Runner restart — reuse, but verify the worktree really is ours: a
|
|
99
|
+
// short-id collision would silently share a worktree between sessions.
|
|
100
|
+
const head = await git(worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
|
|
101
|
+
if (head !== branch) {
|
|
102
|
+
throw new Error(`Worktree ${worktreePath} is on branch ${head ?? 'unknown'}, expected ${branch}`);
|
|
103
|
+
}
|
|
104
|
+
return { branch, worktreePath };
|
|
105
|
+
}
|
|
106
|
+
fs.mkdirSync(worktreesDir(), { recursive: true, mode: 0o700 });
|
|
107
|
+
// Repair stale registrations left by a deleted worktree dir.
|
|
108
|
+
await git(workspacePath, 'worktree', 'prune').catch(() => undefined);
|
|
109
|
+
const branchExists = await git(workspacePath, 'rev-parse', '--verify', '--quiet', branch)
|
|
110
|
+
.then(() => true)
|
|
111
|
+
.catch(() => false);
|
|
112
|
+
if (branchExists) {
|
|
113
|
+
await git(workspacePath, 'worktree', 'add', worktreePath, branch);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
if (options.requireExistingBranch) {
|
|
117
|
+
// The caller is restoring a session that already produced work. Creating
|
|
118
|
+
// a fresh branch off HEAD here would look like success while quietly
|
|
119
|
+
// hiding every commit the agent made — fail loudly instead.
|
|
120
|
+
throw new Error(`session branch ${branch} no longer exists in ${workspacePath}`);
|
|
121
|
+
}
|
|
122
|
+
await git(workspacePath, 'worktree', 'add', '-b', branch, worktreePath, 'HEAD');
|
|
123
|
+
}
|
|
124
|
+
return { branch, worktreePath };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Drop a session branch after its worktree is gone. Only ever called when the
|
|
128
|
+
* API confirmed the work was already applied to the base branch — `-D` because
|
|
129
|
+
* «Применить» is a squash-merge, which git never recognises as a merge, so `-d`
|
|
130
|
+
* would refuse exactly the branches that ARE safe to delete.
|
|
131
|
+
*/
|
|
132
|
+
export async function deleteSessionBranch(workspacePath, branch) {
|
|
133
|
+
const safe = sanitizeBranch(branch);
|
|
134
|
+
if (!safe)
|
|
135
|
+
throw new Error(`refusing to delete an unsafe branch name: ${branch}`);
|
|
136
|
+
await git(workspacePath, 'branch', '-D', safe);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Remove the session worktree (the branch — and the work — stays in the
|
|
140
|
+
* repo). The main repo is recovered from the worktree's `.git` pointer file,
|
|
141
|
+
* so the caller only needs the session id.
|
|
142
|
+
*/
|
|
143
|
+
export async function removeSessionWorktree(sessionId) {
|
|
144
|
+
const worktreePath = sessionWorktreePath(sessionId);
|
|
145
|
+
if (!fs.existsSync(worktreePath))
|
|
146
|
+
return;
|
|
147
|
+
const gitFile = path.join(worktreePath, '.git');
|
|
148
|
+
let mainRepo = null;
|
|
149
|
+
if (fs.existsSync(gitFile) && fs.statSync(gitFile).isFile()) {
|
|
150
|
+
// "gitdir: /repo/.git/worktrees/<name>" → /repo
|
|
151
|
+
const pointer = fs
|
|
152
|
+
.readFileSync(gitFile, 'utf8')
|
|
153
|
+
.match(/^gitdir:\s*(.+)$/m)?.[1]
|
|
154
|
+
?.trim();
|
|
155
|
+
if (pointer) {
|
|
156
|
+
const dotGit = path.resolve(pointer, '..', '..');
|
|
157
|
+
if (path.basename(dotGit) === '.git')
|
|
158
|
+
mainRepo = path.dirname(dotGit);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (mainRepo) {
|
|
162
|
+
await git(mainRepo, 'worktree', 'remove', '--force', worktreePath);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=git.js.map
|
package/dist/gitops.d.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export interface GitFileEntry {
|
|
2
|
+
path: string;
|
|
3
|
+
/** A | M | D | R (git name-status letter). */
|
|
4
|
+
status: string;
|
|
5
|
+
additions: number | null;
|
|
6
|
+
deletions: number | null;
|
|
7
|
+
/** Present in the working tree but not committed yet. */
|
|
8
|
+
uncommitted: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface GitStatusResult {
|
|
11
|
+
branch: string;
|
|
12
|
+
baseBranch: string;
|
|
13
|
+
files: GitFileEntry[];
|
|
14
|
+
additions: number;
|
|
15
|
+
deletions: number;
|
|
16
|
+
/** Commits the agent made on the session branch (base..HEAD). */
|
|
17
|
+
agentCommits: number;
|
|
18
|
+
uncommittedFiles: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function gitStatus(worktreePath: string, workspacePath: string, sessionBranch: string): Promise<GitStatusResult>;
|
|
21
|
+
export interface GitDiffResult {
|
|
22
|
+
diff: string;
|
|
23
|
+
truncated: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Exposed for tests: the header check is the control worth pinning directly. */
|
|
26
|
+
export declare function capDiffForTest(diff: string): GitDiffResult;
|
|
27
|
+
export declare function gitDiff(worktreePath: string, workspacePath: string, sessionBranch: string, filePath: string): Promise<GitDiffResult>;
|
|
28
|
+
export interface GitCommitResult {
|
|
29
|
+
committed: boolean;
|
|
30
|
+
sha?: string;
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function gitCommit(worktreePath: string, message: string): Promise<GitCommitResult>;
|
|
34
|
+
export interface ApplyResult {
|
|
35
|
+
applied: boolean;
|
|
36
|
+
commitSha?: string;
|
|
37
|
+
conflict?: boolean;
|
|
38
|
+
/** On conflict: the branch the agent must merge into its session branch. */
|
|
39
|
+
baseBranch?: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function applySession(workspacePath: string, worktreePath: string, sessionBranch: string, message: string): Promise<ApplyResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Reading history is the one git surface where the *repository*, not the
|
|
45
|
+
* session worktree, is the right place to stand: a finished session may have
|
|
46
|
+
* had its worktree cleaned up while its branch — and everything the agent
|
|
47
|
+
* committed — is still in the repo. So every function here runs in
|
|
48
|
+
* `workspacePath` and names the branch explicitly.
|
|
49
|
+
*
|
|
50
|
+
* Read-only by construction. Nothing in this section can move a ref, and the
|
|
51
|
+
* two ways history could leak something it should not — an argument that git
|
|
52
|
+
* reads as an option, and a diff of a secret file — are closed by
|
|
53
|
+
* `assertRefArgument` / `sanitizeBranch` and by the same denylist the Changes
|
|
54
|
+
* panel uses.
|
|
55
|
+
*/
|
|
56
|
+
export interface GitLogCommit {
|
|
57
|
+
sha: string;
|
|
58
|
+
/** Empty for a root commit, more than one for a merge. */
|
|
59
|
+
parents: string[];
|
|
60
|
+
author: string;
|
|
61
|
+
/** Author date, ISO-8601 with offset. */
|
|
62
|
+
date: string;
|
|
63
|
+
subject: string;
|
|
64
|
+
/** Branch/tag names pointing at this commit (`HEAD -> ` already stripped). */
|
|
65
|
+
refs: string[];
|
|
66
|
+
}
|
|
67
|
+
/** Which slice of history to show. */
|
|
68
|
+
export type GitLogScope = 'session' | 'branch' | 'all';
|
|
69
|
+
export interface GitLogResult {
|
|
70
|
+
branch: string;
|
|
71
|
+
baseBranch: string;
|
|
72
|
+
/** Where the session branch and the base last agreed. */
|
|
73
|
+
mergeBase: string | null;
|
|
74
|
+
commits: GitLogCommit[];
|
|
75
|
+
hasMore: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface GitCommitFileEntry {
|
|
78
|
+
path: string;
|
|
79
|
+
status: string;
|
|
80
|
+
additions: number | null;
|
|
81
|
+
deletions: number | null;
|
|
82
|
+
/** On the secret denylist: the name is shown, the diff is refused. */
|
|
83
|
+
protected: boolean;
|
|
84
|
+
}
|
|
85
|
+
export interface GitCommitDetail {
|
|
86
|
+
kind: 'commit';
|
|
87
|
+
sha: string;
|
|
88
|
+
parents: string[];
|
|
89
|
+
author: string;
|
|
90
|
+
authorEmail: string;
|
|
91
|
+
date: string;
|
|
92
|
+
subject: string;
|
|
93
|
+
body: string;
|
|
94
|
+
refs: string[];
|
|
95
|
+
files: GitCommitFileEntry[];
|
|
96
|
+
/** The file list hit the cap — the commit touched more than we return. */
|
|
97
|
+
truncated: boolean;
|
|
98
|
+
}
|
|
99
|
+
export interface GitCommitFileDiff {
|
|
100
|
+
kind: 'diff';
|
|
101
|
+
diff: string;
|
|
102
|
+
truncated: boolean;
|
|
103
|
+
}
|
|
104
|
+
/** A full or abbreviated commit hash — never a ref name, never an option. */
|
|
105
|
+
export declare function isCommitSha(value: string): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* `--numstat` names a rename as `old => new` (or `dir/{old => new}/file`),
|
|
108
|
+
* while `--name-status` gives the plain new path. Keying the two maps by
|
|
109
|
+
* different strings is why every renamed file showed `null` additions.
|
|
110
|
+
*/
|
|
111
|
+
export declare function normalizeNumstatPath(raw: string): string;
|
|
112
|
+
export declare function gitLog(input: {
|
|
113
|
+
workspacePath: string;
|
|
114
|
+
branch: string;
|
|
115
|
+
scope?: GitLogScope;
|
|
116
|
+
limit?: number;
|
|
117
|
+
skip?: number;
|
|
118
|
+
}): Promise<GitLogResult>;
|
|
119
|
+
/**
|
|
120
|
+
* One commit: its metadata and the files it touched, or — when `filePath` is
|
|
121
|
+
* given — the diff of that one file.
|
|
122
|
+
*
|
|
123
|
+
* `--diff-merges=first-parent` on purpose: without it `git show` renders a
|
|
124
|
+
* merge as a combined diff that hides everything already present in one of the
|
|
125
|
+
* parents, and the answer to "what did this merge bring onto my branch" comes
|
|
126
|
+
* out empty. For an ordinary commit the flag changes nothing.
|
|
127
|
+
*/
|
|
128
|
+
export declare function gitShow(workspacePath: string, sha: string, filePath?: string, branch?: string): Promise<GitCommitDetail | GitCommitFileDiff>;
|
|
129
|
+
export interface RevertResult {
|
|
130
|
+
reverted: boolean;
|
|
131
|
+
revertSha?: string;
|
|
132
|
+
conflict?: boolean;
|
|
133
|
+
error?: string;
|
|
134
|
+
}
|
|
135
|
+
export declare function revertApply(workspacePath: string, commitSha: string): Promise<RevertResult>;
|
|
136
|
+
//# sourceMappingURL=gitops.d.ts.map
|