@naswerks/periscope 1.0.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
import { isAbsolutePath, isContainedBy, normalizePath } from '../core/paths.js';
|
|
3
|
+
import { MAX_WORKSPACE_ID_LENGTH } from '../core/workspace-id.js';
|
|
4
|
+
import { parseBranchList, parseBranchTips, parseWorktreePorcelain } from './worktree-porcelain.js';
|
|
5
|
+
/**
|
|
6
|
+
* The exact `git worktree add` argv, and the one decision that matters.
|
|
7
|
+
*
|
|
8
|
+
* `branchExists` decides between two different commands, not between two spellings of one.
|
|
9
|
+
* exists gives `worktree add <path> <branch>` attach: the branch keeps its own tip.
|
|
10
|
+
* new gives `worktree add -B <branch> <path> <baseRef>` create, off the base ref.
|
|
11
|
+
* Passing `-B` in the first case hard-resets the branch and discards its commits. See this file's
|
|
12
|
+
* header — that is not a hypothetical.
|
|
13
|
+
*
|
|
14
|
+
* Pure and total: no filesystem, no git, no I/O. Exported so the rule is testable directly rather
|
|
15
|
+
* than only through a provider that would need a repository to exercise.
|
|
16
|
+
*/
|
|
17
|
+
export function worktreeAddArgs(path, branch, baseRef, branchExists) {
|
|
18
|
+
if (branchExists)
|
|
19
|
+
return ['worktree', 'add', path, branch];
|
|
20
|
+
return ['worktree', 'add', '-B', branch, path, baseRef];
|
|
21
|
+
}
|
|
22
|
+
export class GitWorktreeProvider {
|
|
23
|
+
#repositoryRoot;
|
|
24
|
+
/** The checkout this provider clones from — see `WorkspaceProvider.repositoryRoot`. */
|
|
25
|
+
get repositoryRoot() {
|
|
26
|
+
return this.#repositoryRoot;
|
|
27
|
+
}
|
|
28
|
+
#workspaceRoot;
|
|
29
|
+
#branchFor;
|
|
30
|
+
#baseRef;
|
|
31
|
+
#effects;
|
|
32
|
+
#commands;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.#repositoryRoot = normalizePath(options.repositoryRoot);
|
|
35
|
+
this.#workspaceRoot = normalizePath(options.workspaceRoot);
|
|
36
|
+
this.#branchFor = options.branchFor ?? ((sessionId) => `periscope/${sessionId}`);
|
|
37
|
+
this.#baseRef = options.baseRef ?? 'HEAD';
|
|
38
|
+
this.#effects = options.effects;
|
|
39
|
+
this.#commands = options.commands;
|
|
40
|
+
}
|
|
41
|
+
/** Where this session's worktree is, whether or not it exists yet. */
|
|
42
|
+
pathFor(sessionId) {
|
|
43
|
+
return `${this.#workspaceRoot}/${sessionId}`;
|
|
44
|
+
}
|
|
45
|
+
async provision(sessionId) {
|
|
46
|
+
const invalid = rejectUnusableId(sessionId);
|
|
47
|
+
if (invalid !== null)
|
|
48
|
+
return invalid;
|
|
49
|
+
if (!isAbsolutePath(this.#workspaceRoot) || !isAbsolutePath(this.#repositoryRoot)) {
|
|
50
|
+
return refuse('workspace-provision-failed', `both roots must be absolute: repository ${this.#repositoryRoot}, workspace ${this.#workspaceRoot}`);
|
|
51
|
+
}
|
|
52
|
+
const path = this.pathFor(sessionId);
|
|
53
|
+
try {
|
|
54
|
+
// Inside the try, deliberately: `branchFor` is caller-supplied and may refuse a render by
|
|
55
|
+
// throwing (a scheme whose output fails the refname screen). A throw here must become the
|
|
56
|
+
// same named `workspace-provision-failed` every other provisioning failure gets — the
|
|
57
|
+
// provider's own contract is results, never exceptions.
|
|
58
|
+
const branch = this.#branchFor(sessionId);
|
|
59
|
+
// An existing directory is attached to, never recreated. The whole point of re-provisioning is
|
|
60
|
+
// that whatever is there survives — including the case where the directory is intact and only
|
|
61
|
+
// this host's memory of it was lost.
|
|
62
|
+
if (await this.#effects.exists(path)) {
|
|
63
|
+
return ok({ path, meta: { branch, baseRef: this.#baseRef, attached: 'directory' } });
|
|
64
|
+
}
|
|
65
|
+
await this.#effects.makeDirectory(this.#workspaceRoot);
|
|
66
|
+
// The probe that decides between attach and create. It runs for every session, not only the
|
|
67
|
+
// ones a caller expects to be re-provisioned: the whole failure is that nobody expects it.
|
|
68
|
+
const branchExists = await this.#branchExists(branch);
|
|
69
|
+
await this.#commands.run('git', worktreeAddArgs(path, branch, this.#baseRef, branchExists), this.#repositoryRoot);
|
|
70
|
+
return ok({
|
|
71
|
+
path,
|
|
72
|
+
meta: {
|
|
73
|
+
branch,
|
|
74
|
+
baseRef: this.#baseRef,
|
|
75
|
+
// Reported rather than inferred: an operator reading this can tell whether their commits
|
|
76
|
+
// were preserved by attachment or whether the branch was created fresh.
|
|
77
|
+
attached: branchExists ? 'branch' : 'created',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return refuse('workspace-provision-failed', `could not provision a worktree for ${sessionId} at ${path}: ${describe(error)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Release the worktree.
|
|
87
|
+
*
|
|
88
|
+
* The branch is never deleted unless the caller names it in the ask. Removing a directory is
|
|
89
|
+
* reversible — the commits are still on the branch. Deleting a branch is not, so branch deletion
|
|
90
|
+
* is opt-in per call (`deleteBranch`), refused for an unmerged branch unless forced, and the
|
|
91
|
+
* merged check runs before anything is removed: a refusal never leaves a half-cleaned key. A
|
|
92
|
+
* deletion that fails after the directory went is a partial, stated on the receipt.
|
|
93
|
+
*
|
|
94
|
+
* The branch is the one the worktree is on (from `worktree list`), which may differ from the
|
|
95
|
+
* scheme's render if an agent checked out elsewhere; only when the directory is already gone is
|
|
96
|
+
* the scheme's render the name.
|
|
97
|
+
*/
|
|
98
|
+
async release(sessionId, options) {
|
|
99
|
+
if (options?.remove !== true)
|
|
100
|
+
return ok(undefined);
|
|
101
|
+
const path = this.pathFor(sessionId);
|
|
102
|
+
const receipt = (directoryRemoved, branchDeleted, refusal = null) => ({
|
|
103
|
+
path,
|
|
104
|
+
directoryRemoved,
|
|
105
|
+
branchDeleted,
|
|
106
|
+
refusal,
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
const existed = await this.#effects.exists(path);
|
|
110
|
+
if (options.deleteBranch !== true) {
|
|
111
|
+
if (!existed)
|
|
112
|
+
return ok(receipt(false, false));
|
|
113
|
+
await this.#removeWorktree(path);
|
|
114
|
+
return ok(receipt(true, false));
|
|
115
|
+
}
|
|
116
|
+
const branch = (existed ? await this.#branchAt(path) : null) ?? this.#branchFor(sessionId);
|
|
117
|
+
if (!existed && !(await this.#branchExists(branch)))
|
|
118
|
+
return ok(receipt(false, false));
|
|
119
|
+
if (options.force !== true) {
|
|
120
|
+
const unmerged = await this.#unmergedProblem(branch);
|
|
121
|
+
if (unmerged !== null)
|
|
122
|
+
return refuse('branch-not-merged', `${unmerged} — nothing was removed`);
|
|
123
|
+
}
|
|
124
|
+
if (existed)
|
|
125
|
+
await this.#removeWorktree(path);
|
|
126
|
+
try {
|
|
127
|
+
await this.#commands.run('git', ['branch', options.force === true ? '-D' : '-d', branch], this.#repositoryRoot);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const removed = existed ? `removed the worktree at ${path} but ` : '';
|
|
131
|
+
return ok(receipt(existed, false, {
|
|
132
|
+
reason: 'workspace-release-failed',
|
|
133
|
+
detail: `${removed}could not delete branch ${branch}: ${describe(error)}`,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
return ok(receipt(existed, true));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return refuse('workspace-release-failed', `could not release the worktree for ${sessionId} at ${path}: ${describe(error)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The key of the directory at `path` when it sits directly under the workspace root, else null. */
|
|
143
|
+
keyForPath(path) {
|
|
144
|
+
return keyDirectlyUnder(path, this.#workspaceRoot);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* `--force` because a worktree with uncommitted changes is still a worktree the caller asked to
|
|
148
|
+
* remove; refusing here would leave it registered with no way for this API to clear it.
|
|
149
|
+
*/
|
|
150
|
+
async #removeWorktree(path) {
|
|
151
|
+
await this.#commands.run('git', ['worktree', 'remove', '--force', path], this.#repositoryRoot);
|
|
152
|
+
}
|
|
153
|
+
/** The branch the worktree at `path` is on, or null when it is detached or not a worktree git knows. */
|
|
154
|
+
async #branchAt(path) {
|
|
155
|
+
const porcelain = await this.#commands.run('git', ['worktree', 'list', '--porcelain'], this.#repositoryRoot);
|
|
156
|
+
const entry = parseWorktreePorcelain(porcelain).find((worktree) => normalizePath(worktree.path) === path);
|
|
157
|
+
return entry?.branch ?? null;
|
|
158
|
+
}
|
|
159
|
+
/** Why `branch` may not be deleted without force, or null when it is merged into the default branch. */
|
|
160
|
+
async #unmergedProblem(branch) {
|
|
161
|
+
const defaultBranch = await this.#defaultBranch();
|
|
162
|
+
if (defaultBranch === null) {
|
|
163
|
+
return `branch ${branch} cannot be judged merged: the repository names no default branch; force the deletion to delete it anyway`;
|
|
164
|
+
}
|
|
165
|
+
const merged = parseBranchList(await this.#commands.run('git', ['branch', '--merged', defaultBranch, '--format=%(refname:short)'], this.#repositoryRoot));
|
|
166
|
+
if (merged.has(branch))
|
|
167
|
+
return null;
|
|
168
|
+
return `branch ${branch} is not merged into ${defaultBranch}; force the deletion to delete it anyway`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Every worktree under the workspace root, from disk, with what git knows about its branch.
|
|
172
|
+
*
|
|
173
|
+
* Three commands for the whole list, plus one `rev-list --count` per branch entry: `worktree list --porcelain` for the worktrees, `for-each-ref`
|
|
174
|
+
* for every branch tip's date, and `branch --merged <default>` for the merged set. Only worktrees
|
|
175
|
+
* CONTAINED BY the workspace root are reported: the repository itself and any checkout the
|
|
176
|
+
* operator keeps elsewhere are not this provider's to list, so no cleanup composed from this
|
|
177
|
+
* answer can name them. `merged` is null when the repository names no default branch.
|
|
178
|
+
*/
|
|
179
|
+
async inventory() {
|
|
180
|
+
try {
|
|
181
|
+
const porcelain = await this.#commands.run('git', ['worktree', 'list', '--porcelain'], this.#repositoryRoot);
|
|
182
|
+
const provisioned = parseWorktreePorcelain(porcelain)
|
|
183
|
+
.map((worktree) => ({ ...worktree, path: normalizePath(worktree.path) }))
|
|
184
|
+
.filter((worktree) => !worktree.bare &&
|
|
185
|
+
worktree.path !== this.#workspaceRoot &&
|
|
186
|
+
isContainedBy(worktree.path, this.#workspaceRoot));
|
|
187
|
+
const defaultBranch = await this.#defaultBranch();
|
|
188
|
+
const merged = defaultBranch === null
|
|
189
|
+
? null
|
|
190
|
+
: parseBranchList(await this.#commands.run('git', ['branch', '--merged', defaultBranch, '--format=%(refname:short)'], this.#repositoryRoot));
|
|
191
|
+
const tips = parseBranchTips(await this.#commands.run('git', ['for-each-ref', '--format=%(refname:short)\t%(committerdate:iso-strict)', 'refs/heads/'], this.#repositoryRoot));
|
|
192
|
+
const entries = [];
|
|
193
|
+
for (const worktree of provisioned) {
|
|
194
|
+
entries.push({
|
|
195
|
+
key: lastSegment(worktree.path),
|
|
196
|
+
path: worktree.path,
|
|
197
|
+
branch: worktree.branch,
|
|
198
|
+
head: worktree.head,
|
|
199
|
+
detached: worktree.detached,
|
|
200
|
+
locked: worktree.locked,
|
|
201
|
+
prunable: worktree.prunable,
|
|
202
|
+
merged: merged === null || worktree.branch === null ? null : merged.has(worktree.branch),
|
|
203
|
+
// One `rev-list --count` per BRANCH entry: the third reading ("nothing here yet") needs the
|
|
204
|
+
// count, and git offers no batch form of it. A count that fails to read is null, never zero.
|
|
205
|
+
aheadCount: defaultBranch === null || worktree.branch === null
|
|
206
|
+
? null
|
|
207
|
+
: await this.#aheadCount(defaultBranch, worktree.branch),
|
|
208
|
+
lastCommitAt: worktree.branch === null ? null : (tips.get(worktree.branch) ?? null),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Newest first by the tip's date, the ones without a date last, ties by key: the order a
|
|
212
|
+
// controller pages through, so a fresh worktree is on the first page rather than wherever git
|
|
213
|
+
// happened to list it.
|
|
214
|
+
entries.sort(byNewestTip);
|
|
215
|
+
return ok({ entries, defaultBranch });
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
return refuse('workspace-list-failed', describe(error));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The branch `merged` is judged against: the remote's HEAD when the repository has one, else
|
|
223
|
+
* `main`, else `master`, else null. Probed, never assumed, and a probe that fails reads as
|
|
224
|
+
* "not this one" because every arm's failure is the absence it tests for.
|
|
225
|
+
*/
|
|
226
|
+
async #defaultBranch() {
|
|
227
|
+
try {
|
|
228
|
+
const remoteHead = await this.#commands.run('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], this.#repositoryRoot);
|
|
229
|
+
const short = remoteHead.replace(/^origin\//, '').trim();
|
|
230
|
+
if (short !== '')
|
|
231
|
+
return short;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// No remote HEAD recorded; fall through to the conventional names.
|
|
235
|
+
}
|
|
236
|
+
for (const candidate of ['main', 'master']) {
|
|
237
|
+
if (await this.#branchExists(candidate))
|
|
238
|
+
return candidate;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
/** Commits on `branch` past `defaultBranch`, or null when git cannot say. */
|
|
243
|
+
async #aheadCount(defaultBranch, branch) {
|
|
244
|
+
try {
|
|
245
|
+
const text = await this.#commands.run('git', ['rev-list', '--count', `${defaultBranch}..${branch}`], this.#repositoryRoot);
|
|
246
|
+
const count = Number.parseInt(text.trim(), 10);
|
|
247
|
+
return Number.isInteger(count) && count >= 0 ? count : null;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** `rev-parse --verify` succeeds for a branch that exists and fails for one that does not. */
|
|
254
|
+
async #branchExists(branch) {
|
|
255
|
+
try {
|
|
256
|
+
await this.#commands.run('git', ['rev-parse', '--verify', branch], this.#repositoryRoot);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// A failing probe means "treat it as new", which takes the `-B` form — so a probe that fails
|
|
261
|
+
// for a reason other than the branch being absent (a broken git, a bad repository path) would
|
|
262
|
+
// hard-reset an existing branch. That is why `provision` runs the probe inside its own
|
|
263
|
+
// try/catch: the `add` that follows fails too, and the refusal names the whole operation
|
|
264
|
+
// rather than silently taking the destructive path.
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* A session id that cannot safely become a path segment or a branch-name component.
|
|
271
|
+
*
|
|
272
|
+
* Refused rather than sanitized. Rewriting an id to make it safe means the directory no longer
|
|
273
|
+
* corresponds to the id the caller used, so two different sessions can collapse onto one workspace —
|
|
274
|
+
* which is the exact isolation property the plain-directory tests check, broken quietly by a
|
|
275
|
+
* helpful-looking fix.
|
|
276
|
+
*
|
|
277
|
+
* The screen is the union of two rule sets. The id becomes both a directory segment (win32/POSIX)
|
|
278
|
+
* and a git branch-name component (`periscope/{id}` by default); an id containing a character that
|
|
279
|
+
* only git refuses would pass a separators-only screen and fail inside git as an unnamed
|
|
280
|
+
* `workspace-provision-failed`. So the guard screens every character either rule set refuses — the
|
|
281
|
+
* `git check-ref-format` component rules together with the win32 segment rules — and the refusal
|
|
282
|
+
* names the offending character and its rule. Deliberately not screened: win32 reserved device
|
|
283
|
+
* names (CON, NUL, …) — a name-list, not a character class; those still die loudly at provision
|
|
284
|
+
* under the named refusal below.
|
|
285
|
+
*/
|
|
286
|
+
function rejectUnusableId(sessionId) {
|
|
287
|
+
if (sessionId.trim() === '') {
|
|
288
|
+
return refuse('workspace-provision-failed', 'a session id is required to name a workspace');
|
|
289
|
+
}
|
|
290
|
+
if (sessionId.length > MAX_WORKSPACE_ID_LENGTH) {
|
|
291
|
+
return refuse('workspace-provision-failed', `the session id ${keyPreview(sessionId)} is longer than ${MAX_WORKSPACE_ID_LENGTH} characters, so it cannot name a worktree branch or directory`);
|
|
292
|
+
}
|
|
293
|
+
if (/[\\/]/.test(sessionId) || sessionId.includes('..')) {
|
|
294
|
+
return refuse('workspace-provision-failed', `the session id ${keyPreview(sessionId)} contains path separators or a parent reference, so it cannot name a directory`);
|
|
295
|
+
}
|
|
296
|
+
const illegality = refnameOrPathIllegality(sessionId);
|
|
297
|
+
if (illegality !== null) {
|
|
298
|
+
return refuse('workspace-provision-failed', `the session id ${keyPreview(sessionId)} ${illegality}, so it cannot name a worktree branch or directory`);
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/** Which rule refuses each screened character — quoted verbatim into the refusal detail. */
|
|
303
|
+
const CHARACTER_LAWS = new Map([
|
|
304
|
+
[' ', 'illegal in a git refname'],
|
|
305
|
+
[':', 'illegal in a git refname AND a win32 path segment'],
|
|
306
|
+
['~', 'illegal in a git refname'],
|
|
307
|
+
['^', 'illegal in a git refname'],
|
|
308
|
+
['[', 'illegal in a git refname'],
|
|
309
|
+
['?', 'illegal in a git refname AND a win32 path segment'],
|
|
310
|
+
['*', 'illegal in a git refname AND a win32 path segment'],
|
|
311
|
+
['"', 'illegal in a win32 path segment'],
|
|
312
|
+
['<', 'illegal in a win32 path segment'],
|
|
313
|
+
['>', 'illegal in a win32 path segment'],
|
|
314
|
+
['|', 'illegal in a win32 path segment'],
|
|
315
|
+
]);
|
|
316
|
+
/**
|
|
317
|
+
* The refname/path-segment class check: the first way this id would be refused by
|
|
318
|
+
* `git check-ref-format` (as the component of a branch name) or by win32 (as a path segment), as a
|
|
319
|
+
* human sentence — or null for an id both rule sets accept. Pure and total, exported so the class
|
|
320
|
+
* is testable without a provider.
|
|
321
|
+
*/
|
|
322
|
+
export function refnameOrPathIllegality(sessionId) {
|
|
323
|
+
// deliberate: the control range is the thing being refused, so the regex names it
|
|
324
|
+
// eslint-disable-next-line no-control-regex
|
|
325
|
+
const control = sessionId.match(/[\u0000-\u001f\u007f]/);
|
|
326
|
+
if (control !== null) {
|
|
327
|
+
const code = control[0].codePointAt(0).toString(16).padStart(2, '0');
|
|
328
|
+
return `contains a control character (0x${code}) — illegal in a git refname and a win32 path segment`;
|
|
329
|
+
}
|
|
330
|
+
const character = sessionId.match(/[ :~^?*"<>|[]/);
|
|
331
|
+
if (character !== null) {
|
|
332
|
+
return `contains '${character[0]}' — ${CHARACTER_LAWS.get(character[0])}`;
|
|
333
|
+
}
|
|
334
|
+
if (sessionId.includes('@{'))
|
|
335
|
+
return `contains '@{' — illegal in a git refname`;
|
|
336
|
+
if (sessionId.startsWith('.'))
|
|
337
|
+
return `begins with '.' — a git refname component cannot`;
|
|
338
|
+
if (sessionId.endsWith('.lock'))
|
|
339
|
+
return `ends with '.lock' — a git refname component cannot`;
|
|
340
|
+
if (sessionId.endsWith('.'))
|
|
341
|
+
return `ends with '.' — illegal in a git refname component and a win32 path segment`;
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
export { rejectUnusableId };
|
|
345
|
+
/**
|
|
346
|
+
* The same screen as `rejectUnusableId`, as a sentence a caller can attach to its own noun.
|
|
347
|
+
*
|
|
348
|
+
* The host validates `session_new.workspaceKey` before any workspace is claimed, and its refusal
|
|
349
|
+
* must name the field — "the session id …" would send a controller author to the wrong key. The
|
|
350
|
+
* union class itself stays single-sourced in `refnameOrPathIllegality`; the two structural checks
|
|
351
|
+
* are restated here because their sentences are the caller's to phrase. Keep the three checks in
|
|
352
|
+
* step with `rejectUnusableId` above — a key this accepts and that refuses (or the reverse) would
|
|
353
|
+
* mean the host's guard and the provider's disagree about the same id.
|
|
354
|
+
*/
|
|
355
|
+
export function unusableKeyProblem(key) {
|
|
356
|
+
if (key.trim() === '')
|
|
357
|
+
return 'is empty, so it cannot name a workspace';
|
|
358
|
+
if (key.length > MAX_WORKSPACE_ID_LENGTH) {
|
|
359
|
+
return `is ${key.length} characters long — over the ${MAX_WORKSPACE_ID_LENGTH}-character bound, so it cannot name a worktree branch or directory`;
|
|
360
|
+
}
|
|
361
|
+
if (/[\\/]/.test(key) || key.includes('..')) {
|
|
362
|
+
return 'contains path separators or a parent reference, so it cannot name a directory';
|
|
363
|
+
}
|
|
364
|
+
const illegality = refnameOrPathIllegality(key);
|
|
365
|
+
if (illegality !== null)
|
|
366
|
+
return `${illegality}, so it cannot name a worktree branch or directory`;
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/** The bound on a workspace id; declared in `core/` so the wire codec can enforce it too. */
|
|
370
|
+
export { MAX_WORKSPACE_ID_LENGTH };
|
|
371
|
+
/**
|
|
372
|
+
* A key safe to echo into a refusal detail: verbatim when short, truncated with its length named
|
|
373
|
+
* when not. Every screen above bounds accepted ids, but the refusal for an over-length id still
|
|
374
|
+
* has to describe the very string that broke the bound — this is the one place an unbounded input
|
|
375
|
+
* reaches a detail, so it is bounded here rather than at each caller's discretion.
|
|
376
|
+
*/
|
|
377
|
+
export function keyPreview(id) {
|
|
378
|
+
if (id.length <= 100)
|
|
379
|
+
return id;
|
|
380
|
+
return `${id.slice(0, 100)}… (${id.length} characters)`;
|
|
381
|
+
}
|
|
382
|
+
function describe(error) {
|
|
383
|
+
return error instanceof Error ? error.message : String(error);
|
|
384
|
+
}
|
|
385
|
+
/** Newest tip first; entries with no tip date (detached, unknown) after every dated one; ties by key. */
|
|
386
|
+
export function byNewestTip(a, b) {
|
|
387
|
+
if (a.lastCommitAt === null && b.lastCommitAt === null)
|
|
388
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
389
|
+
if (a.lastCommitAt === null)
|
|
390
|
+
return 1;
|
|
391
|
+
if (b.lastCommitAt === null)
|
|
392
|
+
return -1;
|
|
393
|
+
const byDate = Date.parse(b.lastCommitAt) - Date.parse(a.lastCommitAt);
|
|
394
|
+
if (byDate !== 0 && !Number.isNaN(byDate))
|
|
395
|
+
return byDate;
|
|
396
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
397
|
+
}
|
|
398
|
+
/** The last path segment: the directory name, which is the key a worktree was provisioned at. */
|
|
399
|
+
function lastSegment(path) {
|
|
400
|
+
const segments = path.split('/').filter((segment) => segment !== '');
|
|
401
|
+
return segments[segments.length - 1] ?? '';
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The key of `path` when it is an absolute path to a directory DIRECTLY under `root` (normalised),
|
|
405
|
+
* else null: the root itself, a nested path, a relative path and anything elsewhere are all null,
|
|
406
|
+
* so a release addressed by path can only ever name what a provision could have made.
|
|
407
|
+
*/
|
|
408
|
+
export function keyDirectlyUnder(path, root) {
|
|
409
|
+
if (!isAbsolutePath(path))
|
|
410
|
+
return null;
|
|
411
|
+
const candidate = normalizePath(path);
|
|
412
|
+
if (candidate === root || !isContainedBy(candidate, root))
|
|
413
|
+
return null;
|
|
414
|
+
const key = lastSegment(candidate);
|
|
415
|
+
return candidate === `${root}/${key}` ? key : null;
|
|
416
|
+
}
|
|
417
|
+
//# sourceMappingURL=git-worktree.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Workspace provisioning and the cwd a session runs in. */
|
|
2
|
+
export type { CommandEffects, ReleaseOptions, ReleaseReceipt, Workspace, WorkspaceEffects, WorkspaceEntry, WorkspaceInventory, WorkspaceProvider, } from './provider.js';
|
|
3
|
+
export type { PorcelainWorktree } from './worktree-porcelain.js';
|
|
4
|
+
export { parseBranchList, parseBranchTips, parseWorktreePorcelain } from './worktree-porcelain.js';
|
|
5
|
+
export type { PlainDirProviderOptions } from './plain-dir.js';
|
|
6
|
+
export { PlainDirProvider } from './plain-dir.js';
|
|
7
|
+
export type { GitWorktreeProviderOptions } from './git-worktree.js';
|
|
8
|
+
export { GitWorktreeProvider, worktreeAddArgs } from './git-worktree.js';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One plain directory per session, under a root the embedder names.
|
|
3
|
+
*
|
|
4
|
+
* The simplest thing that satisfies the contract, and enough to prove an agent works: no git, no
|
|
5
|
+
* branches, no conventions. A code-review tool that clones somewhere itself, or a host running
|
|
6
|
+
* agents over scratch space, wants exactly this and nothing more.
|
|
7
|
+
*
|
|
8
|
+
* Isolation is by construction, not by check. Each session's directory is a distinct child of the
|
|
9
|
+
* root named by its own id, so two sessions cannot share one unless they share an id — and ids are
|
|
10
|
+
* refused if they contain separators, which is the only way one could climb into another's.
|
|
11
|
+
*/
|
|
12
|
+
import type { Result } from '../core/result.js';
|
|
13
|
+
import type { ReleaseOptions, ReleaseReceipt, Workspace, WorkspaceEffects, WorkspaceInventory, WorkspaceProvider } from './provider.js';
|
|
14
|
+
export interface PlainDirProviderOptions {
|
|
15
|
+
/** Absolute. Every session directory is created directly beneath it. */
|
|
16
|
+
readonly root: string;
|
|
17
|
+
readonly effects: WorkspaceEffects;
|
|
18
|
+
}
|
|
19
|
+
export declare class PlainDirProvider implements WorkspaceProvider {
|
|
20
|
+
#private;
|
|
21
|
+
constructor(options: PlainDirProviderOptions);
|
|
22
|
+
/** Where this session's directory is, whether or not it exists yet. */
|
|
23
|
+
pathFor(sessionId: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The directories under the root. A plain directory has no branch, so every git-shaped member is
|
|
26
|
+
* null or false, and `merged` is null: nothing here can say whether work is kept anywhere.
|
|
27
|
+
*/
|
|
28
|
+
inventory(): Promise<Result<WorkspaceInventory>>;
|
|
29
|
+
provision(sessionId: string): Promise<Result<Workspace>>;
|
|
30
|
+
/** The key of the directory at `path` when it sits directly under the root, else null. */
|
|
31
|
+
keyForPath(path: string): string | null;
|
|
32
|
+
release(sessionId: string, options?: ReleaseOptions): Promise<Result<ReleaseReceipt | undefined>>;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=plain-dir.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ok, refuse } from '../core/result.js';
|
|
2
|
+
import { isAbsolutePath, normalizePath } from '../core/paths.js';
|
|
3
|
+
import { keyDirectlyUnder, rejectUnusableId } from './git-worktree.js';
|
|
4
|
+
export class PlainDirProvider {
|
|
5
|
+
#root;
|
|
6
|
+
#effects;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.#root = normalizePath(options.root);
|
|
9
|
+
this.#effects = options.effects;
|
|
10
|
+
}
|
|
11
|
+
/** Where this session's directory is, whether or not it exists yet. */
|
|
12
|
+
pathFor(sessionId) {
|
|
13
|
+
return `${this.#root}/${sessionId}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The directories under the root. A plain directory has no branch, so every git-shaped member is
|
|
17
|
+
* null or false, and `merged` is null: nothing here can say whether work is kept anywhere.
|
|
18
|
+
*/
|
|
19
|
+
async inventory() {
|
|
20
|
+
const list = this.#effects.listDirectories?.bind(this.#effects);
|
|
21
|
+
if (list === undefined) {
|
|
22
|
+
return refuse('workspace-list-failed', 'this provider has no directory listing to read');
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const names = await list(this.#root);
|
|
26
|
+
const entries = names.map((name) => ({
|
|
27
|
+
key: name,
|
|
28
|
+
path: `${this.#root}/${name}`,
|
|
29
|
+
branch: null,
|
|
30
|
+
head: null,
|
|
31
|
+
detached: false,
|
|
32
|
+
locked: false,
|
|
33
|
+
prunable: false,
|
|
34
|
+
merged: null,
|
|
35
|
+
aheadCount: null,
|
|
36
|
+
lastCommitAt: null,
|
|
37
|
+
}));
|
|
38
|
+
return ok({ entries, defaultBranch: null });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return refuse('workspace-list-failed', describe(error));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async provision(sessionId) {
|
|
45
|
+
const invalid = rejectUnusableId(sessionId);
|
|
46
|
+
if (invalid !== null)
|
|
47
|
+
return invalid;
|
|
48
|
+
if (!isAbsolutePath(this.#root)) {
|
|
49
|
+
return refuse('workspace-provision-failed', `the workspace root must be absolute: ${this.#root}`);
|
|
50
|
+
}
|
|
51
|
+
const path = this.pathFor(sessionId);
|
|
52
|
+
try {
|
|
53
|
+
// Creating an existing directory is a no-op, so re-provisioning attaches to whatever is there
|
|
54
|
+
// rather than replacing it. Same rule as the worktree provider, for the same reason: a host
|
|
55
|
+
// that lost track of a directory must not destroy its contents on the way back.
|
|
56
|
+
const reused = await this.#effects.exists(path);
|
|
57
|
+
await this.#effects.makeDirectory(path);
|
|
58
|
+
return ok({ path, meta: { attached: reused ? 'directory' : 'created' } });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return refuse('workspace-provision-failed', `could not provision a directory for ${sessionId} at ${path}: ${describe(error)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** The key of the directory at `path` when it sits directly under the root, else null. */
|
|
65
|
+
keyForPath(path) {
|
|
66
|
+
return keyDirectlyUnder(path, this.#root);
|
|
67
|
+
}
|
|
68
|
+
async release(sessionId, options) {
|
|
69
|
+
// The default is to leave it — see ReleaseOptions. A directory nobody asked to remove is
|
|
70
|
+
// evidence, and deleting it by default destroys it exactly when a session ended badly.
|
|
71
|
+
if (options?.remove !== true)
|
|
72
|
+
return ok(undefined);
|
|
73
|
+
// A plain directory has no branch, so `deleteBranch` finds nothing to delete: the receipt says
|
|
74
|
+
// so (`branchDeleted: false`, no refusal), the idempotent answer for a thing already absent.
|
|
75
|
+
const path = this.pathFor(sessionId);
|
|
76
|
+
try {
|
|
77
|
+
const existed = await this.#effects.exists(path);
|
|
78
|
+
if (existed)
|
|
79
|
+
await this.#effects.removeDirectory(path);
|
|
80
|
+
return ok({ path, directoryRemoved: existed, branchDeleted: false, refusal: null });
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return refuse('workspace-release-failed', `could not release the directory for ${sessionId} at ${path}: ${describe(error)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function describe(error) {
|
|
88
|
+
return error instanceof Error ? error.message : String(error);
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=plain-dir.js.map
|