@naswerks/periscope 1.0.0 → 1.0.1
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 +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,132 @@
|
|
|
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 { ok, refuse } from '../core/result.js';
|
|
14
|
+
import { isAbsolutePath, normalizePath } from '../core/paths.js';
|
|
15
|
+
import type {
|
|
16
|
+
ReleaseOptions,
|
|
17
|
+
ReleaseReceipt,
|
|
18
|
+
Workspace,
|
|
19
|
+
WorkspaceEffects,
|
|
20
|
+
WorkspaceEntry,
|
|
21
|
+
WorkspaceInventory,
|
|
22
|
+
WorkspaceProvider,
|
|
23
|
+
} from './provider.js';
|
|
24
|
+
import { keyDirectlyUnder, rejectUnusableId } from './git-worktree.js';
|
|
25
|
+
|
|
26
|
+
export interface PlainDirProviderOptions {
|
|
27
|
+
/** Absolute. Every session directory is created directly beneath it. */
|
|
28
|
+
readonly root: string;
|
|
29
|
+
readonly effects: WorkspaceEffects;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PlainDirProvider implements WorkspaceProvider {
|
|
33
|
+
readonly #root: string;
|
|
34
|
+
readonly #effects: WorkspaceEffects;
|
|
35
|
+
|
|
36
|
+
constructor(options: PlainDirProviderOptions) {
|
|
37
|
+
this.#root = normalizePath(options.root);
|
|
38
|
+
this.#effects = options.effects;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where this session's directory is, whether or not it exists yet. */
|
|
42
|
+
pathFor(sessionId: string): string {
|
|
43
|
+
return `${this.#root}/${sessionId}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The directories under the root. A plain directory has no branch, so every git-shaped member is
|
|
48
|
+
* null or false, and `merged` is null: nothing here can say whether work is kept anywhere.
|
|
49
|
+
*/
|
|
50
|
+
async inventory(): Promise<Result<WorkspaceInventory>> {
|
|
51
|
+
const list = this.#effects.listDirectories?.bind(this.#effects);
|
|
52
|
+
if (list === undefined) {
|
|
53
|
+
return refuse<WorkspaceInventory>(
|
|
54
|
+
'workspace-list-failed',
|
|
55
|
+
'this provider has no directory listing to read',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const names = await list(this.#root);
|
|
60
|
+
const entries: WorkspaceEntry[] = names.map((name) => ({
|
|
61
|
+
key: name,
|
|
62
|
+
path: `${this.#root}/${name}`,
|
|
63
|
+
branch: null,
|
|
64
|
+
head: null,
|
|
65
|
+
detached: false,
|
|
66
|
+
locked: false,
|
|
67
|
+
prunable: false,
|
|
68
|
+
merged: null,
|
|
69
|
+
aheadCount: null,
|
|
70
|
+
lastCommitAt: null,
|
|
71
|
+
}));
|
|
72
|
+
return ok({ entries, defaultBranch: null });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return refuse<WorkspaceInventory>('workspace-list-failed', describe(error));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async provision(sessionId: string): Promise<Result<Workspace>> {
|
|
79
|
+
const invalid = rejectUnusableId<Workspace>(sessionId);
|
|
80
|
+
if (invalid !== null) return invalid;
|
|
81
|
+
if (!isAbsolutePath(this.#root)) {
|
|
82
|
+
return refuse<Workspace>(
|
|
83
|
+
'workspace-provision-failed',
|
|
84
|
+
`the workspace root must be absolute: ${this.#root}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const path = this.pathFor(sessionId);
|
|
89
|
+
try {
|
|
90
|
+
// Creating an existing directory is a no-op, so re-provisioning attaches to whatever is there
|
|
91
|
+
// rather than replacing it. Same rule as the worktree provider, for the same reason: a host
|
|
92
|
+
// that lost track of a directory must not destroy its contents on the way back.
|
|
93
|
+
const reused = await this.#effects.exists(path);
|
|
94
|
+
await this.#effects.makeDirectory(path);
|
|
95
|
+
return ok({ path, meta: { attached: reused ? 'directory' : 'created' } });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return refuse<Workspace>(
|
|
98
|
+
'workspace-provision-failed',
|
|
99
|
+
`could not provision a directory for ${sessionId} at ${path}: ${describe(error)}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The key of the directory at `path` when it sits directly under the root, else null. */
|
|
105
|
+
keyForPath(path: string): string | null {
|
|
106
|
+
return keyDirectlyUnder(path, this.#root);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async release(sessionId: string, options?: ReleaseOptions): Promise<Result<ReleaseReceipt | undefined>> {
|
|
110
|
+
// The default is to leave it — see ReleaseOptions. A directory nobody asked to remove is
|
|
111
|
+
// evidence, and deleting it by default destroys it exactly when a session ended badly.
|
|
112
|
+
if (options?.remove !== true) return ok(undefined);
|
|
113
|
+
|
|
114
|
+
// A plain directory has no branch, so `deleteBranch` finds nothing to delete: the receipt says
|
|
115
|
+
// so (`branchDeleted: false`, no refusal), the idempotent answer for a thing already absent.
|
|
116
|
+
const path = this.pathFor(sessionId);
|
|
117
|
+
try {
|
|
118
|
+
const existed = await this.#effects.exists(path);
|
|
119
|
+
if (existed) await this.#effects.removeDirectory(path);
|
|
120
|
+
return ok({ path, directoryRemoved: existed, branchDeleted: false, refusal: null });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return refuse<ReleaseReceipt>(
|
|
123
|
+
'workspace-release-failed',
|
|
124
|
+
`could not release the directory for ${sessionId} at ${path}: ${describe(error)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function describe(error: unknown): string {
|
|
131
|
+
return error instanceof Error ? error.message : String(error);
|
|
132
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory a session runs in, and who decides what it looks like.
|
|
3
|
+
*
|
|
4
|
+
* This is entirely the host's job. The SDK has no provisioning surface at all — it takes a `cwd`
|
|
5
|
+
* and assumes something already made it. So the choice of what a workspace is — a plain directory, a
|
|
6
|
+
* checkout, a linked worktree, a container mount — is the embedder's, and this is the seam where
|
|
7
|
+
* that choice plugs in.
|
|
8
|
+
*
|
|
9
|
+
* The provider is the embedder's, not the package's. Two implementations ship because they cover
|
|
10
|
+
* the two shapes almost everyone needs, but the interesting policies are not here and must not move
|
|
11
|
+
* here: a shared directory per task, a write-once key, a branch naming rule, a cleanup schedule.
|
|
12
|
+
* Those are the embedder's own conventions, and a tool that reviews code in a throwaway clone
|
|
13
|
+
* should be able to write its own provider without ever learning that any of them exist.
|
|
14
|
+
*
|
|
15
|
+
* Every result is a named refusal, never an exception. `provision` failing is an ordinary outcome
|
|
16
|
+
* — a disk is full, a path is taken, a git command failed — and the caller has to decide what to do
|
|
17
|
+
* about it. A thrown error at this seam would make "the session could not get a directory"
|
|
18
|
+
* indistinguishable from a bug in the provider, and the package names the difference everywhere else.
|
|
19
|
+
*/
|
|
20
|
+
import type { Result } from '../core/result.js';
|
|
21
|
+
import type { Refusal } from '../core/refusal.js';
|
|
22
|
+
|
|
23
|
+
/** Where a session works, plus whatever the provider wants the caller to know about it. */
|
|
24
|
+
export interface Workspace {
|
|
25
|
+
/** Absolute, always. This is what becomes the session's `cwd`. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
/**
|
|
28
|
+
* Provider-specific facts, as strings.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately opaque to this package. A git provider reports its branch and base ref here; a
|
|
31
|
+
* plain-directory provider reports almost nothing; somebody else's reports a container id. Nothing
|
|
32
|
+
* in the host reads a key out of this — it exists so a provider can tell its caller something
|
|
33
|
+
* without this file having to grow a field for every provider anyone might write.
|
|
34
|
+
*/
|
|
35
|
+
readonly meta: Readonly<Record<string, string>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What `release` should do with the directory. */
|
|
39
|
+
export interface ReleaseOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Remove the workspace from disk. Defaults to false.
|
|
42
|
+
*
|
|
43
|
+
* The default is "leave it", and that is a deliberate asymmetry. A session that ended badly is
|
|
44
|
+
* one whose directory somebody wants to look at, and a host that tidied up by default would
|
|
45
|
+
* destroy the evidence exactly when it matters. Leaving a directory costs disk; deleting one that
|
|
46
|
+
* was still wanted costs the investigation.
|
|
47
|
+
*/
|
|
48
|
+
readonly remove?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Delete the branch the worktree is on, after the directory. Defaults to false, and only means
|
|
51
|
+
* anything with `remove`. Deleting a branch is not reversible the way removing a directory is,
|
|
52
|
+
* so it is asked for per call and never remembered.
|
|
53
|
+
*/
|
|
54
|
+
readonly deleteBranch?: boolean;
|
|
55
|
+
/** Delete the branch even when it is not merged into the repository's default branch. */
|
|
56
|
+
readonly force?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a release did. `refusal` is the partial: the directory went and the branch did not, said
|
|
61
|
+
* rather than hidden. Null means everything asked for happened, or was already absent.
|
|
62
|
+
*/
|
|
63
|
+
export interface ReleaseReceipt {
|
|
64
|
+
/** The workspace's path as this provider knows it. */
|
|
65
|
+
readonly path: string;
|
|
66
|
+
/** A directory existed and is gone. */
|
|
67
|
+
readonly directoryRemoved: boolean;
|
|
68
|
+
readonly branchDeleted: boolean;
|
|
69
|
+
readonly refusal: Refusal | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One workspace on disk, as the provider's inventory reports it. See `WorkspaceListEntry` on the wire. */
|
|
73
|
+
export interface WorkspaceEntry {
|
|
74
|
+
readonly key: string;
|
|
75
|
+
readonly path: string;
|
|
76
|
+
readonly branch: string | null;
|
|
77
|
+
readonly head: string | null;
|
|
78
|
+
readonly detached: boolean;
|
|
79
|
+
readonly locked: boolean;
|
|
80
|
+
readonly prunable: boolean;
|
|
81
|
+
readonly merged: boolean | null;
|
|
82
|
+
/** Commits past the default branch; null when detached or when no default branch was named. */
|
|
83
|
+
readonly aheadCount: number | null;
|
|
84
|
+
readonly lastCommitAt: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `inventory` answers: every workspace under the root, and the default branch `merged` was judged against. */
|
|
88
|
+
export interface WorkspaceInventory {
|
|
89
|
+
readonly entries: readonly WorkspaceEntry[];
|
|
90
|
+
readonly defaultBranch: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WorkspaceProvider {
|
|
94
|
+
/**
|
|
95
|
+
* Get the directory for this session, creating it if needed.
|
|
96
|
+
*
|
|
97
|
+
* It must be safe to call twice for one session. A host that lost a directory — a cleanup ran,
|
|
98
|
+
* a volume remounted, a container restarted — re-provisions, and the second call must attach to
|
|
99
|
+
* whatever survived rather than recreate it. That is not a nicety: see git-worktree.ts, where
|
|
100
|
+
* getting it wrong destroys committed work.
|
|
101
|
+
*/
|
|
102
|
+
provision(sessionId: string): Promise<Result<Workspace>>;
|
|
103
|
+
/**
|
|
104
|
+
* Done with it. Idempotent: releasing a session this provider does not hold succeeds. The
|
|
105
|
+
* receipt is optional so a provider that predates it still conforms; the host reads no receipt as
|
|
106
|
+
* "vouches for nothing" and reports both flags false.
|
|
107
|
+
*/
|
|
108
|
+
release(sessionId: string, options?: ReleaseOptions): Promise<Result<ReleaseReceipt | undefined>>;
|
|
109
|
+
/**
|
|
110
|
+
* The key of the workspace at `path`, when `path` is a directory directly under this provider's
|
|
111
|
+
* root; null for anything else. Optional: a provider without it cannot be addressed by path.
|
|
112
|
+
*/
|
|
113
|
+
keyForPath?(path: string): string | null;
|
|
114
|
+
/**
|
|
115
|
+
* What exists under the workspace root right now, read from disk on demand. Optional: a provider
|
|
116
|
+
* that keeps no inventory leaves it out and the host answers `workspace-list-failed` by name.
|
|
117
|
+
* Only what this provider provisioned is listed; the repository it links from never is.
|
|
118
|
+
*/
|
|
119
|
+
inventory?(): Promise<Result<WorkspaceInventory>>;
|
|
120
|
+
/**
|
|
121
|
+
* The repository this provider clones from, when it has one. The one directory a controller may
|
|
122
|
+
* name and get, provider or not: the operator's own checkout has no isolation to protect.
|
|
123
|
+
*/
|
|
124
|
+
readonly repositoryRoot?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The filesystem and process effects a provider needs, as injected functions.
|
|
129
|
+
*
|
|
130
|
+
* Why this exists rather than a direct `node:fs` import. `src/host/` is the only directory allowed
|
|
131
|
+
* to touch the machine (pinned by pins/host-boundary.test.ts), and the provider logic — which argv a
|
|
132
|
+
* git call gets, when a directory is reused rather than made, what a release does — is exactly the
|
|
133
|
+
* part worth testing without a disk. So the logic lives here and the real implementations live in
|
|
134
|
+
* host/workspace-fs.ts, the same split the path jail already uses.
|
|
135
|
+
*
|
|
136
|
+
* The payoff is not tidiness: it makes "never hard-reset an existing branch" a pure unit test rather
|
|
137
|
+
* than something only a destroyed branch could have proven.
|
|
138
|
+
*/
|
|
139
|
+
export interface WorkspaceEffects {
|
|
140
|
+
/** Create a directory and every missing parent. Succeeds if it already exists. */
|
|
141
|
+
makeDirectory(path: string): Promise<void>;
|
|
142
|
+
/** Does anything exist at this path? */
|
|
143
|
+
exists(path: string): Promise<boolean>;
|
|
144
|
+
/** Remove a directory and everything under it. Succeeds if it is already gone. */
|
|
145
|
+
removeDirectory(path: string): Promise<void>;
|
|
146
|
+
/** The names of the directories directly under `path`, or an empty list when it does not exist. Optional. */
|
|
147
|
+
listDirectories?(path: string): Promise<string[]>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Running a command. Separate from the filesystem effects because only one provider needs it. */
|
|
151
|
+
export interface CommandEffects {
|
|
152
|
+
/**
|
|
153
|
+
* Run a program with an argv array and return its stdout, trimmed.
|
|
154
|
+
*
|
|
155
|
+
* Argv, never a command string. A shell string would make every branch name and path an
|
|
156
|
+
* injection site, and branch names come from a caller this package does not control.
|
|
157
|
+
*/
|
|
158
|
+
run(program: string, args: readonly string[], cwd: string): Promise<string>;
|
|
159
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Readers for the three git outputs an inventory is built from. Pure: text in, records out, no
|
|
3
|
+
* process and no filesystem, so the parsing is testable against fixtures and the provider that
|
|
4
|
+
* runs the commands stays thin.
|
|
5
|
+
*
|
|
6
|
+
* `git worktree list --porcelain` prints one block per worktree, blank-line separated, each line
|
|
7
|
+
* `<attribute> <value>` or a bare attribute: `worktree <path>`, `HEAD <sha>`, `branch <ref>`,
|
|
8
|
+
* `detached`, `locked [reason]`, `prunable [reason]`, `bare`. Attributes this reader does not know
|
|
9
|
+
* are carried past, never fatal, so a newer git cannot break the inventory by adding one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One worktree as git reports it. `branch` is the short name (`refs/heads/` stripped), null when detached. */
|
|
13
|
+
export interface PorcelainWorktree {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly head: string | null;
|
|
16
|
+
readonly branch: string | null;
|
|
17
|
+
readonly detached: boolean;
|
|
18
|
+
readonly locked: boolean;
|
|
19
|
+
readonly prunable: boolean;
|
|
20
|
+
readonly bare: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HEADS_PREFIX = 'refs/heads/';
|
|
24
|
+
|
|
25
|
+
export function parseWorktreePorcelain(text: string): PorcelainWorktree[] {
|
|
26
|
+
const worktrees: PorcelainWorktree[] = [];
|
|
27
|
+
let current: {
|
|
28
|
+
path: string;
|
|
29
|
+
head: string | null;
|
|
30
|
+
branch: string | null;
|
|
31
|
+
detached: boolean;
|
|
32
|
+
locked: boolean;
|
|
33
|
+
prunable: boolean;
|
|
34
|
+
bare: boolean;
|
|
35
|
+
} | null = null;
|
|
36
|
+
|
|
37
|
+
const flush = (): void => {
|
|
38
|
+
if (current !== null) worktrees.push({ ...current });
|
|
39
|
+
current = null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
43
|
+
const line = raw.trimEnd();
|
|
44
|
+
if (line === '') {
|
|
45
|
+
flush();
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const space = line.indexOf(' ');
|
|
49
|
+
const attribute = space === -1 ? line : line.slice(0, space);
|
|
50
|
+
const value = space === -1 ? '' : line.slice(space + 1);
|
|
51
|
+
if (attribute === 'worktree') {
|
|
52
|
+
flush();
|
|
53
|
+
current = {
|
|
54
|
+
path: value,
|
|
55
|
+
head: null,
|
|
56
|
+
branch: null,
|
|
57
|
+
detached: false,
|
|
58
|
+
locked: false,
|
|
59
|
+
prunable: false,
|
|
60
|
+
bare: false,
|
|
61
|
+
};
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (current === null) continue;
|
|
65
|
+
switch (attribute) {
|
|
66
|
+
case 'HEAD':
|
|
67
|
+
current.head = value === '' ? null : value;
|
|
68
|
+
break;
|
|
69
|
+
case 'branch':
|
|
70
|
+
current.branch = value.startsWith(HEADS_PREFIX) ? value.slice(HEADS_PREFIX.length) : value;
|
|
71
|
+
break;
|
|
72
|
+
case 'detached':
|
|
73
|
+
current.detached = true;
|
|
74
|
+
break;
|
|
75
|
+
case 'locked':
|
|
76
|
+
current.locked = true;
|
|
77
|
+
break;
|
|
78
|
+
case 'prunable':
|
|
79
|
+
current.prunable = true;
|
|
80
|
+
break;
|
|
81
|
+
case 'bare':
|
|
82
|
+
current.bare = true;
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
flush();
|
|
89
|
+
return worktrees;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `git for-each-ref --format='%(refname:short)%09%(committerdate:iso-strict)' refs/heads/`: one
|
|
94
|
+
* branch per line, tab-separated. Returns the tip's committer date per branch, as given.
|
|
95
|
+
*/
|
|
96
|
+
export function parseBranchTips(text: string): Map<string, string> {
|
|
97
|
+
const tips = new Map<string, string>();
|
|
98
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
99
|
+
const line = raw.trim();
|
|
100
|
+
if (line === '') continue;
|
|
101
|
+
const tab = line.indexOf('\t');
|
|
102
|
+
if (tab === -1) continue;
|
|
103
|
+
const branch = line.slice(0, tab).trim();
|
|
104
|
+
const at = line.slice(tab + 1).trim();
|
|
105
|
+
if (branch !== '' && at !== '') tips.set(branch, at);
|
|
106
|
+
}
|
|
107
|
+
return tips;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `git branch --merged <ref> --format='%(refname:short)'`: one branch per line. */
|
|
111
|
+
export function parseBranchList(text: string): Set<string> {
|
|
112
|
+
const branches = new Set<string>();
|
|
113
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
114
|
+
const line = raw.trim().replace(/^[*+]\s+/, '');
|
|
115
|
+
if (line !== '') branches.add(line);
|
|
116
|
+
}
|
|
117
|
+
return branches;
|
|
118
|
+
}
|