@vibesdotdev/agent-execution 0.0.0-stage → 1.0.0-rc.2
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 +18 -0
- package/README.md +53 -2
- package/dist/code-session/agent-code-session.consumer.descriptor.d.ts +68 -0
- package/dist/code-session/agent-code-session.consumer.descriptor.d.ts.map +1 -0
- package/dist/code-session/agent-code-session.consumer.descriptor.js +31 -0
- package/dist/code-session/agent-code-session.consumer.descriptor.js.map +1 -0
- package/dist/code-session/agent-code-session.consumer.plugin.d.ts +18 -0
- package/dist/code-session/agent-code-session.consumer.plugin.d.ts.map +1 -0
- package/dist/code-session/agent-code-session.consumer.plugin.js +47 -0
- package/dist/code-session/agent-code-session.consumer.plugin.js.map +1 -0
- package/dist/code-session/agent-code-session.descriptor.d.ts +163 -0
- package/dist/code-session/agent-code-session.descriptor.d.ts.map +1 -0
- package/dist/code-session/agent-code-session.descriptor.js +129 -0
- package/dist/code-session/agent-code-session.descriptor.js.map +1 -0
- package/dist/code-session/agent-code-session.impl.consumer.d.ts +224 -0
- package/dist/code-session/agent-code-session.impl.consumer.d.ts.map +1 -0
- package/dist/code-session/agent-code-session.impl.consumer.js +2131 -0
- package/dist/code-session/agent-code-session.impl.consumer.js.map +1 -0
- package/dist/code-session/agent-code-session.kind.d.ts +15 -0
- package/dist/code-session/agent-code-session.kind.d.ts.map +1 -0
- package/dist/code-session/agent-code-session.kind.js +17 -0
- package/dist/code-session/agent-code-session.kind.js.map +1 -0
- package/dist/code-session/agent-code-session.plugin.d.ts +17 -0
- package/dist/code-session/agent-code-session.plugin.d.ts.map +1 -0
- package/dist/code-session/agent-code-session.plugin.js +28 -0
- package/dist/code-session/agent-code-session.plugin.js.map +1 -0
- package/dist/code-session/billing/gates.d.ts +52 -0
- package/dist/code-session/billing/gates.d.ts.map +1 -0
- package/dist/code-session/billing/gates.js +75 -0
- package/dist/code-session/billing/gates.js.map +1 -0
- package/dist/code-session/client/index.d.ts +2 -0
- package/dist/code-session/client/index.d.ts.map +1 -0
- package/dist/code-session/client/index.js +2 -0
- package/dist/code-session/client/index.js.map +1 -0
- package/dist/code-session/client/resolve.d.ts +20 -0
- package/dist/code-session/client/resolve.d.ts.map +1 -0
- package/dist/code-session/client/resolve.js +145 -0
- package/dist/code-session/client/resolve.js.map +1 -0
- package/dist/code-session/docs/agent-code-session.docs.descriptor.d.ts +4 -0
- package/dist/code-session/docs/agent-code-session.docs.descriptor.d.ts.map +1 -0
- package/dist/code-session/docs/agent-code-session.docs.descriptor.js +109 -0
- package/dist/code-session/docs/agent-code-session.docs.descriptor.js.map +1 -0
- package/dist/code-session/git-credential.d.ts +87 -0
- package/dist/code-session/git-credential.d.ts.map +1 -0
- package/dist/code-session/git-credential.js +107 -0
- package/dist/code-session/git-credential.js.map +1 -0
- package/dist/code-session/host-target.d.ts +76 -0
- package/dist/code-session/host-target.d.ts.map +1 -0
- package/dist/code-session/host-target.js +81 -0
- package/dist/code-session/host-target.js.map +1 -0
- package/dist/code-session/index.d.ts +20 -0
- package/dist/code-session/index.d.ts.map +1 -0
- package/dist/code-session/index.js +20 -0
- package/dist/code-session/index.js.map +1 -0
- package/dist/code-session/interfaces/code-session.d.ts +448 -0
- package/dist/code-session/interfaces/code-session.d.ts.map +1 -0
- package/dist/code-session/interfaces/code-session.js +373 -0
- package/dist/code-session/interfaces/code-session.js.map +1 -0
- package/dist/code-session/operation-lease.d.ts +48 -0
- package/dist/code-session/operation-lease.d.ts.map +1 -0
- package/dist/code-session/operation-lease.js +73 -0
- package/dist/code-session/operation-lease.js.map +1 -0
- package/dist/code-session/remote-setup-custody.d.ts +59 -0
- package/dist/code-session/remote-setup-custody.d.ts.map +1 -0
- package/dist/code-session/remote-setup-custody.js +78 -0
- package/dist/code-session/remote-setup-custody.js.map +1 -0
- package/dist/code-session/remote-workspace-fs.d.ts +119 -0
- package/dist/code-session/remote-workspace-fs.d.ts.map +1 -0
- package/dist/code-session/remote-workspace-fs.js +257 -0
- package/dist/code-session/remote-workspace-fs.js.map +1 -0
- package/dist/code-session/session-metadata.d.ts +40 -0
- package/dist/code-session/session-metadata.d.ts.map +1 -0
- package/dist/code-session/session-metadata.js +69 -0
- package/dist/code-session/session-metadata.js.map +1 -0
- package/dist/code-session-relay/agent-code-session-relay.descriptor.d.ts +78 -0
- package/dist/code-session-relay/agent-code-session-relay.descriptor.d.ts.map +1 -0
- package/dist/code-session-relay/agent-code-session-relay.descriptor.js +38 -0
- package/dist/code-session-relay/agent-code-session-relay.descriptor.js.map +1 -0
- package/dist/code-session-relay/agent-code-session-relay.impl.d.ts +120 -0
- package/dist/code-session-relay/agent-code-session-relay.impl.d.ts.map +1 -0
- package/dist/code-session-relay/agent-code-session-relay.impl.js +1392 -0
- package/dist/code-session-relay/agent-code-session-relay.impl.js.map +1 -0
- package/dist/code-session-relay/agent-code-session-relay.plugin.d.ts +14 -0
- package/dist/code-session-relay/agent-code-session-relay.plugin.d.ts.map +1 -0
- package/dist/code-session-relay/agent-code-session-relay.plugin.js +25 -0
- package/dist/code-session-relay/agent-code-session-relay.plugin.js.map +1 -0
- package/dist/code-session-relay/index.d.ts +13 -0
- package/dist/code-session-relay/index.d.ts.map +1 -0
- package/dist/code-session-relay/index.js +13 -0
- package/dist/code-session-relay/index.js.map +1 -0
- package/dist/code-session-relay/relay-dial.d.ts +72 -0
- package/dist/code-session-relay/relay-dial.d.ts.map +1 -0
- package/dist/code-session-relay/relay-dial.js +177 -0
- package/dist/code-session-relay/relay-dial.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/sandbox/agent-sandbox.consumer.descriptor.d.ts +35 -0
- package/dist/sandbox/agent-sandbox.consumer.descriptor.d.ts.map +1 -0
- package/dist/sandbox/agent-sandbox.consumer.descriptor.js +19 -0
- package/dist/sandbox/agent-sandbox.consumer.descriptor.js.map +1 -0
- package/dist/sandbox/agent-sandbox.consumer.plugin.d.ts +9 -0
- package/dist/sandbox/agent-sandbox.consumer.plugin.d.ts.map +1 -0
- package/dist/sandbox/agent-sandbox.consumer.plugin.js +33 -0
- package/dist/sandbox/agent-sandbox.consumer.plugin.js.map +1 -0
- package/dist/sandbox/agent-sandbox.descriptor.d.ts +76 -0
- package/dist/sandbox/agent-sandbox.descriptor.d.ts.map +1 -0
- package/dist/sandbox/agent-sandbox.descriptor.js +57 -0
- package/dist/sandbox/agent-sandbox.descriptor.js.map +1 -0
- package/dist/sandbox/agent-sandbox.impl.consumer.d.ts +15 -0
- package/dist/sandbox/agent-sandbox.impl.consumer.d.ts.map +1 -0
- package/dist/sandbox/agent-sandbox.impl.consumer.js +23 -0
- package/dist/sandbox/agent-sandbox.impl.consumer.js.map +1 -0
- package/dist/sandbox/agent-sandbox.kind.d.ts +14 -0
- package/dist/sandbox/agent-sandbox.kind.d.ts.map +1 -0
- package/dist/sandbox/agent-sandbox.kind.js +16 -0
- package/dist/sandbox/agent-sandbox.kind.js.map +1 -0
- package/dist/sandbox/agent-sandbox.plugin.d.ts +15 -0
- package/dist/sandbox/agent-sandbox.plugin.d.ts.map +1 -0
- package/dist/sandbox/agent-sandbox.plugin.js +25 -0
- package/dist/sandbox/agent-sandbox.plugin.js.map +1 -0
- package/dist/sandbox/client/resolve.d.ts +21 -0
- package/dist/sandbox/client/resolve.d.ts.map +1 -0
- package/dist/sandbox/client/resolve.js +103 -0
- package/dist/sandbox/client/resolve.js.map +1 -0
- package/dist/sandbox/docs/agent-sandbox.docs.descriptor.d.ts +4 -0
- package/dist/sandbox/docs/agent-sandbox.docs.descriptor.d.ts.map +1 -0
- package/dist/sandbox/docs/agent-sandbox.docs.descriptor.js +75 -0
- package/dist/sandbox/docs/agent-sandbox.docs.descriptor.js.map +1 -0
- package/dist/sandbox/index.d.ts +15 -0
- package/dist/sandbox/index.d.ts.map +1 -0
- package/dist/sandbox/index.js +14 -0
- package/dist/sandbox/index.js.map +1 -0
- package/dist/sandbox/interfaces/sandbox.d.ts +61 -0
- package/dist/sandbox/interfaces/sandbox.d.ts.map +1 -0
- package/dist/sandbox/interfaces/sandbox.js +54 -0
- package/dist/sandbox/interfaces/sandbox.js.map +1 -0
- package/dist/schemas/code-session-resolution.d.ts +20 -0
- package/dist/schemas/code-session-resolution.d.ts.map +1 -0
- package/dist/schemas/code-session-resolution.js +11 -0
- package/dist/schemas/code-session-resolution.js.map +1 -0
- package/dist/schemas/session-operation.d.ts +73 -0
- package/dist/schemas/session-operation.d.ts.map +1 -0
- package/dist/schemas/session-operation.js +47 -0
- package/dist/schemas/session-operation.js.map +1 -0
- package/dist/schemas/voice.d.ts +87 -0
- package/dist/schemas/voice.d.ts.map +1 -0
- package/dist/schemas/voice.js +70 -0
- package/dist/schemas/voice.js.map +1 -0
- package/dist/voice/agent-voice.consumer.descriptor.d.ts +43 -0
- package/dist/voice/agent-voice.consumer.descriptor.d.ts.map +1 -0
- package/dist/voice/agent-voice.consumer.descriptor.js +20 -0
- package/dist/voice/agent-voice.consumer.descriptor.js.map +1 -0
- package/dist/voice/agent-voice.consumer.plugin.d.ts +4 -0
- package/dist/voice/agent-voice.consumer.plugin.d.ts.map +1 -0
- package/dist/voice/agent-voice.consumer.plugin.js +13 -0
- package/dist/voice/agent-voice.consumer.plugin.js.map +1 -0
- package/dist/voice/agent-voice.descriptor.d.ts +82 -0
- package/dist/voice/agent-voice.descriptor.d.ts.map +1 -0
- package/dist/voice/agent-voice.descriptor.js +79 -0
- package/dist/voice/agent-voice.descriptor.js.map +1 -0
- package/dist/voice/agent-voice.impl.consumer.d.ts +35 -0
- package/dist/voice/agent-voice.impl.consumer.d.ts.map +1 -0
- package/dist/voice/agent-voice.impl.consumer.js +50 -0
- package/dist/voice/agent-voice.impl.consumer.js.map +1 -0
- package/dist/voice/agent-voice.kind.d.ts +67 -0
- package/dist/voice/agent-voice.kind.d.ts.map +1 -0
- package/dist/voice/agent-voice.kind.js +26 -0
- package/dist/voice/agent-voice.kind.js.map +1 -0
- package/dist/voice/agent-voice.plugin.d.ts +4 -0
- package/dist/voice/agent-voice.plugin.d.ts.map +1 -0
- package/dist/voice/agent-voice.plugin.js +18 -0
- package/dist/voice/agent-voice.plugin.js.map +1 -0
- package/dist/voice/client.d.ts +38 -0
- package/dist/voice/client.d.ts.map +1 -0
- package/dist/voice/client.js +29 -0
- package/dist/voice/client.js.map +1 -0
- package/dist/voice/index.d.ts +14 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +14 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/pipeline.d.ts +66 -0
- package/dist/voice/pipeline.d.ts.map +1 -0
- package/dist/voice/pipeline.js +330 -0
- package/dist/voice/pipeline.js.map +1 -0
- package/dist/voice/session.d.ts +29 -0
- package/dist/voice/session.d.ts.map +1 -0
- package/dist/voice/session.js +78 -0
- package/dist/voice/session.js.map +1 -0
- package/package.json +279 -4
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer Agent Code Session — runtime-routed impl (WS3)
|
|
3
|
+
*
|
|
4
|
+
* A *persistent* code session: unlike the throwaway `agent/sandbox`, the
|
|
5
|
+
* workspace under the active Vibes profile SURVIVES across calls until
|
|
6
|
+
* an explicit `teardown`. Lifecycle:
|
|
7
|
+
*
|
|
8
|
+
* initialize → clone repo (git CLI) into the persistent workspace, register
|
|
9
|
+
* session metadata so `attach` can resume.
|
|
10
|
+
* attach → resume a known session (rehydrate from the metadata store).
|
|
11
|
+
* runCommand→ foreground (capture stdout/stderr/exit) OR background (a
|
|
12
|
+
* long-lived process handle kept in an in-process registry).
|
|
13
|
+
* read/write/patch → file ops scoped under the workspace root.
|
|
14
|
+
* gitStatus/Commit/Push → git CLI in the workspace.
|
|
15
|
+
* exposePort → record a port + return a localhost preview URL.
|
|
16
|
+
* teardown → kill background procs, remove the persistent workspace.
|
|
17
|
+
*
|
|
18
|
+
* All file ops route through the `filesystem/adapter` kind and all command
|
|
19
|
+
* execution through the `runtime/execution` kind (consumer impls back both):
|
|
20
|
+
* one-shot git/foreground commands use `execute()`; a long-lived background
|
|
21
|
+
* process uses `spawn()` and keeps its `ProcessHandle` live in an in-process
|
|
22
|
+
* registry. Raw `node:fs` / `Bun.spawn` are NOT used here — the impl stays
|
|
23
|
+
* swappable.
|
|
24
|
+
*
|
|
25
|
+
* Env is composed through the handed runtime's `composeChildEnv`: only host essentials +
|
|
26
|
+
* the caller's explicit env are forwarded; nothing from the host's own environment leaks
|
|
27
|
+
* into session processes.
|
|
28
|
+
*
|
|
29
|
+
* MVP host routing: a `{ kind:'host', hostId }` target runs LOCALLY (on this
|
|
30
|
+
* consumer worker/daemon). Dispatching to a *specific remote* Host daemon by
|
|
31
|
+
* `hostId` is a documented follow-up — the `hostId` is recorded on the session
|
|
32
|
+
* handle so a future remote-dispatch layer can target it.
|
|
33
|
+
*/
|
|
34
|
+
import { getLogger } from '@vibesdotdev/logging';
|
|
35
|
+
import { sha256Hex } from '@vibesdotdev/shared/hashing';
|
|
36
|
+
import { CapabilityError, CaughtFailureSchema, composeChildEnv, createRuntimeImplementation, isCapabilityError, readEnv, settle, valueFreeFailure } from '@vibesdotdev/kernel';
|
|
37
|
+
import { acquireWireProcess } from '@vibesdotdev/kernel-rpc/execution';
|
|
38
|
+
import { getVibesRemoteFrom } from '@vibesdotdev/kernel-rpc';
|
|
39
|
+
import { createFileExclusiveChecked, resolveFileSystemAdapter } from '@vibesdotdev/filesystem';
|
|
40
|
+
import { getPathRoots, getRemotePathRoots } from '@vibesdotdev/paths';
|
|
41
|
+
import * as z from 'zod/v4';
|
|
42
|
+
import { CODE_SESSION_MIN_TRUST_LEVEL, AgentCodeSessionDescriptorSchema } from "./agent-code-session.descriptor.js";
|
|
43
|
+
import consumerCodeSessionDescriptor from "./agent-code-session.consumer.descriptor.js";
|
|
44
|
+
import { codeSessionCredentialArgs } from "./git-credential.js";
|
|
45
|
+
import { DEFAULT_SESSION_HOST_TARGET } from "./host-target.js";
|
|
46
|
+
import { CodeSessionOperationIdSchema, PersistedCodeSessionOperationSchema, codeSessionOperationFingerprint, codeSessionOperationKey, codeSessionTempName, parseCodeSessionCreateOrGetInput, parseCodeSessionId, parseCodeSessionInitializeInput, parseCodeSessionPath, parseExposePortInput, parseGitApplyInput, parseGitCommitInput, parseGitCommitOptions, parseGitPushInput, parsePatchFileInput, parseBackgroundProcessReadInput, parseBackgroundProcessWaitInput, parseProcessId, parseProcessSignal, parsePushCredentialResolver, parseRunCommandInput, parseWriteFileInput } from "./interfaces/code-session.js";
|
|
47
|
+
import { pollUntil, withLeaseHeartbeat } from "./operation-lease.js";
|
|
48
|
+
import { remoteSetupCustody } from "./remote-setup-custody.js";
|
|
49
|
+
import { resolveRemoteWorkspaceFileSystem, parseRemoteFileSystemIO } from "./remote-workspace-fs.js";
|
|
50
|
+
import { CODE_SESSION_METADATA_FILENAME, parseCodeSessionMetadata, serializeCodeSessionMetadata } from "./session-metadata.js";
|
|
51
|
+
/** The consumer's logger on the runtime that owns the session (C083a): never an ambient default-bound one. */
|
|
52
|
+
const consumerLogger = (runtime) => getLogger('agent.code-session.consumer', runtime);
|
|
53
|
+
/** Directory below the active Vibes profile that owns durable local sessions. */
|
|
54
|
+
const SESSION_DIRECTORY = 'code-sessions';
|
|
55
|
+
/** Pre-profile local root. Read only for explicit, stable-id reattachment. */
|
|
56
|
+
const LEGACY_LOCAL_SESSION_ROOT = '/tmp/vibes-code-session';
|
|
57
|
+
const CONSUMER_FILESYSTEM_ADAPTER_ID = 'filesystem-adapter-bun';
|
|
58
|
+
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
59
|
+
const CodeSessionOperationLeaseSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
holderId: z.string().uuid(),
|
|
62
|
+
expiresAt: z.number().finite().nonnegative()
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
const FileContentsSchema = z.union([z.string(), z.instanceof(Uint8Array)]);
|
|
66
|
+
function utf8Text(contents) {
|
|
67
|
+
const admitted = FileContentsSchema.parse(contents);
|
|
68
|
+
const text = z.string().safeParse(admitted);
|
|
69
|
+
return text.success ? text.data : new TextDecoder().decode(z.instanceof(Uint8Array).parse(admitted));
|
|
70
|
+
}
|
|
71
|
+
function requestInvalid(field) {
|
|
72
|
+
return new CapabilityError('The code-session request is invalid.', 'INVALID_INPUT', {
|
|
73
|
+
reason: 'code-session-request-invalid',
|
|
74
|
+
retryable: false,
|
|
75
|
+
details: { field }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function metadataInvalid(cause) {
|
|
79
|
+
return new CapabilityError('Code-session control metadata is malformed.', 'INVALID_INPUT', {
|
|
80
|
+
reason: 'code-session-metadata-invalid',
|
|
81
|
+
retryable: false,
|
|
82
|
+
cause
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Trust ladder ranks for the writable-FS escalation check. */
|
|
86
|
+
const TRUST_RANK = { '0': 0, '1': 1, '2a': 2, '2b': 3, '3': 4 };
|
|
87
|
+
function trustAllowsCodeSession(trustLevel) {
|
|
88
|
+
return TRUST_RANK[trustLevel] >= TRUST_RANK[CODE_SESSION_MIN_TRUST_LEVEL];
|
|
89
|
+
}
|
|
90
|
+
function isSafeSessionId(sessionId) {
|
|
91
|
+
return SAFE_SESSION_ID.test(sessionId);
|
|
92
|
+
}
|
|
93
|
+
function requireSafeSessionId(sessionId) {
|
|
94
|
+
if (!isSafeSessionId(sessionId)) {
|
|
95
|
+
throw requestInvalid('sessionId');
|
|
96
|
+
}
|
|
97
|
+
return sessionId;
|
|
98
|
+
}
|
|
99
|
+
function ownedWorkspacePath(fs, sessionRoot, sessionId) {
|
|
100
|
+
const safeId = requireSafeSessionId(sessionId);
|
|
101
|
+
const root = fs.normalize(fs.resolve(sessionRoot));
|
|
102
|
+
const candidate = fs.normalize(fs.resolve(root, safeId));
|
|
103
|
+
const rootWithSep = root.endsWith('/') ? root : `${root}/`;
|
|
104
|
+
if (candidate === root || !candidate.startsWith(rootWithSep)) {
|
|
105
|
+
throw requestInvalid('sessionId');
|
|
106
|
+
}
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
function assertOwnedWorkspacePath(fs, sessionRoot, sessionId, workspacePath) {
|
|
110
|
+
const expected = ownedWorkspacePath(fs, sessionRoot, sessionId);
|
|
111
|
+
if (fs.normalize(fs.resolve(workspacePath)) !== expected) {
|
|
112
|
+
throw new CapabilityError('Workspace cleanup is outside its owned session root.', 'CONFLICT', {
|
|
113
|
+
reason: 'code-session-cleanup-conflict',
|
|
114
|
+
retryable: false,
|
|
115
|
+
details: { sessionId }
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const RemoteRuntimeExecutionSchema = z.custom((value) => z.object({ spawn: z.function() }).safeParse(value).success);
|
|
120
|
+
function parseRemoteRuntimeExecution(value, hostId) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = RemoteRuntimeExecutionSchema.safeParse(value);
|
|
123
|
+
if (!parsed.success) {
|
|
124
|
+
throw new CapabilityError('The remote host exposes no execution capability.', 'UNAVAILABLE', {
|
|
125
|
+
reason: 'code-session-host-unavailable', retryable: false, details: { hostId }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return parsed.data;
|
|
129
|
+
}
|
|
130
|
+
catch (caught) {
|
|
131
|
+
const failure = CaughtFailureSchema.parse(caught);
|
|
132
|
+
if (isCapabilityError(failure))
|
|
133
|
+
throw failure;
|
|
134
|
+
throw new CapabilityError('The remote execution contract could not be read.', 'UNAVAILABLE', {
|
|
135
|
+
reason: 'code-session-host-unavailable',
|
|
136
|
+
retryable: false,
|
|
137
|
+
details: { hostId },
|
|
138
|
+
cause: valueFreeFailure(failure)
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Output chunks a background process keeps for `readBackgroundProcess` / `waitBackgroundProcess`. */
|
|
143
|
+
const MAX_BACKGROUND_CHUNKS = 512;
|
|
144
|
+
/** How long a wait that saw the exit gives the pipes to deliver their last output. */
|
|
145
|
+
const BACKGROUND_DRAIN_JOIN_MS = 1_000;
|
|
146
|
+
/** The output a background entry produced after `since`, split by stream. */
|
|
147
|
+
function backgroundOutputSince(entry, since, timedOut) {
|
|
148
|
+
const dropped = entry.produced - entry.output.length;
|
|
149
|
+
const from = Math.max(since ?? 0, dropped) - dropped;
|
|
150
|
+
const stdout = new TextDecoder();
|
|
151
|
+
const stderr = new TextDecoder();
|
|
152
|
+
let out = '';
|
|
153
|
+
let err = '';
|
|
154
|
+
for (const chunk of entry.output.slice(Math.min(from, entry.output.length))) {
|
|
155
|
+
if (chunk.stream === 'stdout')
|
|
156
|
+
out += stdout.decode(chunk.bytes, { stream: true });
|
|
157
|
+
else
|
|
158
|
+
err += stderr.decode(chunk.bytes, { stream: true });
|
|
159
|
+
}
|
|
160
|
+
out += stdout.decode();
|
|
161
|
+
err += stderr.decode();
|
|
162
|
+
return {
|
|
163
|
+
procId: entry.procId,
|
|
164
|
+
stdout: out,
|
|
165
|
+
stderr: err,
|
|
166
|
+
cursor: entry.produced,
|
|
167
|
+
running: entry.running,
|
|
168
|
+
exitCode: entry.running === false ? (entry.exitCode ?? null) : null,
|
|
169
|
+
timedOut
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** `lifecycle.wait` rejects a lapsed deadline as `CapabilityError` `TIMEOUT` `wait-timeout`. */
|
|
173
|
+
function isBackgroundWaitTimeout(failure) {
|
|
174
|
+
return isCapabilityError(failure) && failure.code === 'TIMEOUT' && failure.reason === 'wait-timeout';
|
|
175
|
+
}
|
|
176
|
+
/** Reject paths that escape the workspace root (path-traversal guard). */
|
|
177
|
+
function resolveInWorkspace(fs, workspacePath, relPath) {
|
|
178
|
+
const abs = fs.resolve(workspacePath, relPath);
|
|
179
|
+
const normalizedRoot = fs.normalize(workspacePath);
|
|
180
|
+
const rootWithSep = normalizedRoot.endsWith('/') ? normalizedRoot : `${normalizedRoot}/`;
|
|
181
|
+
if (abs !== normalizedRoot && !abs.startsWith(rootWithSep)) {
|
|
182
|
+
throw requestInvalid('path');
|
|
183
|
+
}
|
|
184
|
+
return abs;
|
|
185
|
+
}
|
|
186
|
+
/** Drain a (possibly null) ReadableStream into a UTF-8 string. */
|
|
187
|
+
async function readStream(stream) {
|
|
188
|
+
if (!stream)
|
|
189
|
+
return '';
|
|
190
|
+
return new Response(stream).text();
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Invoke `onAllocated` without letting it take down the initialize. A caller's
|
|
194
|
+
* bookkeeping failure must not strand an allocated workspace.
|
|
195
|
+
*/
|
|
196
|
+
async function notifyAllocated(input, sessionId, logger) {
|
|
197
|
+
if (!input.onAllocated)
|
|
198
|
+
return;
|
|
199
|
+
try {
|
|
200
|
+
await input.onAllocated(sessionId);
|
|
201
|
+
}
|
|
202
|
+
catch (caught) {
|
|
203
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
204
|
+
logger.warn('onAllocated threw; continuing initialization', {
|
|
205
|
+
sessionId,
|
|
206
|
+
failure
|
|
207
|
+
});
|
|
208
|
+
return failure;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export class ConsumerCodeSession {
|
|
212
|
+
descriptor;
|
|
213
|
+
/** In-process session registry, keyed by sessionId — backs `attach`. */
|
|
214
|
+
sessions = new Map();
|
|
215
|
+
/** Live background procs per session. */
|
|
216
|
+
background = new Map();
|
|
217
|
+
/** Host-supplied resolver for a push credential keyed by repositoryId. */
|
|
218
|
+
pushCredentialResolver;
|
|
219
|
+
/** In-flight attach-on-demand attempts, keyed by sessionId — collapses concurrent callers onto one attach. */
|
|
220
|
+
attachInFlight = new Map();
|
|
221
|
+
/** Cached remote execution handles per non-local hostId (fleet wire). */
|
|
222
|
+
remotes = new Map();
|
|
223
|
+
remoteActiveHomes = new Map();
|
|
224
|
+
/** Cached remote workspace filesystem adapters per non-local hostId (fleet wire). */
|
|
225
|
+
remoteFilesystems = new Map();
|
|
226
|
+
/**
|
|
227
|
+
* Same-process create retries collapse before they reach the durable operation
|
|
228
|
+
* ledger. Claims are registered synchronously, keyed by the trimmed operation
|
|
229
|
+
* id, so call order is claim order regardless of when hashing finishes.
|
|
230
|
+
*/
|
|
231
|
+
operationInFlight = new Map();
|
|
232
|
+
/** Serializes git add/commit per git repository root so concurrent sessions never collide on .git/index.lock. */
|
|
233
|
+
gitCommitQueues = new Map();
|
|
234
|
+
runtime;
|
|
235
|
+
activeHome;
|
|
236
|
+
constructor(descriptor, runtime) {
|
|
237
|
+
this.descriptor = descriptor;
|
|
238
|
+
this.runtime = runtime;
|
|
239
|
+
this.activeHome = getPathRoots(this.runtime).activeHome;
|
|
240
|
+
}
|
|
241
|
+
/** This session's logger, on the runtime that owns it. */
|
|
242
|
+
get log() {
|
|
243
|
+
return consumerLogger(this.runtime);
|
|
244
|
+
}
|
|
245
|
+
now() {
|
|
246
|
+
return this.runtime.lifecycle.now();
|
|
247
|
+
}
|
|
248
|
+
nowIso() {
|
|
249
|
+
return new Date().toISOString();
|
|
250
|
+
}
|
|
251
|
+
/** Local control records stay under this runtime's active profile. */
|
|
252
|
+
sessionRoot(fs) {
|
|
253
|
+
return fs.normalize(fs.resolve(fs.join(this.activeHome, SESSION_DIRECTORY)));
|
|
254
|
+
}
|
|
255
|
+
async targetSessionRoot(fs, hostTarget) {
|
|
256
|
+
if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local') {
|
|
257
|
+
const remote = await this.remoteRuntime(hostTarget.hostId);
|
|
258
|
+
const roots = await getRemotePathRoots(remote);
|
|
259
|
+
const previous = this.remoteActiveHomes.get(hostTarget.hostId);
|
|
260
|
+
if (previous && previous !== roots.activeHome) {
|
|
261
|
+
throw new CapabilityError('Remote profile changed; explicit session recovery is required.', 'CONFLICT', {
|
|
262
|
+
reason: 'code-session-profile-conflict',
|
|
263
|
+
retryable: false,
|
|
264
|
+
details: { hostId: hostTarget.hostId }
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
this.remoteActiveHomes.set(hostTarget.hostId, roots.activeHome);
|
|
268
|
+
return fs.normalize(fs.resolve(fs.join(roots.activeHome, SESSION_DIRECTORY)));
|
|
269
|
+
}
|
|
270
|
+
return this.sessionRoot(fs);
|
|
271
|
+
}
|
|
272
|
+
/** The temp root of the host a session runs on, from that host's path roots. */
|
|
273
|
+
async targetTempRoot(fs, hostTarget) {
|
|
274
|
+
const roots = hostTarget.kind === 'host' && hostTarget.hostId !== 'local'
|
|
275
|
+
? await getRemotePathRoots(await this.remoteRuntime(hostTarget.hostId))
|
|
276
|
+
: getPathRoots(this.runtime);
|
|
277
|
+
return fs.normalize(fs.resolve(roots.tempDir));
|
|
278
|
+
}
|
|
279
|
+
setPushCredentialResolver(resolver) {
|
|
280
|
+
this.pushCredentialResolver = parsePushCredentialResolver(resolver);
|
|
281
|
+
}
|
|
282
|
+
/** Workspace-local process state belongs to the control root, not source files.
|
|
283
|
+
* Keep a private home across reattachments without inheriting host credentials.
|
|
284
|
+
* TMPDIR lives under the host's temp root with a short name, so sockets bound
|
|
285
|
+
* under it fit the platform's socket path limit.
|
|
286
|
+
*/
|
|
287
|
+
async processEnvironment(workspacePath, hostTarget, extra) {
|
|
288
|
+
const fs = await this.workspaceFilesystem(hostTarget);
|
|
289
|
+
const key = sha256Hex(workspacePath);
|
|
290
|
+
const root = fs.join(await this.targetSessionRoot(fs, hostTarget), '.environments', key);
|
|
291
|
+
const home = fs.join(root, 'home');
|
|
292
|
+
const temp = fs.join(await this.targetTempRoot(fs, hostTarget), codeSessionTempName(key));
|
|
293
|
+
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(temp, { recursive: true })]);
|
|
294
|
+
const declared = {
|
|
295
|
+
HOME: home,
|
|
296
|
+
TMPDIR: temp,
|
|
297
|
+
VIBES_WORKSPACE_PATH: workspacePath,
|
|
298
|
+
GIT_CONFIG_COUNT: '1',
|
|
299
|
+
GIT_CONFIG_KEY_0: 'commit.gpgsign',
|
|
300
|
+
GIT_CONFIG_VALUE_0: 'false',
|
|
301
|
+
...extra
|
|
302
|
+
};
|
|
303
|
+
if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local')
|
|
304
|
+
return declared;
|
|
305
|
+
await this.seedGitAuthorIdentity(fs, home);
|
|
306
|
+
const env = composeChildEnv(this.runtime, { env: { PWD: workspacePath, ...declared } });
|
|
307
|
+
if (!readEnv({ platform: this.runtime.localEnvironment.getPlatform?.(), snapshot: env }, 'PATH')) {
|
|
308
|
+
throw new CapabilityError('The runtime that owns this child captured no PATH.', 'UNAVAILABLE', {
|
|
309
|
+
reason: 'host-path-unavailable', retryable: false
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return env;
|
|
313
|
+
}
|
|
314
|
+
async seedGitAuthorIdentity(fs, home) {
|
|
315
|
+
const targetGitConfig = fs.join(home, '.gitconfig');
|
|
316
|
+
if (await fs.exists(targetGitConfig))
|
|
317
|
+
return;
|
|
318
|
+
const hostHome = this.runtime.getEnv('HOME');
|
|
319
|
+
if (!hostHome)
|
|
320
|
+
return;
|
|
321
|
+
const sourceGitConfig = fs.join(hostHome, '.gitconfig');
|
|
322
|
+
if (!(await fs.exists(sourceGitConfig)))
|
|
323
|
+
return;
|
|
324
|
+
const content = utf8Text(await fs.readFile(sourceGitConfig, 'utf8'));
|
|
325
|
+
let inUserSection = false;
|
|
326
|
+
let name;
|
|
327
|
+
let email;
|
|
328
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
329
|
+
const line = rawLine.trim();
|
|
330
|
+
if (!line || line.startsWith('#') || line.startsWith(';'))
|
|
331
|
+
continue;
|
|
332
|
+
const sectionMatch = /^\[([^\]]+)\]$/.exec(line);
|
|
333
|
+
if (sectionMatch) {
|
|
334
|
+
inUserSection = sectionMatch[1].trim().toLowerCase() === 'user';
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!inUserSection)
|
|
338
|
+
continue;
|
|
339
|
+
const kvMatch = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line);
|
|
340
|
+
if (!kvMatch)
|
|
341
|
+
continue;
|
|
342
|
+
const key = kvMatch[1].toLowerCase();
|
|
343
|
+
const value = kvMatch[2].trim().replace(/^"(.*)"$/, '$1');
|
|
344
|
+
if (key === 'name' && value)
|
|
345
|
+
name = value;
|
|
346
|
+
else if (key === 'email' && value)
|
|
347
|
+
email = value;
|
|
348
|
+
}
|
|
349
|
+
if (!name && !email)
|
|
350
|
+
return;
|
|
351
|
+
const lines = ['[user]'];
|
|
352
|
+
if (name)
|
|
353
|
+
lines.push(`\tname = ${name}`);
|
|
354
|
+
if (email)
|
|
355
|
+
lines.push(`\temail = ${email}`);
|
|
356
|
+
await fs.writeFile(targetGitConfig, `${lines.join('\n')}\n`, 'utf8');
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Resolve the `runtime/execution` impl for a session's host target.
|
|
360
|
+
*
|
|
361
|
+
* THE JOIN (D-THE-JOIN): a `{ kind:'host', hostId }` target with a NON-local
|
|
362
|
+
* hostId reaches THAT host's execution impl over the fleet wire via
|
|
363
|
+
* `getVibesRemoteFrom(runtime, hostId)` (the proven path — fleet-daemon
|
|
364
|
+
* `reach-over-daemon.test.ts`). Local + default targets keep resolving the
|
|
365
|
+
* in-process impl exactly as before. There is NO silent local fallback: an
|
|
366
|
+
* unreachable host throws a precise, actionable error.
|
|
367
|
+
*/
|
|
368
|
+
async execution() {
|
|
369
|
+
const impl = await this.runtime
|
|
370
|
+
.query('runtime/execution')
|
|
371
|
+
.resolve();
|
|
372
|
+
if (!impl) {
|
|
373
|
+
throw new CapabilityError('No execution capability is registered for this runtime scope.', 'UNAVAILABLE', {
|
|
374
|
+
reason: 'code-session-host-unavailable',
|
|
375
|
+
retryable: false
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return impl;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Reach a remote host's `runtime/execution` over the fleet `runtime/rpc`
|
|
382
|
+
* wire. A `RemoteRuntime` pins its socket, so one connection per hostId is
|
|
383
|
+
* cached and reused across a session's commands; a failed resolve is dropped
|
|
384
|
+
* from the cache so a later call can retry once the host is paired.
|
|
385
|
+
*/
|
|
386
|
+
remoteRuntime(hostId) {
|
|
387
|
+
let pending = this.remotes.get(hostId);
|
|
388
|
+
if (!pending) {
|
|
389
|
+
pending = getVibesRemoteFrom(this.runtime, hostId);
|
|
390
|
+
this.remotes.set(hostId, pending);
|
|
391
|
+
const owned = pending;
|
|
392
|
+
const evict = () => {
|
|
393
|
+
if (this.remotes.get(hostId) === owned) {
|
|
394
|
+
this.remotes.delete(hostId);
|
|
395
|
+
this.remoteFilesystems.delete(hostId);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
void pending.then((remote) => remote.connection.closed.then(evict, (caught) => {
|
|
399
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
400
|
+
this.log.warn('remote code-session connection closed with a failure', { hostId, failure });
|
|
401
|
+
evict();
|
|
402
|
+
return failure;
|
|
403
|
+
}), (caught) => {
|
|
404
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
405
|
+
evict();
|
|
406
|
+
return failure;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return pending;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Spawn on the session's host. With `setup`, cancellation is checked after
|
|
413
|
+
* every pre-dispatch await: a cancelled setup returns `null` and dispatches
|
|
414
|
+
* nothing. A remote dispatch is owned by an acquisition retained in this
|
|
415
|
+
* runtime's custody (plugin `onDispose` drains it), so a late port is killed
|
|
416
|
+
* once and observed rather than abandoned.
|
|
417
|
+
*/
|
|
418
|
+
async spawnOnHost(workspacePath, hostTarget, options, extra, setup) {
|
|
419
|
+
const remoteTarget = hostTarget.kind === 'host' && hostTarget.hostId !== 'local';
|
|
420
|
+
const custody = remoteSetupCustody(this.runtime);
|
|
421
|
+
if (remoteTarget)
|
|
422
|
+
custody.admit();
|
|
423
|
+
const env = await this.processEnvironment(workspacePath, hostTarget, extra);
|
|
424
|
+
if (setup?.cancelled())
|
|
425
|
+
return null;
|
|
426
|
+
if (remoteTarget) {
|
|
427
|
+
const remote = await this.remoteRuntime(hostTarget.hostId);
|
|
428
|
+
if (setup?.cancelled())
|
|
429
|
+
return null;
|
|
430
|
+
const execution = parseRemoteRuntimeExecution(await remote.query('runtime/execution'), hostTarget.hostId);
|
|
431
|
+
if (setup?.cancelled())
|
|
432
|
+
return null;
|
|
433
|
+
const request = { ...options, declaration: { env } };
|
|
434
|
+
custody.admit();
|
|
435
|
+
const acquisition = custody.retain(acquireWireProcess(this.runtime, () => execution.spawn(request)));
|
|
436
|
+
setup?.dispatched(acquisition);
|
|
437
|
+
return acquisition.ready;
|
|
438
|
+
}
|
|
439
|
+
const execution = await this.execution();
|
|
440
|
+
if (setup?.cancelled())
|
|
441
|
+
return null;
|
|
442
|
+
setup?.dispatched();
|
|
443
|
+
return execution.spawn({ ...options, env, envMode: 'replace' });
|
|
444
|
+
}
|
|
445
|
+
async spawnBackground(workspacePath, hostTarget, options, extra) {
|
|
446
|
+
const handle = await this.spawnOnHost(workspacePath, hostTarget, options, extra);
|
|
447
|
+
// Without a setup control nothing can cancel before dispatch.
|
|
448
|
+
if (!handle)
|
|
449
|
+
throw new CapabilityError('Background process setup was cancelled', 'UNAVAILABLE', { reason: 'code-session-setup-cancelled', retryable: false });
|
|
450
|
+
return handle;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Reach a remote host's workspace `filesystem/adapter` over the fleet
|
|
454
|
+
* `runtime/rpc` wire (UNIT-B3 / D-DAEMON-CLIENT), mirroring
|
|
455
|
+
* {@link remoteExecution}. One connection per hostId is cached and reused; a
|
|
456
|
+
* failed resolve is dropped so a later call can retry once the host is paired.
|
|
457
|
+
*/
|
|
458
|
+
remoteFilesystem(hostId) {
|
|
459
|
+
let pending = this.remoteFilesystems.get(hostId);
|
|
460
|
+
if (!pending) {
|
|
461
|
+
pending = this.resolveRemoteFilesystem(hostId).then((adapter) => adapter, (caught) => {
|
|
462
|
+
this.remoteFilesystems.delete(hostId);
|
|
463
|
+
throw CapabilityError.from(CaughtFailureSchema.parse(caught));
|
|
464
|
+
});
|
|
465
|
+
this.remoteFilesystems.set(hostId, pending);
|
|
466
|
+
}
|
|
467
|
+
return pending;
|
|
468
|
+
}
|
|
469
|
+
async resolveRemoteFilesystem(hostId) {
|
|
470
|
+
let remote;
|
|
471
|
+
try {
|
|
472
|
+
remote = await this.remoteRuntime(hostId);
|
|
473
|
+
}
|
|
474
|
+
catch (caught) {
|
|
475
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
476
|
+
this.log.warn('remote session host is unavailable', { hostId, failure });
|
|
477
|
+
throw new CapabilityError('The remote session host is unavailable.', 'UNAVAILABLE', {
|
|
478
|
+
reason: 'code-session-host-unavailable', retryable: true, details: { hostId }, cause: failure
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
const io = parseRemoteFileSystemIO(await remote.query('filesystem/adapter'), hostId);
|
|
482
|
+
return resolveRemoteWorkspaceFileSystem(io, hostId);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Resolve the LOCAL read/write filesystem adapter (the consumer's own disk);
|
|
486
|
+
* throw clearly if absent. This backs the session CONTROL ROOT (persisted
|
|
487
|
+
* metadata, borrowed bookkeeping, discovery) which always lives on THIS host,
|
|
488
|
+
* never a remote workspace. Workspace file ops use {@link workspaceFilesystem}.
|
|
489
|
+
*/
|
|
490
|
+
async filesystem() {
|
|
491
|
+
// Code-session workspaces and their control metadata are host-managed and
|
|
492
|
+
// may intentionally live outside the currently bound user workspace.
|
|
493
|
+
const adapter = await resolveFileSystemAdapter(this.runtime, {
|
|
494
|
+
explicitId: CONSUMER_FILESYSTEM_ADAPTER_ID
|
|
495
|
+
});
|
|
496
|
+
if (!adapter) {
|
|
497
|
+
throw new CapabilityError('No workspace filesystem capability is registered for this runtime scope.', 'UNAVAILABLE', {
|
|
498
|
+
reason: 'code-session-host-unavailable',
|
|
499
|
+
retryable: false
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return adapter;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Resolve the filesystem that backs a session's WORKSPACE. A `{ kind:'host',
|
|
506
|
+
* hostId }` target with a NON-local hostId reaches THAT host's workspace over
|
|
507
|
+
* the fleet wire (UNIT-B3, mirroring {@link execution}); local + default + cloud
|
|
508
|
+
* targets keep resolving the in-process adapter. There is NO silent local
|
|
509
|
+
* fallback: an unreachable host throws a precise, actionable error.
|
|
510
|
+
*/
|
|
511
|
+
workspaceFilesystem(hostTarget = DEFAULT_SESSION_HOST_TARGET) {
|
|
512
|
+
if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local') {
|
|
513
|
+
return this.remoteFilesystem(hostTarget.hostId);
|
|
514
|
+
}
|
|
515
|
+
return this.filesystem();
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Resolve a session's metadata, attempting attach-on-demand exactly once if
|
|
519
|
+
* the id is unknown IN THIS PROCESS: a session initialized by a prior process
|
|
520
|
+
* on this host persisted its metadata under the profile-owned session root (see
|
|
521
|
+
* `attach()`), so a cold process can still serve it. Concurrent calls for the
|
|
522
|
+
* same unknown id collapse onto a single attach attempt via `attachInFlight`.
|
|
523
|
+
*/
|
|
524
|
+
/**
|
|
525
|
+
* The same trust gate `initialize()` enforces, applied to every path that
|
|
526
|
+
* can hand out a workspace (attach / attach-on-demand). Without it, a
|
|
527
|
+
* below-threshold descriptor that could never CREATE a session could still
|
|
528
|
+
* run commands in one persisted by a higher-trust composition.
|
|
529
|
+
*/
|
|
530
|
+
requireTrust() {
|
|
531
|
+
if (!trustAllowsCodeSession(this.descriptor.trustLevel)) {
|
|
532
|
+
throw new CapabilityError('The code-session trust level cannot edit a writable workspace.', 'PERMISSION_DENIED', {
|
|
533
|
+
reason: 'code-session-trust-denied',
|
|
534
|
+
retryable: false,
|
|
535
|
+
details: { trustLevel: this.descriptor.trustLevel, requiredTrustLevel: CODE_SESSION_MIN_TRUST_LEVEL }
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async ensureSession(sessionId) {
|
|
540
|
+
this.requireTrust();
|
|
541
|
+
requireSafeSessionId(sessionId);
|
|
542
|
+
const known = this.sessions.get(sessionId);
|
|
543
|
+
if (known)
|
|
544
|
+
return this.requireReadySession(known);
|
|
545
|
+
let pending = this.attachInFlight.get(sessionId);
|
|
546
|
+
if (!pending) {
|
|
547
|
+
pending = this.loadPersistedSessionMeta(sessionId).finally(() => {
|
|
548
|
+
this.attachInFlight.delete(sessionId);
|
|
549
|
+
});
|
|
550
|
+
this.attachInFlight.set(sessionId, pending);
|
|
551
|
+
}
|
|
552
|
+
const meta = await pending;
|
|
553
|
+
if (!meta) {
|
|
554
|
+
throw new CapabilityError('The requested code session was not found.', 'NOT_FOUND', {
|
|
555
|
+
reason: 'code-session-workspace-not-found',
|
|
556
|
+
retryable: false,
|
|
557
|
+
details: { sessionId }
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return this.requireReadySession(meta);
|
|
561
|
+
}
|
|
562
|
+
async requireReadySession(meta) {
|
|
563
|
+
if (meta.hostTarget.kind === 'host' && meta.hostTarget.hostId !== 'local') {
|
|
564
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
565
|
+
const root = await this.targetSessionRoot(fs, meta.hostTarget);
|
|
566
|
+
if (meta.workspaceOwnership === 'owned' && meta.workspacePath !== ownedWorkspacePath(fs, root, meta.sessionId)) {
|
|
567
|
+
throw new CapabilityError('Remote session belongs to a different profile.', 'CONFLICT', {
|
|
568
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId: meta.sessionId }
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (meta.workspaceOwnership === 'owned') {
|
|
572
|
+
const contents = await fs.readFile(this.metadataPath(fs, meta.workspacePath), 'utf8');
|
|
573
|
+
const persisted = parseCodeSessionMetadata(utf8Text(contents));
|
|
574
|
+
if (!persisted || persisted.sessionId !== meta.sessionId || persisted.workspacePath !== meta.workspacePath ||
|
|
575
|
+
persisted.operationId !== meta.operationId || persisted.operationFingerprint !== meta.operationFingerprint ||
|
|
576
|
+
persisted.hostTarget.kind !== 'host' || persisted.hostTarget.hostId !== meta.hostTarget.hostId) {
|
|
577
|
+
throw new CapabilityError('Remote session metadata changed; explicit recovery is required.', 'CONFLICT', {
|
|
578
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId: meta.sessionId }
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (!meta.operationId)
|
|
584
|
+
return meta;
|
|
585
|
+
const fs = await this.filesystem();
|
|
586
|
+
const key = await codeSessionOperationKey(meta.operationId);
|
|
587
|
+
const record = await this.readOperationRecord(fs, key);
|
|
588
|
+
if (!record ||
|
|
589
|
+
record.stage !== 'ready' ||
|
|
590
|
+
record.sessionId !== meta.sessionId ||
|
|
591
|
+
record.fingerprint !== meta.operationFingerprint) {
|
|
592
|
+
throw new CapabilityError('The code-session operation is not ready.', 'CONFLICT', {
|
|
593
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { sessionId: meta.sessionId }
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return meta;
|
|
597
|
+
}
|
|
598
|
+
metadataPath(fs, workspacePath) {
|
|
599
|
+
return fs.join(workspacePath, CODE_SESSION_METADATA_FILENAME);
|
|
600
|
+
}
|
|
601
|
+
borrowedMetadataPath(fs, sessionId) {
|
|
602
|
+
return fs.join(this.sessionRoot(fs), '.metadata', `${requireSafeSessionId(sessionId)}.json`);
|
|
603
|
+
}
|
|
604
|
+
operationRoot(fs) {
|
|
605
|
+
return fs.join(this.sessionRoot(fs), '.operations');
|
|
606
|
+
}
|
|
607
|
+
operationDirectory(fs, key) {
|
|
608
|
+
return fs.join(this.operationRoot(fs), key);
|
|
609
|
+
}
|
|
610
|
+
operationRecordPath(fs, key) {
|
|
611
|
+
return fs.join(this.operationDirectory(fs, key), 'operation.json');
|
|
612
|
+
}
|
|
613
|
+
operationLeaseDirectory(fs, key) {
|
|
614
|
+
return fs.join(this.operationDirectory(fs, key), 'lease');
|
|
615
|
+
}
|
|
616
|
+
async readOperationLease(fs, key) {
|
|
617
|
+
const value = await this.readControlMetadata(fs, fs.join(this.operationLeaseDirectory(fs, key), 'lease.json'));
|
|
618
|
+
if (!value)
|
|
619
|
+
return null;
|
|
620
|
+
try {
|
|
621
|
+
return CodeSessionOperationLeaseSchema.parse(JSON.parse(utf8Text(value)));
|
|
622
|
+
}
|
|
623
|
+
catch (caught) {
|
|
624
|
+
throw metadataInvalid(valueFreeFailure(CaughtFailureSchema.parse(caught)));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async withOperationLease(operation, run, exclusive = false) {
|
|
628
|
+
const leaseDirectory = this.operationLeaseDirectory(operation.fs, operation.key);
|
|
629
|
+
const leasePath = operation.fs.join(leaseDirectory, 'lease.json');
|
|
630
|
+
const holderId = crypto.randomUUID();
|
|
631
|
+
const leaseDurationMs = 15_000;
|
|
632
|
+
// Another process holds the lease; its only signal is the lease directory
|
|
633
|
+
// going away, so this polls on the runtime's lifecycle (operation-lease.ts).
|
|
634
|
+
const acquired = await pollUntil(this.runtime.lifecycle, async () => {
|
|
635
|
+
try {
|
|
636
|
+
await operation.fs.mkdir(leaseDirectory);
|
|
637
|
+
return 'acquired';
|
|
638
|
+
}
|
|
639
|
+
catch (caught) {
|
|
640
|
+
const parsed = CaughtFailureSchema.parse(caught);
|
|
641
|
+
const failure = valueFreeFailure(parsed);
|
|
642
|
+
if (failure.code !== 'EEXIST')
|
|
643
|
+
throw CapabilityError.from(parsed);
|
|
644
|
+
const latest = await this.readOperationRecord(operation.fs, operation.key);
|
|
645
|
+
if (!exclusive && (latest?.stage === 'ready' ||
|
|
646
|
+
latest?.stage === 'attention-required' ||
|
|
647
|
+
latest?.stage === 'torn-down')) {
|
|
648
|
+
return latest;
|
|
649
|
+
}
|
|
650
|
+
const lease = await this.readOperationLease(operation.fs, operation.key);
|
|
651
|
+
if (lease && lease.expiresAt <= Date.now()) {
|
|
652
|
+
throw new CapabilityError('The code-session operation lease expired before reconciliation.', 'CONFLICT', {
|
|
653
|
+
reason: 'code-session-operation-conflict',
|
|
654
|
+
retryable: false,
|
|
655
|
+
details: { operationId: operation.record.operationId }
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
}, { intervalMs: 50, timeoutMs: 10 * 60_000, reason: 'code-session-operation-lease' });
|
|
661
|
+
if (acquired !== 'acquired') {
|
|
662
|
+
operation.record = acquired;
|
|
663
|
+
return run();
|
|
664
|
+
}
|
|
665
|
+
const renew = async () => {
|
|
666
|
+
await operation.fs.writeFile(leasePath, JSON.stringify({ holderId, expiresAt: Date.now() + leaseDurationMs }), 'utf8');
|
|
667
|
+
};
|
|
668
|
+
await renew();
|
|
669
|
+
try {
|
|
670
|
+
return await withLeaseHeartbeat(this.runtime.lifecycle, { periodMs: leaseDurationMs / 3, operationId: operation.record.operationId, renew, logger: this.log }, async () => {
|
|
671
|
+
const latest = await this.readOperationRecord(operation.fs, operation.key);
|
|
672
|
+
if (latest)
|
|
673
|
+
operation.record = latest;
|
|
674
|
+
return run();
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
finally {
|
|
678
|
+
const current = await this.readOperationLease(operation.fs, operation.key);
|
|
679
|
+
if (current?.holderId === holderId) {
|
|
680
|
+
await operation.fs.remove(leaseDirectory, { recursive: true });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async readOperationRecord(fs, key) {
|
|
685
|
+
const value = await this.readControlMetadata(fs, this.operationRecordPath(fs, key));
|
|
686
|
+
if (value === null)
|
|
687
|
+
return null;
|
|
688
|
+
try {
|
|
689
|
+
return PersistedCodeSessionOperationSchema.parse(JSON.parse(utf8Text(value)));
|
|
690
|
+
}
|
|
691
|
+
catch (caught) {
|
|
692
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
693
|
+
this.log.warn('operation record is malformed', { failure });
|
|
694
|
+
throw metadataInvalid(failure);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async writeOperationRecord(operation, patch) {
|
|
698
|
+
const updated = { ...operation.record, ...patch, updatedAt: this.nowIso() };
|
|
699
|
+
const fs = operation.fs;
|
|
700
|
+
const path = this.operationRecordPath(fs, operation.key);
|
|
701
|
+
const temporary = fs.join(fs.dirname(path), `.operation-${crypto.randomUUID()}.json`);
|
|
702
|
+
if (!await createFileExclusiveChecked(fs, temporary, JSON.stringify(updated, null, 2), 'utf8', {
|
|
703
|
+
message: 'Operation publication requires exclusive file creation'
|
|
704
|
+
})) {
|
|
705
|
+
throw new CapabilityError('Operation publication path is already occupied', 'CONFLICT', { retryable: false });
|
|
706
|
+
}
|
|
707
|
+
// Same-directory rename publishes complete bytes; failure preserves the old record and temporary evidence.
|
|
708
|
+
await fs.rename(temporary, path);
|
|
709
|
+
operation.record = updated;
|
|
710
|
+
}
|
|
711
|
+
async prepareOperation(input) {
|
|
712
|
+
const operationId = input.operationId?.trim();
|
|
713
|
+
if (!operationId)
|
|
714
|
+
return null;
|
|
715
|
+
if (input.sessionId) {
|
|
716
|
+
throw requestInvalid('sessionId');
|
|
717
|
+
}
|
|
718
|
+
const [key, fingerprint, fs] = await Promise.all([
|
|
719
|
+
codeSessionOperationKey(operationId),
|
|
720
|
+
codeSessionOperationFingerprint(input, { authorityId: this.descriptor.id }),
|
|
721
|
+
this.filesystem()
|
|
722
|
+
]);
|
|
723
|
+
const operationDirectory = this.operationDirectory(fs, key);
|
|
724
|
+
await fs.mkdir(operationDirectory, { recursive: true });
|
|
725
|
+
let record = await this.readOperationRecord(fs, key);
|
|
726
|
+
if (record) {
|
|
727
|
+
if (record.operationId !== operationId || record.fingerprint !== fingerprint) {
|
|
728
|
+
throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
|
|
729
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return { key, record, fs };
|
|
733
|
+
}
|
|
734
|
+
const claimDirectory = fs.join(operationDirectory, 'claim');
|
|
735
|
+
let ownsClaim = false;
|
|
736
|
+
try {
|
|
737
|
+
await fs.mkdir(claimDirectory);
|
|
738
|
+
ownsClaim = true;
|
|
739
|
+
}
|
|
740
|
+
catch (caught) {
|
|
741
|
+
const parsed = CaughtFailureSchema.parse(caught);
|
|
742
|
+
const failure = valueFreeFailure(parsed);
|
|
743
|
+
if (failure.code !== 'EEXIST')
|
|
744
|
+
throw CapabilityError.from(parsed);
|
|
745
|
+
}
|
|
746
|
+
if (!ownsClaim) {
|
|
747
|
+
// The claimant is another process; its only signal is the record file.
|
|
748
|
+
const claimed = await pollUntil(this.runtime.lifecycle, () => this.readOperationRecord(fs, key), {
|
|
749
|
+
intervalMs: 25,
|
|
750
|
+
timeoutMs: 10_000,
|
|
751
|
+
reason: 'code-session-operation-claim-record'
|
|
752
|
+
});
|
|
753
|
+
if (claimed.operationId !== operationId || claimed.fingerprint !== fingerprint) {
|
|
754
|
+
throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
|
|
755
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
return { key, record: claimed, fs };
|
|
759
|
+
}
|
|
760
|
+
// Re-read after taking the exclusive claim: a completed claimant may have
|
|
761
|
+
// released immediately before our mkdir won.
|
|
762
|
+
record = await this.readOperationRecord(fs, key);
|
|
763
|
+
if (record) {
|
|
764
|
+
await fs.remove(claimDirectory, { recursive: true });
|
|
765
|
+
if (record.operationId !== operationId || record.fingerprint !== fingerprint) {
|
|
766
|
+
throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
|
|
767
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
return { key, record, fs };
|
|
771
|
+
}
|
|
772
|
+
const now = this.nowIso();
|
|
773
|
+
record = {
|
|
774
|
+
operationId,
|
|
775
|
+
fingerprint,
|
|
776
|
+
sessionId: `op-${key.slice(0, 32)}`,
|
|
777
|
+
stage: 'reserved',
|
|
778
|
+
generation: 1,
|
|
779
|
+
createdAt: now,
|
|
780
|
+
updatedAt: now
|
|
781
|
+
};
|
|
782
|
+
const fingerprintMarker = fs.join(operationDirectory, 'fingerprints', fingerprint.slice('sha256:'.length));
|
|
783
|
+
await fs.mkdir(fingerprintMarker, { recursive: true });
|
|
784
|
+
await this.writeOperationRecord({ fs, key, record }, {});
|
|
785
|
+
const durable = await this.readOperationRecord(fs, key);
|
|
786
|
+
if (!durable || durable.fingerprint !== fingerprint) {
|
|
787
|
+
throw new CapabilityError('The code-session operation claim was not durably recorded.', 'CONFLICT', {
|
|
788
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId }
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
await fs.remove(claimDirectory, { recursive: true });
|
|
792
|
+
return { key, record, fs };
|
|
793
|
+
}
|
|
794
|
+
async persistSessionMetadata(meta, ownedStoragePath) {
|
|
795
|
+
// Borrowed bookkeeping lives on the LOCAL control root (never inside the
|
|
796
|
+
// user's repo); an OWNED workspace carries its own metadata file, which for a
|
|
797
|
+
// remote host lives on the REMOTE workspace filesystem.
|
|
798
|
+
const fs = meta.workspaceOwnership === 'borrowed'
|
|
799
|
+
? await this.filesystem()
|
|
800
|
+
: await this.workspaceFilesystem(meta.hostTarget);
|
|
801
|
+
const metadataPath = meta.workspaceOwnership === 'borrowed'
|
|
802
|
+
? this.borrowedMetadataPath(fs, meta.sessionId)
|
|
803
|
+
: this.metadataPath(fs, ownedStoragePath ?? meta.workspacePath);
|
|
804
|
+
if (meta.workspaceOwnership === 'borrowed') {
|
|
805
|
+
await fs.mkdir(fs.dirname(metadataPath), { recursive: true });
|
|
806
|
+
}
|
|
807
|
+
await fs.writeFile(metadataPath, serializeCodeSessionMetadata({
|
|
808
|
+
sessionId: meta.sessionId,
|
|
809
|
+
operationId: meta.operationId,
|
|
810
|
+
operationFingerprint: meta.operationFingerprint,
|
|
811
|
+
workspacePath: meta.workspacePath,
|
|
812
|
+
workspaceOwnership: meta.workspaceOwnership,
|
|
813
|
+
createdAt: meta.createdAt,
|
|
814
|
+
hostTarget: meta.hostTarget,
|
|
815
|
+
repositoryId: meta.repositoryId,
|
|
816
|
+
cloneUrl: meta.cloneUrl,
|
|
817
|
+
ephemeral: meta.ephemeral,
|
|
818
|
+
checkoutRef: meta.checkoutRef,
|
|
819
|
+
exposedPorts: meta.exposedPorts,
|
|
820
|
+
previewUrl: meta.previewUrl,
|
|
821
|
+
pausedAt: meta.pausedAt
|
|
822
|
+
}), 'utf8');
|
|
823
|
+
}
|
|
824
|
+
/** Local discovery contains identity only; the remote workspace owns full metadata. */
|
|
825
|
+
async persistRemotePointer(meta) {
|
|
826
|
+
const fs = await this.filesystem();
|
|
827
|
+
const path = this.borrowedMetadataPath(fs, meta.sessionId);
|
|
828
|
+
await fs.mkdir(fs.dirname(path), { recursive: true });
|
|
829
|
+
await fs.writeFile(path, JSON.stringify({
|
|
830
|
+
sessionId: meta.sessionId, operationId: meta.operationId,
|
|
831
|
+
operationFingerprint: meta.operationFingerprint, hostTarget: meta.hostTarget,
|
|
832
|
+
workspacePath: meta.workspacePath, workspaceOwnership: 'owned'
|
|
833
|
+
}), 'utf8');
|
|
834
|
+
}
|
|
835
|
+
cleanupPath(fs, root, key, cleanup) {
|
|
836
|
+
const parent = fs.join(root, '.cleanup', key);
|
|
837
|
+
const relative = fs.relative(parent, cleanup.path);
|
|
838
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/workspace$/.test(relative) ||
|
|
839
|
+
!['intent', 'quarantined', 'removed'].includes(cleanup.phase) || fs.normalize(cleanup.path) !== fs.join(parent, relative)) {
|
|
840
|
+
throw new CapabilityError('Remote cleanup authorization does not match the current profile', 'CONFLICT', { retryable: false });
|
|
841
|
+
}
|
|
842
|
+
return cleanup.path;
|
|
843
|
+
}
|
|
844
|
+
async readControlMetadata(fs, path) {
|
|
845
|
+
try {
|
|
846
|
+
return await fs.readFile(path, 'utf8');
|
|
847
|
+
}
|
|
848
|
+
catch (caught) {
|
|
849
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
850
|
+
if (failure.code === 'ENOENT')
|
|
851
|
+
return null;
|
|
852
|
+
throw new CapabilityError('Code-session control metadata could not be read.', 'UNAVAILABLE', {
|
|
853
|
+
reason: 'code-session-host-unavailable', retryable: false, cause: failure
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
async loadPersistedSessionMeta(sessionId, forCleanup = false, allowLegacy = true) {
|
|
858
|
+
const fs = await this.filesystem();
|
|
859
|
+
requireSafeSessionId(sessionId);
|
|
860
|
+
const persistedOwnedWorkspacePath = ownedWorkspacePath(fs, this.sessionRoot(fs), sessionId);
|
|
861
|
+
const borrowedPath = this.borrowedMetadataPath(fs, sessionId);
|
|
862
|
+
const ownedPath = this.metadataPath(fs, persistedOwnedWorkspacePath);
|
|
863
|
+
const contents = (await this.readControlMetadata(fs, borrowedPath)) ??
|
|
864
|
+
(await this.readControlMetadata(fs, ownedPath));
|
|
865
|
+
const text = contents === null ? null : utf8Text(contents);
|
|
866
|
+
let parsed = parseCodeSessionMetadata(text);
|
|
867
|
+
if (!parsed) {
|
|
868
|
+
if (text !== null)
|
|
869
|
+
throw metadataInvalid();
|
|
870
|
+
return allowLegacy ? this.reattachLegacyLocalSession(fs, sessionId) : null;
|
|
871
|
+
}
|
|
872
|
+
if (parsed.sessionId !== sessionId) {
|
|
873
|
+
throw new CapabilityError('Persisted session identity does not match the requested session.', 'CONFLICT', {
|
|
874
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
const workspaceOwnership = parsed.workspaceOwnership ?? 'owned';
|
|
878
|
+
let cleanupPending = false;
|
|
879
|
+
let cleanupRecord = null;
|
|
880
|
+
if (forCleanup && parsed.operationId) {
|
|
881
|
+
const record = await this.readOperationRecord(fs, await codeSessionOperationKey(parsed.operationId));
|
|
882
|
+
cleanupRecord = record;
|
|
883
|
+
cleanupPending = !!record && (record.stage === 'cleanup-pending' || record.stage === 'torn-down') &&
|
|
884
|
+
record.sessionId === sessionId && record.fingerprint === parsed.operationFingerprint;
|
|
885
|
+
}
|
|
886
|
+
const targetFs = await this.workspaceFilesystem(parsed.hostTarget);
|
|
887
|
+
const isRemote = parsed.hostTarget.kind === 'host' && parsed.hostTarget.hostId !== 'local';
|
|
888
|
+
const workspacePath = workspaceOwnership === 'borrowed' ? parsed.workspacePath
|
|
889
|
+
: ownedWorkspacePath(targetFs, await this.targetSessionRoot(targetFs, parsed.hostTarget), sessionId);
|
|
890
|
+
if (!workspacePath)
|
|
891
|
+
return null;
|
|
892
|
+
if (isRemote && workspaceOwnership === 'owned') {
|
|
893
|
+
if (!parsed.operationId || !parsed.operationFingerprint || parsed.workspacePath !== workspacePath) {
|
|
894
|
+
throw new CapabilityError('Remote session roots or identity changed; explicit recovery is required.', 'CONFLICT', {
|
|
895
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
let cleanupAuthorized = false;
|
|
899
|
+
if (cleanupPending && cleanupRecord?.cleanup) {
|
|
900
|
+
const root = await this.targetSessionRoot(targetFs, parsed.hostTarget);
|
|
901
|
+
const path = this.cleanupPath(targetFs, root, await codeSessionOperationKey(parsed.operationId), cleanupRecord.cleanup);
|
|
902
|
+
if (cleanupRecord.cleanup.phase === 'intent') {
|
|
903
|
+
const quarantined = await targetFs.exists(path);
|
|
904
|
+
if (quarantined && await targetFs.exists(workspacePath)) {
|
|
905
|
+
throw new CapabilityError('Remote cleanup rename is ambiguous; explicit recovery is required.', 'CONFLICT', {
|
|
906
|
+
reason: 'code-session-cleanup-conflict', retryable: false, details: { sessionId }
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
cleanupAuthorized = quarantined;
|
|
910
|
+
}
|
|
911
|
+
else
|
|
912
|
+
cleanupAuthorized = true;
|
|
913
|
+
}
|
|
914
|
+
if (!cleanupAuthorized && (!cleanupPending || await targetFs.exists(workspacePath))) {
|
|
915
|
+
const remoteContents = await targetFs.readFile(this.metadataPath(targetFs, workspacePath), 'utf8');
|
|
916
|
+
const remoteMetadata = parseCodeSessionMetadata(utf8Text(remoteContents));
|
|
917
|
+
if (!remoteMetadata || remoteMetadata.sessionId !== sessionId ||
|
|
918
|
+
remoteMetadata.operationId !== parsed.operationId || remoteMetadata.operationFingerprint !== parsed.operationFingerprint ||
|
|
919
|
+
remoteMetadata.workspacePath !== workspacePath || remoteMetadata.workspaceOwnership !== 'owned' ||
|
|
920
|
+
remoteMetadata.hostTarget.kind !== 'host' || parsed.hostTarget.kind !== 'host' || remoteMetadata.hostTarget.hostId !== parsed.hostTarget.hostId) {
|
|
921
|
+
throw new CapabilityError('Remote session publication does not match its control record.', 'CONFLICT', {
|
|
922
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
parsed = remoteMetadata;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
if (!cleanupPending && !(await targetFs.exists(workspacePath)))
|
|
929
|
+
return null;
|
|
930
|
+
const meta = {
|
|
931
|
+
sessionId: parsed.sessionId,
|
|
932
|
+
operationId: parsed.operationId,
|
|
933
|
+
operationFingerprint: parsed.operationFingerprint,
|
|
934
|
+
workspacePath,
|
|
935
|
+
workspaceOwnership,
|
|
936
|
+
createdAt: parsed.createdAt ?? this.nowIso(),
|
|
937
|
+
hostTarget: parsed.hostTarget,
|
|
938
|
+
repositoryId: parsed.repositoryId,
|
|
939
|
+
cloneUrl: parsed.cloneUrl,
|
|
940
|
+
ephemeral: parsed.ephemeral,
|
|
941
|
+
checkoutRef: parsed.checkoutRef,
|
|
942
|
+
exposedPorts: parsed.exposedPorts ?? [],
|
|
943
|
+
previewUrl: parsed.previewUrl,
|
|
944
|
+
pausedAt: parsed.pausedAt
|
|
945
|
+
};
|
|
946
|
+
if (isRemote && workspaceOwnership === 'owned' && !cleanupPending)
|
|
947
|
+
await this.requireReadySession(meta);
|
|
948
|
+
this.sessions.set(sessionId, meta);
|
|
949
|
+
this.background.set(sessionId, this.background.get(sessionId) ?? []);
|
|
950
|
+
return meta;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Reattach exactly one requested pre-profile local session.
|
|
954
|
+
*
|
|
955
|
+
* This intentionally never participates in discover(): profile isolation
|
|
956
|
+
* remains the default and no new profile absorbs the process-global legacy
|
|
957
|
+
* roster. A successful match is copied into this profile as BORROWED, so
|
|
958
|
+
* teardown cannot delete legacy workspace bytes that may still be referenced
|
|
959
|
+
* by an older process.
|
|
960
|
+
*/
|
|
961
|
+
async reattachLegacyLocalSession(fs, sessionId) {
|
|
962
|
+
const legacyRoot = fs.normalize(fs.resolve(LEGACY_LOCAL_SESSION_ROOT));
|
|
963
|
+
if (legacyRoot === this.sessionRoot(fs))
|
|
964
|
+
return null;
|
|
965
|
+
const legacyWorkspacePath = ownedWorkspacePath(fs, legacyRoot, sessionId);
|
|
966
|
+
const legacyBorrowedPath = fs.join(legacyRoot, '.metadata', `${sessionId}.json`);
|
|
967
|
+
const legacyOwnedPath = this.metadataPath(fs, legacyWorkspacePath);
|
|
968
|
+
const contents = (await this.readControlMetadata(fs, legacyBorrowedPath)) ??
|
|
969
|
+
(await this.readControlMetadata(fs, legacyOwnedPath));
|
|
970
|
+
const text = contents === null ? null : utf8Text(contents);
|
|
971
|
+
const parsed = parseCodeSessionMetadata(text);
|
|
972
|
+
if (!parsed) {
|
|
973
|
+
if (text !== null)
|
|
974
|
+
throw metadataInvalid();
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
if (parsed.sessionId !== sessionId) {
|
|
978
|
+
throw new CapabilityError('Legacy session identity does not match the requested session.', 'CONFLICT', {
|
|
979
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
if (parsed.hostTarget.kind !== 'host' || parsed.hostTarget.hostId !== 'local') {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
const legacyOwnership = parsed.workspaceOwnership ?? 'owned';
|
|
986
|
+
const workspacePath = legacyOwnership === 'borrowed' ? parsed.workspacePath : legacyWorkspacePath;
|
|
987
|
+
if (!workspacePath || !(await fs.exists(workspacePath)))
|
|
988
|
+
return null;
|
|
989
|
+
const meta = {
|
|
990
|
+
sessionId,
|
|
991
|
+
operationId: parsed.operationId,
|
|
992
|
+
operationFingerprint: parsed.operationFingerprint,
|
|
993
|
+
workspacePath,
|
|
994
|
+
workspaceOwnership: 'borrowed',
|
|
995
|
+
createdAt: parsed.createdAt ?? this.nowIso(),
|
|
996
|
+
hostTarget: parsed.hostTarget,
|
|
997
|
+
repositoryId: parsed.repositoryId,
|
|
998
|
+
cloneUrl: parsed.cloneUrl,
|
|
999
|
+
ephemeral: parsed.ephemeral,
|
|
1000
|
+
checkoutRef: parsed.checkoutRef,
|
|
1001
|
+
exposedPorts: parsed.exposedPorts ?? [],
|
|
1002
|
+
previewUrl: parsed.previewUrl,
|
|
1003
|
+
pausedAt: parsed.pausedAt
|
|
1004
|
+
};
|
|
1005
|
+
await this.persistSessionMetadata(meta);
|
|
1006
|
+
this.sessions.set(sessionId, meta);
|
|
1007
|
+
this.background.set(sessionId, this.background.get(sessionId) ?? []);
|
|
1008
|
+
return meta;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Run a one-shot command, capturing output. Used internally for git ops.
|
|
1012
|
+
*
|
|
1013
|
+
* Uses `runtime/execution`'s `spawn()` mode + self-drain rather than
|
|
1014
|
+
* `execute()` on purpose: this method owns a distinct timeout contract
|
|
1015
|
+
* (SIGTERM kill, forced `exitCode:null`, and an explicit
|
|
1016
|
+
* `[code-session] aborted after …` stderr suffix) that `execute()`'s
|
|
1017
|
+
* SIGKILL/137 + no-suffix semantics would change. Keeping spawn()+drain
|
|
1018
|
+
* preserves the exact consumer-observable behavior the characterization
|
|
1019
|
+
* tests pin.
|
|
1020
|
+
*/
|
|
1021
|
+
async exec(workspacePath, command, opts = {}, hostTarget = DEFAULT_SESSION_HOST_TARGET) {
|
|
1022
|
+
const started = this.now();
|
|
1023
|
+
if (opts.signal?.aborted) {
|
|
1024
|
+
return {
|
|
1025
|
+
exitCode: null,
|
|
1026
|
+
stdout: '',
|
|
1027
|
+
stderr: '[code-session] stopped before spawn',
|
|
1028
|
+
timedOut: false,
|
|
1029
|
+
durationMs: this.now() - started,
|
|
1030
|
+
aborted: true
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
let timedOut = false;
|
|
1034
|
+
const timeoutMs = Math.min(opts.timeoutMs ?? this.descriptor.timeoutMs, this.descriptor.timeoutMs);
|
|
1035
|
+
const [bin, ...args] = command;
|
|
1036
|
+
// One cancellation and one total deadline own the operation from here,
|
|
1037
|
+
// before any host work: before dispatch they stop it; during a remote
|
|
1038
|
+
// setup they cancel its acquisition; afterwards they kill the handle.
|
|
1039
|
+
let handle = null;
|
|
1040
|
+
let acquisition;
|
|
1041
|
+
let aborted = false;
|
|
1042
|
+
const cancel = (signal) => {
|
|
1043
|
+
if (handle)
|
|
1044
|
+
requestKill(signal);
|
|
1045
|
+
else
|
|
1046
|
+
acquisition?.cancel();
|
|
1047
|
+
};
|
|
1048
|
+
const disarmDeadline = this.runtime.lifecycle.timeout(() => {
|
|
1049
|
+
timedOut = true;
|
|
1050
|
+
cancel('SIGKILL');
|
|
1051
|
+
}, timeoutMs);
|
|
1052
|
+
// User abort (composer Stop): same cancellation as the timeout path,
|
|
1053
|
+
// distinct flag so callers can tell "stopped" from "timed out".
|
|
1054
|
+
const onAbort = () => {
|
|
1055
|
+
if (aborted)
|
|
1056
|
+
return;
|
|
1057
|
+
aborted = true;
|
|
1058
|
+
cancel(opts.abortKillSignal ?? 'SIGKILL');
|
|
1059
|
+
};
|
|
1060
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1061
|
+
let kill;
|
|
1062
|
+
let killSignal;
|
|
1063
|
+
let resolveKillFailure;
|
|
1064
|
+
const killFailure = new Promise((resolve) => { resolveKillFailure = resolve; });
|
|
1065
|
+
const requestKill = (signal) => {
|
|
1066
|
+
if (!handle || killSignal === 'SIGKILL' || killSignal === signal)
|
|
1067
|
+
return;
|
|
1068
|
+
const owned = handle;
|
|
1069
|
+
killSignal = signal;
|
|
1070
|
+
// A hard deadline can escalate an earlier graceful stop without waiting for its acknowledgement.
|
|
1071
|
+
const request = Promise.resolve().then(() => owned.kill(signal)).then(() => undefined, (caught) => {
|
|
1072
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
1073
|
+
resolveKillFailure(new CapabilityError('Code-session process cancellation failed; cleanup is unconfirmed.', 'UNAVAILABLE', {
|
|
1074
|
+
reason: 'code-session-cleanup-unconfirmed', retryable: false, cause: failure
|
|
1075
|
+
}));
|
|
1076
|
+
return failure;
|
|
1077
|
+
});
|
|
1078
|
+
kill = kill ? Promise.all([kill, request]).then(([previous, current]) => previous ?? current) : request;
|
|
1079
|
+
};
|
|
1080
|
+
try {
|
|
1081
|
+
try {
|
|
1082
|
+
handle = await this.spawnOnHost(workspacePath, hostTarget, {
|
|
1083
|
+
command: bin,
|
|
1084
|
+
args,
|
|
1085
|
+
cwd: opts.cwd ?? workspacePath,
|
|
1086
|
+
stdout: 'pipe',
|
|
1087
|
+
stderr: 'pipe',
|
|
1088
|
+
// Timeout and abort kill the command's whole tree, not only its direct process.
|
|
1089
|
+
killTree: true
|
|
1090
|
+
}, opts.env, {
|
|
1091
|
+
cancelled: () => timedOut || aborted,
|
|
1092
|
+
dispatched: (remote) => { acquisition = remote; }
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
catch (caught) {
|
|
1096
|
+
// A cancelled acquisition keeps observing its late port; the caller
|
|
1097
|
+
// learns only that cleanup is not yet confirmed.
|
|
1098
|
+
if (acquisition && (timedOut || aborted)) {
|
|
1099
|
+
throw new CapabilityError('Code-session process setup was cancelled; cleanup is unconfirmed.', 'UNAVAILABLE', {
|
|
1100
|
+
reason: 'code-session-cleanup-unconfirmed',
|
|
1101
|
+
retryable: false,
|
|
1102
|
+
cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
throw CapabilityError.from(CaughtFailureSchema.parse(caught));
|
|
1106
|
+
}
|
|
1107
|
+
if (!handle) {
|
|
1108
|
+
// Cancelled before dispatch: no process was requested.
|
|
1109
|
+
const cancelled = {
|
|
1110
|
+
exitCode: null,
|
|
1111
|
+
stdout: '',
|
|
1112
|
+
stderr: timedOut ? `[code-session] aborted after ${timeoutMs}ms` : '[code-session] stopped before spawn',
|
|
1113
|
+
timedOut,
|
|
1114
|
+
durationMs: this.now() - started
|
|
1115
|
+
};
|
|
1116
|
+
if (aborted && !timedOut)
|
|
1117
|
+
cancelled.aborted = true;
|
|
1118
|
+
return cancelled;
|
|
1119
|
+
}
|
|
1120
|
+
// Cancellation during a local spawn (which always settles) kills now.
|
|
1121
|
+
if (timedOut || aborted)
|
|
1122
|
+
requestKill(timedOut ? 'SIGKILL' : (opts.abortKillSignal ?? 'SIGKILL'));
|
|
1123
|
+
const outcome = await Promise.race([
|
|
1124
|
+
Promise.all([readStream(handle.stdout), readStream(handle.stderr), handle.exited]).then((value) => ({ kind: 'complete', value })),
|
|
1125
|
+
killFailure.then((error) => ({ kind: 'kill-failed', error }))
|
|
1126
|
+
]);
|
|
1127
|
+
if (outcome.kind === 'kill-failed')
|
|
1128
|
+
throw outcome.error;
|
|
1129
|
+
const [stdout, stderr, exit] = outcome.value;
|
|
1130
|
+
if (kill)
|
|
1131
|
+
await kill;
|
|
1132
|
+
const result = {
|
|
1133
|
+
exitCode: timedOut || aborted ? null : exit.code,
|
|
1134
|
+
stdout,
|
|
1135
|
+
stderr: timedOut || aborted
|
|
1136
|
+
? `${stderr}\n${timedOut ? `[code-session] aborted after ${timeoutMs}ms` : '[code-session] stopped by user'}`
|
|
1137
|
+
: stderr,
|
|
1138
|
+
timedOut,
|
|
1139
|
+
durationMs: this.now() - started
|
|
1140
|
+
};
|
|
1141
|
+
if (aborted)
|
|
1142
|
+
result.aborted = true;
|
|
1143
|
+
return result;
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
disarmDeadline();
|
|
1147
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
1148
|
+
if (kill)
|
|
1149
|
+
await kill;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
async initialize(input) {
|
|
1153
|
+
input = parseCodeSessionInitializeInput(input);
|
|
1154
|
+
if (input.sessionId) {
|
|
1155
|
+
if (input.operationId) {
|
|
1156
|
+
throw requestInvalid('operationId');
|
|
1157
|
+
}
|
|
1158
|
+
if (input.workspacePath) {
|
|
1159
|
+
this.requireTrust();
|
|
1160
|
+
const sessionId = requireSafeSessionId(input.sessionId);
|
|
1161
|
+
const hostTarget = input.hostTarget ?? this.descriptor.hostTarget ?? DEFAULT_SESSION_HOST_TARGET;
|
|
1162
|
+
if (hostTarget.kind !== 'host') {
|
|
1163
|
+
throw requestInvalid('hostTarget');
|
|
1164
|
+
}
|
|
1165
|
+
if (input.cloneUrl || input.repositoryId || input.ephemeral) {
|
|
1166
|
+
throw requestInvalid('workspacePath');
|
|
1167
|
+
}
|
|
1168
|
+
const fs = await this.workspaceFilesystem(hostTarget);
|
|
1169
|
+
const workspacePath = fs.resolve(input.workspacePath);
|
|
1170
|
+
if (!(await fs.exists(workspacePath)) || (await fs.stat(workspacePath)).kind !== 'directory') {
|
|
1171
|
+
throw new CapabilityError('The requested workspace does not exist.', 'NOT_FOUND', {
|
|
1172
|
+
reason: 'code-session-workspace-not-found', retryable: false, details: { sessionId }
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
const meta = {
|
|
1176
|
+
sessionId,
|
|
1177
|
+
workspacePath,
|
|
1178
|
+
workspaceOwnership: 'borrowed',
|
|
1179
|
+
createdAt: this.nowIso(),
|
|
1180
|
+
hostTarget,
|
|
1181
|
+
checkoutRef: input.checkoutRef,
|
|
1182
|
+
exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts]
|
|
1183
|
+
};
|
|
1184
|
+
await this.persistSessionMetadata(meta);
|
|
1185
|
+
this.sessions.set(sessionId, meta);
|
|
1186
|
+
this.background.set(sessionId, []);
|
|
1187
|
+
await notifyAllocated(input, sessionId, this.log);
|
|
1188
|
+
return this.toHandle(meta);
|
|
1189
|
+
}
|
|
1190
|
+
return this.attach(input.sessionId);
|
|
1191
|
+
}
|
|
1192
|
+
return this.createOrGet({
|
|
1193
|
+
...input,
|
|
1194
|
+
operationId: input.operationId?.trim() || `initialize:${crypto.randomUUID()}`
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
async createOrGet(input) {
|
|
1198
|
+
input = parseCodeSessionCreateOrGetInput(input);
|
|
1199
|
+
this.requireTrust();
|
|
1200
|
+
input = {
|
|
1201
|
+
...input,
|
|
1202
|
+
hostTarget: input.hostTarget ?? this.descriptor.hostTarget ?? DEFAULT_SESSION_HOST_TARGET,
|
|
1203
|
+
runtimeConfig: { ...this.descriptor.runtimeConfig, ...input.runtimeConfig },
|
|
1204
|
+
exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts]
|
|
1205
|
+
};
|
|
1206
|
+
const operationId = input.operationId;
|
|
1207
|
+
const admittedOperation = CodeSessionOperationIdSchema.safeParse(operationId);
|
|
1208
|
+
if (!admittedOperation.success)
|
|
1209
|
+
throw requestInvalid('operationId');
|
|
1210
|
+
const fingerprint = codeSessionOperationFingerprint(input, { authorityId: this.descriptor.id });
|
|
1211
|
+
// Claim before any await: the key's own refusals (empty, too long) still
|
|
1212
|
+
// surface through codeSessionOperationKey below, on the claimant's path.
|
|
1213
|
+
const claimKey = admittedOperation.data;
|
|
1214
|
+
const running = this.operationInFlight.get(claimKey);
|
|
1215
|
+
if (running) {
|
|
1216
|
+
// A later caller is compared against the first claimant, never the reverse.
|
|
1217
|
+
const [mine, theirs] = await Promise.all([fingerprint, running.fingerprint]);
|
|
1218
|
+
if (mine !== theirs) {
|
|
1219
|
+
throw new CapabilityError('The code-session operation was claimed with a different configuration.', 'CONFLICT', {
|
|
1220
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: claimKey }
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
return running.promise;
|
|
1224
|
+
}
|
|
1225
|
+
const pending = Promise.all([codeSessionOperationKey(operationId), fingerprint])
|
|
1226
|
+
.then(() => this.createOrGetOnce(input))
|
|
1227
|
+
.finally(() => {
|
|
1228
|
+
if (this.operationInFlight.get(claimKey)?.promise === pending) {
|
|
1229
|
+
this.operationInFlight.delete(claimKey);
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
this.operationInFlight.set(claimKey, { fingerprint, promise: pending });
|
|
1233
|
+
return pending;
|
|
1234
|
+
}
|
|
1235
|
+
async createOrGetOnce(input) {
|
|
1236
|
+
const operation = await this.prepareOperation(input);
|
|
1237
|
+
if (!operation)
|
|
1238
|
+
throw requestInvalid('operationId');
|
|
1239
|
+
return this.withOperationLease(operation, async () => {
|
|
1240
|
+
const { record } = operation;
|
|
1241
|
+
if (record.stage === 'cleanup-pending' || record.stage === 'torn-down') {
|
|
1242
|
+
throw new CapabilityError('The code-session operation was already torn down.', 'CONFLICT', {
|
|
1243
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
if (record.stage === 'attention-required') {
|
|
1247
|
+
throw new CapabilityError('The code-session operation requires explicit reconciliation.', 'CONFLICT', {
|
|
1248
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
if (record.stage === 'setup-intent' || record.stage === 'setup-running') {
|
|
1252
|
+
await this.writeOperationRecord(operation, {
|
|
1253
|
+
stage: 'attention-required',
|
|
1254
|
+
failure: 'setup start was recorded without a durable success receipt; refusing to rerun it'
|
|
1255
|
+
});
|
|
1256
|
+
throw new CapabilityError('The code-session setup may have partially run.', 'CONFLICT', {
|
|
1257
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
if (record.stage === 'ready') {
|
|
1261
|
+
await notifyAllocated(input, record.sessionId, this.log);
|
|
1262
|
+
return this.attach(record.sessionId);
|
|
1263
|
+
}
|
|
1264
|
+
const sessionId = requireSafeSessionId(record.sessionId);
|
|
1265
|
+
await notifyAllocated(input, sessionId, this.log);
|
|
1266
|
+
const hostTarget = input.hostTarget ?? DEFAULT_SESSION_HOST_TARGET;
|
|
1267
|
+
const fs = await this.workspaceFilesystem(hostTarget);
|
|
1268
|
+
const borrowedWorkspace = input.workspacePath?.trim();
|
|
1269
|
+
if (borrowedWorkspace && hostTarget.kind !== 'host') {
|
|
1270
|
+
throw requestInvalid('hostTarget');
|
|
1271
|
+
}
|
|
1272
|
+
if (borrowedWorkspace && (input.cloneUrl || input.repositoryId || input.ephemeral)) {
|
|
1273
|
+
throw requestInvalid('workspacePath');
|
|
1274
|
+
}
|
|
1275
|
+
const sessionRoot = await this.targetSessionRoot(fs, hostTarget);
|
|
1276
|
+
const workspacePath = borrowedWorkspace
|
|
1277
|
+
? fs.resolve(borrowedWorkspace)
|
|
1278
|
+
: ownedWorkspacePath(fs, sessionRoot, sessionId);
|
|
1279
|
+
const workspaceOwnership = borrowedWorkspace
|
|
1280
|
+
? 'borrowed'
|
|
1281
|
+
: 'owned';
|
|
1282
|
+
const stagingPath = borrowedWorkspace
|
|
1283
|
+
? workspacePath
|
|
1284
|
+
: fs.join(sessionRoot, '.staging', operation.key, `generation-${record.generation}`);
|
|
1285
|
+
const existingReady = await this.loadPersistedSessionMeta(sessionId, false, false);
|
|
1286
|
+
if (existingReady?.operationId === record.operationId &&
|
|
1287
|
+
existingReady.operationFingerprint === record.fingerprint) {
|
|
1288
|
+
await this.writeOperationRecord(operation, { stage: 'ready', failure: undefined });
|
|
1289
|
+
return this.toHandle(existingReady);
|
|
1290
|
+
}
|
|
1291
|
+
await this.writeOperationRecord(operation, { stagingPath });
|
|
1292
|
+
if (workspaceOwnership === 'borrowed') {
|
|
1293
|
+
if (!(await fs.exists(workspacePath))) {
|
|
1294
|
+
throw new CapabilityError('The requested workspace does not exist.', 'NOT_FOUND', {
|
|
1295
|
+
reason: 'code-session-workspace-not-found', retryable: false, details: { sessionId }
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
const stat = await fs.stat(workspacePath);
|
|
1299
|
+
if (stat.kind !== 'directory') {
|
|
1300
|
+
throw requestInvalid('workspacePath');
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
await fs.mkdir(stagingPath, { recursive: true });
|
|
1305
|
+
}
|
|
1306
|
+
if (record.stage === 'reserved' || record.stage === 'source-preparing') {
|
|
1307
|
+
await this.writeOperationRecord(operation, { stage: 'source-preparing' });
|
|
1308
|
+
const gitDirectory = fs.join(stagingPath, '.git');
|
|
1309
|
+
const hasGit = await fs.exists(gitDirectory);
|
|
1310
|
+
if (input.cloneUrl) {
|
|
1311
|
+
if (hasGit) {
|
|
1312
|
+
const remote = await this.exec(stagingPath, ['git', 'remote', 'get-url', 'origin'], { cwd: stagingPath }, hostTarget);
|
|
1313
|
+
if (remote.exitCode !== 0 || remote.stdout.trim() !== input.cloneUrl) {
|
|
1314
|
+
await this.writeOperationRecord(operation, {
|
|
1315
|
+
stage: 'attention-required',
|
|
1316
|
+
failure: 'the preserved staging repository does not match the requested clone URL'
|
|
1317
|
+
});
|
|
1318
|
+
throw new CapabilityError('The preserved staging repository does not match this operation.', 'CONFLICT', {
|
|
1319
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
else {
|
|
1324
|
+
const entries = await fs.list(stagingPath);
|
|
1325
|
+
if (entries.length > 0) {
|
|
1326
|
+
await this.writeOperationRecord(operation, {
|
|
1327
|
+
stage: 'attention-required',
|
|
1328
|
+
failure: 'clone was interrupted and left an unverified staging workspace'
|
|
1329
|
+
});
|
|
1330
|
+
throw new CapabilityError('An interrupted clone left an unverified staging workspace.', 'CONFLICT', {
|
|
1331
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
const cloneCred = codeSessionCredentialArgs(input.cloneCredential);
|
|
1335
|
+
const credEnv = Object.keys(cloneCred.env).length > 0 ? cloneCred.env : undefined;
|
|
1336
|
+
const cloneArgs = ['git', ...cloneCred.args, 'clone'];
|
|
1337
|
+
if (input.checkoutRef)
|
|
1338
|
+
cloneArgs.push('--branch', input.checkoutRef);
|
|
1339
|
+
cloneArgs.push(input.cloneUrl, stagingPath);
|
|
1340
|
+
const cloneOptions = { cwd: sessionRoot };
|
|
1341
|
+
if (credEnv)
|
|
1342
|
+
cloneOptions.env = credEnv;
|
|
1343
|
+
const result = await this.exec(sessionRoot, cloneArgs, cloneOptions, hostTarget);
|
|
1344
|
+
if (result.exitCode !== 0) {
|
|
1345
|
+
await this.writeOperationRecord(operation, {
|
|
1346
|
+
stage: 'attention-required',
|
|
1347
|
+
failure: 'clone command failed'
|
|
1348
|
+
});
|
|
1349
|
+
throw new CapabilityError('The code-session clone command failed.', 'UNAVAILABLE', {
|
|
1350
|
+
reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: result.exitCode }
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
else if ((input.repositoryId || input.ephemeral) && !hasGit) {
|
|
1356
|
+
const init = await this.exec(stagingPath, ['git', 'init'], {}, hostTarget);
|
|
1357
|
+
if (init.exitCode !== 0) {
|
|
1358
|
+
await this.writeOperationRecord(operation, {
|
|
1359
|
+
stage: 'attention-required',
|
|
1360
|
+
failure: 'git initialization command failed'
|
|
1361
|
+
});
|
|
1362
|
+
throw new CapabilityError('The code-session git initialization command failed.', 'UNAVAILABLE', {
|
|
1363
|
+
reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: init.exitCode }
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
await this.writeOperationRecord(operation, { stage: 'source-ready', failure: undefined });
|
|
1368
|
+
}
|
|
1369
|
+
const setupScript = input.runtimeConfig?.setupScript?.trim();
|
|
1370
|
+
if (setupScript && operation.record.stage === 'source-ready') {
|
|
1371
|
+
await this.writeOperationRecord(operation, { stage: 'setup-intent' });
|
|
1372
|
+
await this.writeOperationRecord(operation, { stage: 'setup-running' });
|
|
1373
|
+
const result = await this.exec(stagingPath, ['sh', '-lc', setupScript], { cwd: stagingPath, env: input.initialEnv }, hostTarget);
|
|
1374
|
+
if (result.exitCode !== 0) {
|
|
1375
|
+
await this.writeOperationRecord(operation, {
|
|
1376
|
+
stage: 'attention-required',
|
|
1377
|
+
failure: 'setup command failed'
|
|
1378
|
+
});
|
|
1379
|
+
throw new CapabilityError('The code-session setup command failed.', 'UNAVAILABLE', {
|
|
1380
|
+
reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: result.exitCode }
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
await this.writeOperationRecord(operation, { stage: 'setup-succeeded', failure: undefined });
|
|
1384
|
+
}
|
|
1385
|
+
else if (!setupScript && operation.record.stage === 'source-ready') {
|
|
1386
|
+
await this.writeOperationRecord(operation, { stage: 'setup-succeeded', failure: undefined });
|
|
1387
|
+
}
|
|
1388
|
+
const meta = {
|
|
1389
|
+
sessionId,
|
|
1390
|
+
operationId: record.operationId,
|
|
1391
|
+
operationFingerprint: record.fingerprint,
|
|
1392
|
+
workspacePath,
|
|
1393
|
+
workspaceOwnership,
|
|
1394
|
+
createdAt: record.createdAt,
|
|
1395
|
+
hostTarget,
|
|
1396
|
+
repositoryId: input.repositoryId,
|
|
1397
|
+
cloneUrl: input.cloneUrl,
|
|
1398
|
+
checkoutRef: input.checkoutRef,
|
|
1399
|
+
exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts],
|
|
1400
|
+
ephemeral: input.ephemeral
|
|
1401
|
+
};
|
|
1402
|
+
await this.persistSessionMetadata(meta, workspaceOwnership === 'owned' ? stagingPath : undefined);
|
|
1403
|
+
if (workspaceOwnership === 'owned') {
|
|
1404
|
+
if (await fs.exists(workspacePath)) {
|
|
1405
|
+
await this.writeOperationRecord(operation, {
|
|
1406
|
+
stage: 'attention-required',
|
|
1407
|
+
failure: 'the final workspace path already exists and was not proven to belong to this operation'
|
|
1408
|
+
});
|
|
1409
|
+
throw new CapabilityError('The final workspace path is already occupied.', 'CONFLICT', {
|
|
1410
|
+
reason: 'code-session-operation-conflict', retryable: false, details: { operationId: record.operationId }
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
await fs.rename(stagingPath, workspacePath);
|
|
1414
|
+
if (hostTarget.kind === 'host' && hostTarget.hostId !== 'local') {
|
|
1415
|
+
try {
|
|
1416
|
+
const published = await fs.readFile(this.metadataPath(fs, workspacePath), 'utf8');
|
|
1417
|
+
const verified = parseCodeSessionMetadata(utf8Text(published));
|
|
1418
|
+
if (!verified || verified.sessionId !== sessionId || verified.operationId !== meta.operationId || verified.operationFingerprint !== meta.operationFingerprint || verified.workspacePath !== workspacePath) {
|
|
1419
|
+
throw new CapabilityError('Remote session publication could not be verified.', 'CONFLICT', {
|
|
1420
|
+
reason: 'code-session-profile-conflict', retryable: false, details: { sessionId }
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
await this.persistRemotePointer(meta);
|
|
1424
|
+
}
|
|
1425
|
+
catch (caught) {
|
|
1426
|
+
await this.writeOperationRecord(operation, { stage: 'attention-required', failure: 'Remote workspace published; local discovery publication failed. Preserve the remote workspace for explicit recovery.' });
|
|
1427
|
+
throw new CapabilityError('Remote session control publication failed; explicit recovery is required.', 'UNAVAILABLE', {
|
|
1428
|
+
reason: 'code-session-host-unavailable',
|
|
1429
|
+
retryable: false,
|
|
1430
|
+
cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
await this.writeOperationRecord(operation, { stage: 'ready', failure: undefined });
|
|
1436
|
+
this.sessions.set(sessionId, meta);
|
|
1437
|
+
this.background.set(sessionId, []);
|
|
1438
|
+
this.log.info?.('code session initialized', {
|
|
1439
|
+
sessionId,
|
|
1440
|
+
operationId: record.operationId,
|
|
1441
|
+
workspacePath,
|
|
1442
|
+
hostKind: hostTarget.kind,
|
|
1443
|
+
repositoryId: input.repositoryId
|
|
1444
|
+
});
|
|
1445
|
+
return this.toHandle(meta);
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
async attach(sessionId) {
|
|
1449
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1450
|
+
this.requireTrust();
|
|
1451
|
+
requireSafeSessionId(sessionId);
|
|
1452
|
+
const meta = this.sessions.get(sessionId) ?? (await this.loadPersistedSessionMeta(sessionId));
|
|
1453
|
+
if (!meta) {
|
|
1454
|
+
throw new CapabilityError('The requested code session was not found.', 'NOT_FOUND', {
|
|
1455
|
+
reason: 'code-session-workspace-not-found', retryable: false, details: { sessionId }
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
return this.toHandle(await this.requireReadySession(meta));
|
|
1459
|
+
}
|
|
1460
|
+
async discover() {
|
|
1461
|
+
this.requireTrust();
|
|
1462
|
+
const fs = await this.filesystem();
|
|
1463
|
+
const sessionRoot = this.sessionRoot(fs);
|
|
1464
|
+
if (!(await fs.exists(sessionRoot)))
|
|
1465
|
+
return [];
|
|
1466
|
+
const ids = new Set();
|
|
1467
|
+
for (const entry of await fs.list(sessionRoot)) {
|
|
1468
|
+
if (entry.kind === 'directory' && isSafeSessionId(entry.name))
|
|
1469
|
+
ids.add(entry.name);
|
|
1470
|
+
}
|
|
1471
|
+
const borrowedRoot = fs.join(sessionRoot, '.metadata');
|
|
1472
|
+
if (await fs.exists(borrowedRoot)) {
|
|
1473
|
+
for (const entry of await fs.list(borrowedRoot)) {
|
|
1474
|
+
if (entry.kind === 'file' && entry.name.endsWith('.json')) {
|
|
1475
|
+
const sessionId = entry.name.slice(0, -'.json'.length);
|
|
1476
|
+
if (isSafeSessionId(sessionId))
|
|
1477
|
+
ids.add(sessionId);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
const handles = await Promise.all([...ids].map(async (sessionId) => {
|
|
1482
|
+
const meta = await this.loadPersistedSessionMeta(sessionId, false, false);
|
|
1483
|
+
return meta ? this.toHandle(meta) : null;
|
|
1484
|
+
}));
|
|
1485
|
+
return handles
|
|
1486
|
+
.filter((handle) => handle !== null)
|
|
1487
|
+
.sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
|
|
1488
|
+
}
|
|
1489
|
+
async runCommand(sessionId, input) {
|
|
1490
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1491
|
+
input = parseRunCommandInput(input);
|
|
1492
|
+
const meta = await this.ensureSession(sessionId);
|
|
1493
|
+
if (meta.pausedAt)
|
|
1494
|
+
throw sessionPaused(sessionId);
|
|
1495
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
1496
|
+
const cwd = input.cwd
|
|
1497
|
+
? resolveInWorkspace(fs, meta.workspacePath, input.cwd)
|
|
1498
|
+
: meta.workspacePath;
|
|
1499
|
+
if (input.signal?.aborted) {
|
|
1500
|
+
return {
|
|
1501
|
+
exitCode: null,
|
|
1502
|
+
stdout: '',
|
|
1503
|
+
stderr: '[code-session] stopped before spawn',
|
|
1504
|
+
timedOut: false,
|
|
1505
|
+
durationMs: 0,
|
|
1506
|
+
aborted: true
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
if (input.background) {
|
|
1510
|
+
// Background mode needs a LONG-LIVED handle, so it uses spawn() (not
|
|
1511
|
+
// execute()): the ProcessHandle stays live in `this.background`,
|
|
1512
|
+
// `running` is tracked off `proc.exited`, and teardown() calls
|
|
1513
|
+
// `proc.kill()`. This is the path that proves the spawn() mode must
|
|
1514
|
+
// exist — execute()+session() alone has nowhere to land it.
|
|
1515
|
+
const procId = crypto.randomUUID();
|
|
1516
|
+
const [bin, ...args] = input.command;
|
|
1517
|
+
const proc = await this.spawnBackground(meta.workspacePath, meta.hostTarget, {
|
|
1518
|
+
command: bin,
|
|
1519
|
+
args,
|
|
1520
|
+
cwd,
|
|
1521
|
+
stdout: 'pipe',
|
|
1522
|
+
stderr: 'pipe',
|
|
1523
|
+
// Stop and teardown take the background command's whole tree.
|
|
1524
|
+
killTree: true
|
|
1525
|
+
}, input.env);
|
|
1526
|
+
const entry = {
|
|
1527
|
+
procId,
|
|
1528
|
+
proc,
|
|
1529
|
+
running: true,
|
|
1530
|
+
command: input.command,
|
|
1531
|
+
startedAt: this.nowIso(),
|
|
1532
|
+
output: [],
|
|
1533
|
+
produced: 0,
|
|
1534
|
+
drains: []
|
|
1535
|
+
};
|
|
1536
|
+
if (input.cwd)
|
|
1537
|
+
entry.cwd = input.cwd;
|
|
1538
|
+
// Piped output is always read: an undrained pipe blocks a chatty process once its buffer fills.
|
|
1539
|
+
this.drainBackground(sessionId, entry, 'stdout', proc.stdout);
|
|
1540
|
+
this.drainBackground(sessionId, entry, 'stderr', proc.stderr);
|
|
1541
|
+
void proc.exited.then(({ code }) => {
|
|
1542
|
+
entry.running = false;
|
|
1543
|
+
entry.exitCode = code;
|
|
1544
|
+
}, (caught) => {
|
|
1545
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
1546
|
+
entry.running = null;
|
|
1547
|
+
this.log.warn('background process exit could not be observed', { sessionId, procId, failure });
|
|
1548
|
+
return failure;
|
|
1549
|
+
});
|
|
1550
|
+
this.background.get(sessionId)?.push(entry);
|
|
1551
|
+
this.log.info?.('background process spawned', { sessionId, procId, pid: proc.pid });
|
|
1552
|
+
return {
|
|
1553
|
+
exitCode: null,
|
|
1554
|
+
stdout: '',
|
|
1555
|
+
stderr: '',
|
|
1556
|
+
timedOut: false,
|
|
1557
|
+
durationMs: 0,
|
|
1558
|
+
procId,
|
|
1559
|
+
running: true
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
return this.exec(meta.workspacePath, input.command, { cwd, env: input.env, timeoutMs: input.timeoutMs, signal: input.signal }, meta.hostTarget);
|
|
1563
|
+
}
|
|
1564
|
+
async listBackgroundProcesses(sessionId) {
|
|
1565
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1566
|
+
await this.ensureSession(sessionId);
|
|
1567
|
+
return (this.background.get(sessionId) ?? []).map((entry) => {
|
|
1568
|
+
const info = {
|
|
1569
|
+
procId: entry.procId,
|
|
1570
|
+
command: entry.command,
|
|
1571
|
+
running: entry.running,
|
|
1572
|
+
startedAt: entry.startedAt,
|
|
1573
|
+
pid: entry.proc.pid
|
|
1574
|
+
};
|
|
1575
|
+
if (entry.cwd)
|
|
1576
|
+
info.cwd = entry.cwd;
|
|
1577
|
+
if (entry.running === false)
|
|
1578
|
+
info.exitCode = entry.exitCode ?? null;
|
|
1579
|
+
return info;
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Keep one background stream's chunks in the entry's ring until the stream
|
|
1584
|
+
* ends. Disposal joins owned work before plugins tear sessions down, so the
|
|
1585
|
+
* read stops when disposal begins instead of holding it for a live process.
|
|
1586
|
+
*/
|
|
1587
|
+
drainBackground(sessionId, entry, stream, source) {
|
|
1588
|
+
if (source === null)
|
|
1589
|
+
return;
|
|
1590
|
+
const reader = source.getReader();
|
|
1591
|
+
const lifecycle = this.runtime.lifecycle;
|
|
1592
|
+
const drained = settle(() => lifecycle.own(async () => {
|
|
1593
|
+
const stop = () => void settle(() => reader.cancel());
|
|
1594
|
+
lifecycle.signal.addEventListener('abort', stop, { once: true });
|
|
1595
|
+
try {
|
|
1596
|
+
for (;;) {
|
|
1597
|
+
const next = await reader.read();
|
|
1598
|
+
if (next.done)
|
|
1599
|
+
return;
|
|
1600
|
+
entry.produced += 1;
|
|
1601
|
+
entry.output.push({ stream, bytes: next.value });
|
|
1602
|
+
if (entry.output.length > MAX_BACKGROUND_CHUNKS)
|
|
1603
|
+
entry.output.shift();
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
lifecycle.signal.removeEventListener('abort', stop);
|
|
1608
|
+
}
|
|
1609
|
+
})).then((read) => {
|
|
1610
|
+
if (read.ok)
|
|
1611
|
+
return;
|
|
1612
|
+
this.log.warn('background process output stopped being read', {
|
|
1613
|
+
sessionId,
|
|
1614
|
+
procId: entry.procId,
|
|
1615
|
+
failure: valueFreeFailure(read.cause)
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
entry.drains.push(drained);
|
|
1619
|
+
}
|
|
1620
|
+
async backgroundEntry(sessionId, procId) {
|
|
1621
|
+
await this.ensureSession(sessionId);
|
|
1622
|
+
const entry = (this.background.get(sessionId) ?? []).find((candidate) => candidate.procId === procId);
|
|
1623
|
+
if (!entry) {
|
|
1624
|
+
throw new CapabilityError('The background process is not known in this session.', 'NOT_FOUND', {
|
|
1625
|
+
reason: 'code-session-process-not-found', retryable: false, details: { procId }
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
return entry;
|
|
1629
|
+
}
|
|
1630
|
+
async readBackgroundProcess(sessionId, procId, input) {
|
|
1631
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1632
|
+
procId = parseProcessId(procId);
|
|
1633
|
+
const { since } = parseBackgroundProcessReadInput(input ?? {});
|
|
1634
|
+
return backgroundOutputSince(await this.backgroundEntry(sessionId, procId), since, false);
|
|
1635
|
+
}
|
|
1636
|
+
async waitBackgroundProcess(sessionId, procId, input) {
|
|
1637
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1638
|
+
procId = parseProcessId(procId);
|
|
1639
|
+
const { since, timeoutMs } = parseBackgroundProcessWaitInput(input);
|
|
1640
|
+
const entry = await this.backgroundEntry(sessionId, procId);
|
|
1641
|
+
const waited = await settle(() => this.runtime.lifecycle.wait(async () => entry.proc.exited, {
|
|
1642
|
+
timeoutMs,
|
|
1643
|
+
reason: 'code-session background wait'
|
|
1644
|
+
}));
|
|
1645
|
+
if (!waited.ok && !isBackgroundWaitTimeout(waited.cause))
|
|
1646
|
+
throw waited.cause;
|
|
1647
|
+
if (waited.ok) {
|
|
1648
|
+
entry.running = false;
|
|
1649
|
+
entry.exitCode = waited.value.code;
|
|
1650
|
+
// An ended process's last output is still in its pipes: answer once both are read. A
|
|
1651
|
+
// descendant can hold a pipe open past the exit, so the join is bounded.
|
|
1652
|
+
const joined = await settle(() => this.runtime.lifecycle.wait(async () => {
|
|
1653
|
+
await Promise.all(entry.drains);
|
|
1654
|
+
}, { timeoutMs: BACKGROUND_DRAIN_JOIN_MS, reason: 'code-session background output' }));
|
|
1655
|
+
if (!joined.ok && !isBackgroundWaitTimeout(joined.cause))
|
|
1656
|
+
throw joined.cause;
|
|
1657
|
+
}
|
|
1658
|
+
return backgroundOutputSince(entry, since, !waited.ok);
|
|
1659
|
+
}
|
|
1660
|
+
async stopBackgroundProcess(sessionId, procId, signal) {
|
|
1661
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1662
|
+
procId = parseProcessId(procId);
|
|
1663
|
+
signal = parseProcessSignal(signal);
|
|
1664
|
+
await this.ensureSession(sessionId);
|
|
1665
|
+
const entry = (this.background.get(sessionId) ?? []).find((candidate) => candidate.procId === procId);
|
|
1666
|
+
if (!entry)
|
|
1667
|
+
return false;
|
|
1668
|
+
if (entry.running === false)
|
|
1669
|
+
return true;
|
|
1670
|
+
if (entry.running === null) {
|
|
1671
|
+
throw new CapabilityError('Background process liveness is unknown.', 'UNAVAILABLE', {
|
|
1672
|
+
reason: 'code-session-cleanup-unconfirmed', retryable: false, details: { procId }
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
try {
|
|
1676
|
+
await entry.proc.kill(signal ?? 'SIGTERM');
|
|
1677
|
+
}
|
|
1678
|
+
catch (caught) {
|
|
1679
|
+
entry.running = null;
|
|
1680
|
+
throw new CapabilityError('Background process signal submission failed.', 'UNAVAILABLE', {
|
|
1681
|
+
reason: 'code-session-cleanup-unconfirmed',
|
|
1682
|
+
retryable: false,
|
|
1683
|
+
details: { procId },
|
|
1684
|
+
cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
async pause(sessionId) {
|
|
1690
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1691
|
+
const meta = await this.ensureSession(sessionId);
|
|
1692
|
+
if (meta.pausedAt)
|
|
1693
|
+
return this.toHandle(meta);
|
|
1694
|
+
for (const entry of this.background.get(sessionId) ?? []) {
|
|
1695
|
+
if (entry.running !== false)
|
|
1696
|
+
await this.stopBackgroundProcess(sessionId, entry.procId);
|
|
1697
|
+
}
|
|
1698
|
+
meta.pausedAt = this.nowIso();
|
|
1699
|
+
await this.persistSessionMetadata(meta);
|
|
1700
|
+
return this.toHandle(meta);
|
|
1701
|
+
}
|
|
1702
|
+
async resume(sessionId) {
|
|
1703
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1704
|
+
const meta = await this.ensureSession(sessionId);
|
|
1705
|
+
if (!meta.pausedAt)
|
|
1706
|
+
return this.toHandle(meta);
|
|
1707
|
+
meta.pausedAt = undefined;
|
|
1708
|
+
await this.persistSessionMetadata(meta);
|
|
1709
|
+
return this.attach(sessionId);
|
|
1710
|
+
}
|
|
1711
|
+
async readFile(sessionId, path) {
|
|
1712
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1713
|
+
path = parseCodeSessionPath(path);
|
|
1714
|
+
const meta = await this.ensureSession(sessionId);
|
|
1715
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
1716
|
+
const abs = resolveInWorkspace(fs, meta.workspacePath, path);
|
|
1717
|
+
const contents = await fs.readFile(abs, 'utf8');
|
|
1718
|
+
return utf8Text(contents);
|
|
1719
|
+
}
|
|
1720
|
+
async writeFile(sessionId, path, content) {
|
|
1721
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1722
|
+
({ path, content } = parseWriteFileInput(path, content));
|
|
1723
|
+
const meta = await this.ensureSession(sessionId);
|
|
1724
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
1725
|
+
const abs = resolveInWorkspace(fs, meta.workspacePath, path);
|
|
1726
|
+
await fs.mkdir(fs.dirname(abs), { recursive: true });
|
|
1727
|
+
await fs.writeFile(abs, content, 'utf8');
|
|
1728
|
+
}
|
|
1729
|
+
async patchFile(sessionId, input) {
|
|
1730
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1731
|
+
input = parsePatchFileInput(input);
|
|
1732
|
+
const meta = await this.ensureSession(sessionId);
|
|
1733
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
1734
|
+
// Validate the target stays in-workspace before applying.
|
|
1735
|
+
resolveInWorkspace(fs, meta.workspacePath, input.path);
|
|
1736
|
+
// `git apply` is the safe, atomic diff applier; write the diff to a temp
|
|
1737
|
+
// file in the workspace then apply it relative to the workspace root.
|
|
1738
|
+
const patchPath = resolveInWorkspace(fs, meta.workspacePath, `.vibes-patch-${crypto.randomUUID()}.diff`);
|
|
1739
|
+
await fs.writeFile(patchPath, input.diff, 'utf8');
|
|
1740
|
+
try {
|
|
1741
|
+
const result = await this.exec(meta.workspacePath, ['git', 'apply', patchPath], {}, meta.hostTarget);
|
|
1742
|
+
if (result.exitCode !== 0) {
|
|
1743
|
+
throw new CapabilityError('The code-session patch command failed.', 'INVALID_INPUT', {
|
|
1744
|
+
reason: 'code-session-request-invalid', retryable: false, details: { exitCode: result.exitCode }
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
finally {
|
|
1749
|
+
await fs.remove(patchPath);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
async gitApply(sessionId, input) {
|
|
1753
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1754
|
+
input = parseGitApplyInput(input);
|
|
1755
|
+
const meta = await this.ensureSession(sessionId);
|
|
1756
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
1757
|
+
// Write diff to a temp file in the workspace (git apply requires a file path).
|
|
1758
|
+
const patchPath = resolveInWorkspace(fs, meta.workspacePath, `.vibes-patch-${crypto.randomUUID()}.diff`);
|
|
1759
|
+
await fs.writeFile(patchPath, input.diff, 'utf8');
|
|
1760
|
+
try {
|
|
1761
|
+
const args = input.check
|
|
1762
|
+
? ['git', 'apply', '--check', patchPath]
|
|
1763
|
+
: ['git', 'apply', patchPath];
|
|
1764
|
+
const result = await this.exec(meta.workspacePath, args, {}, meta.hostTarget);
|
|
1765
|
+
return {
|
|
1766
|
+
ok: result.exitCode === 0,
|
|
1767
|
+
stdout: result.stdout,
|
|
1768
|
+
stderr: result.stderr,
|
|
1769
|
+
exitCode: result.exitCode
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
finally {
|
|
1773
|
+
await fs.remove(patchPath);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
async gitStatus(sessionId) {
|
|
1777
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1778
|
+
const meta = await this.ensureSession(sessionId);
|
|
1779
|
+
const [porcelain, branch] = await Promise.all([
|
|
1780
|
+
this.exec(meta.workspacePath, ['git', '--no-optional-locks', 'status', '--porcelain'], {}, meta.hostTarget),
|
|
1781
|
+
this.exec(meta.workspacePath, ['git', '--no-optional-locks', 'rev-parse', '--abbrev-ref', 'HEAD'], {}, meta.hostTarget)
|
|
1782
|
+
]);
|
|
1783
|
+
if (porcelain.exitCode !== 0) {
|
|
1784
|
+
throw new CapabilityError('The code-session git status command failed.', 'UNAVAILABLE', {
|
|
1785
|
+
reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: porcelain.exitCode }
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
const staged = [];
|
|
1789
|
+
const unstaged = [];
|
|
1790
|
+
const untracked = [];
|
|
1791
|
+
for (const line of porcelain.stdout.split('\n')) {
|
|
1792
|
+
if (!line.trim())
|
|
1793
|
+
continue;
|
|
1794
|
+
const x = line[0];
|
|
1795
|
+
const y = line[1];
|
|
1796
|
+
const file = line.slice(3);
|
|
1797
|
+
if (x === '?' && y === '?')
|
|
1798
|
+
untracked.push(file);
|
|
1799
|
+
else {
|
|
1800
|
+
if (x && x !== ' ')
|
|
1801
|
+
staged.push(file);
|
|
1802
|
+
if (y && y !== ' ')
|
|
1803
|
+
unstaged.push(file);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
return { staged, unstaged, untracked, branch: branch.stdout.trim() || 'HEAD' };
|
|
1807
|
+
}
|
|
1808
|
+
async withGitRepoLock(repoRoot, fn, signal) {
|
|
1809
|
+
const previous = this.gitCommitQueues.get(repoRoot);
|
|
1810
|
+
const run = async () => {
|
|
1811
|
+
if (previous)
|
|
1812
|
+
await settle(() => previous);
|
|
1813
|
+
signal?.throwIfAborted();
|
|
1814
|
+
return fn();
|
|
1815
|
+
};
|
|
1816
|
+
const current = run();
|
|
1817
|
+
const tracked = settle(() => current);
|
|
1818
|
+
this.gitCommitQueues.set(repoRoot, tracked);
|
|
1819
|
+
try {
|
|
1820
|
+
return await current;
|
|
1821
|
+
}
|
|
1822
|
+
finally {
|
|
1823
|
+
if (this.gitCommitQueues.get(repoRoot) === tracked) {
|
|
1824
|
+
this.gitCommitQueues.delete(repoRoot);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
async resolveGitRootForPaths(meta, paths, signal) {
|
|
1829
|
+
signal?.throwIfAborted();
|
|
1830
|
+
if (!paths || paths.length === 0) {
|
|
1831
|
+
return { gitRoot: meta.workspacePath, relPaths: [] };
|
|
1832
|
+
}
|
|
1833
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
1834
|
+
signal?.throwIfAborted();
|
|
1835
|
+
const rootNorm = fs.normalize(meta.workspacePath);
|
|
1836
|
+
const resolved = await Promise.all(paths.map(async (rawPath) => {
|
|
1837
|
+
const abs = fs.normalize(resolveInWorkspace(fs, rootNorm, rawPath));
|
|
1838
|
+
let current = abs;
|
|
1839
|
+
while (current.startsWith(rootNorm) && current.length >= rootNorm.length) {
|
|
1840
|
+
signal?.throwIfAborted();
|
|
1841
|
+
const isRepo = await fs.exists(fs.join(current, '.git'));
|
|
1842
|
+
signal?.throwIfAborted();
|
|
1843
|
+
if (isRepo) {
|
|
1844
|
+
return { abs, gitRoot: current };
|
|
1845
|
+
}
|
|
1846
|
+
const parent = fs.normalize(fs.dirname(current));
|
|
1847
|
+
if (parent === current)
|
|
1848
|
+
break;
|
|
1849
|
+
current = parent;
|
|
1850
|
+
}
|
|
1851
|
+
return { abs, gitRoot: rootNorm };
|
|
1852
|
+
}));
|
|
1853
|
+
const firstRoot = resolved[0].gitRoot;
|
|
1854
|
+
const commonRoot = resolved.every((entry) => entry.gitRoot === firstRoot) ? firstRoot : rootNorm;
|
|
1855
|
+
const relPaths = resolved.map(({ abs }) => {
|
|
1856
|
+
if (abs === commonRoot)
|
|
1857
|
+
return '.';
|
|
1858
|
+
const prefix = commonRoot.endsWith('/') ? commonRoot : `${commonRoot}/`;
|
|
1859
|
+
return abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
|
|
1860
|
+
});
|
|
1861
|
+
return { gitRoot: commonRoot, relPaths };
|
|
1862
|
+
}
|
|
1863
|
+
async gitCommit(sessionId, input, options = {}) {
|
|
1864
|
+
const { signal } = parseGitCommitOptions(options);
|
|
1865
|
+
signal?.throwIfAborted();
|
|
1866
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1867
|
+
input = parseGitCommitInput(input);
|
|
1868
|
+
const meta = await this.ensureSession(sessionId);
|
|
1869
|
+
signal?.throwIfAborted();
|
|
1870
|
+
const { gitRoot, relPaths } = await this.resolveGitRootForPaths(meta, input.paths, signal);
|
|
1871
|
+
const hasExplicitPaths = relPaths.length > 0 && !relPaths.includes('.');
|
|
1872
|
+
return this.withGitRepoLock(gitRoot, async () => {
|
|
1873
|
+
const runStage = async (operation, command) => {
|
|
1874
|
+
signal?.throwIfAborted();
|
|
1875
|
+
const outcome = await settle(() => this.exec(gitRoot, command, { cwd: gitRoot, signal, abortKillSignal: 'SIGTERM' }, meta.hostTarget));
|
|
1876
|
+
if (!outcome.ok || outcome.value.exitCode !== 0) {
|
|
1877
|
+
throw new CapabilityError('The code-session Git command failed.', 'UNAVAILABLE', {
|
|
1878
|
+
reason: 'code-session-host-unavailable',
|
|
1879
|
+
retryable: false,
|
|
1880
|
+
details: { operation, exitCode: outcome.ok ? outcome.value.exitCode : null }
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
return outcome.value;
|
|
1884
|
+
};
|
|
1885
|
+
let stagedByHelper = false;
|
|
1886
|
+
if (hasExplicitPaths) {
|
|
1887
|
+
await runStage('git-add', ['git', 'add', '-A', '--', ...relPaths]);
|
|
1888
|
+
stagedByHelper = true;
|
|
1889
|
+
}
|
|
1890
|
+
else {
|
|
1891
|
+
const stagedCheck = await runStage('git-staged-inspection', [
|
|
1892
|
+
'git', '--no-optional-locks', 'diff', '--cached', '--name-only'
|
|
1893
|
+
]);
|
|
1894
|
+
if (stagedCheck.stdout.trim().length === 0) {
|
|
1895
|
+
const addArgs = relPaths.length > 0 ? ['git', 'add', '-A', '--', ...relPaths] : ['git', 'add', '-A'];
|
|
1896
|
+
await runStage('git-add', addArgs);
|
|
1897
|
+
stagedByHelper = true;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
const commitArgs = ['git', '-c', 'commit.gpgsign=false'];
|
|
1901
|
+
if (input.author) {
|
|
1902
|
+
const authorMatch = /^([^<>]+?)\s*<([^<>]+)>$/.exec(input.author.trim());
|
|
1903
|
+
if (authorMatch) {
|
|
1904
|
+
commitArgs.push('-c', `user.name=${authorMatch[1].trim()}`, '-c', `user.email=${authorMatch[2].trim()}`);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
commitArgs.push('commit', '-m', input.message);
|
|
1908
|
+
if (input.author)
|
|
1909
|
+
commitArgs.push('--author', input.author);
|
|
1910
|
+
if (hasExplicitPaths) {
|
|
1911
|
+
commitArgs.push('--', ...relPaths);
|
|
1912
|
+
}
|
|
1913
|
+
try {
|
|
1914
|
+
await runStage('git-commit', commitArgs);
|
|
1915
|
+
}
|
|
1916
|
+
catch (failure) {
|
|
1917
|
+
if (stagedByHelper) {
|
|
1918
|
+
const resetArgs = hasExplicitPaths
|
|
1919
|
+
? ['git', 'reset', '-q', 'HEAD', '--', ...relPaths]
|
|
1920
|
+
: ['git', 'reset', '-q', 'HEAD'];
|
|
1921
|
+
// Reset is cleanup of this attempt, independent of its cancelled foreground lifetime.
|
|
1922
|
+
const reset = await settle(() => this.exec(gitRoot, resetArgs, { cwd: gitRoot }, meta.hostTarget));
|
|
1923
|
+
if (!reset.ok || reset.value.exitCode !== 0) {
|
|
1924
|
+
throw new CapabilityError('Code-session Git cleanup is unconfirmed.', 'UNAVAILABLE', {
|
|
1925
|
+
reason: 'code-session-cleanup-unconfirmed', retryable: false,
|
|
1926
|
+
details: { operation: 'git-reset', exitCode: reset.ok ? reset.value.exitCode : null },
|
|
1927
|
+
cause: failure
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
throw failure;
|
|
1932
|
+
}
|
|
1933
|
+
const hash = await runStage('git-hash-lookup', [
|
|
1934
|
+
'git', '--no-optional-locks', 'rev-parse', 'HEAD'
|
|
1935
|
+
]);
|
|
1936
|
+
return { hash: hash.stdout.trim(), message: input.message };
|
|
1937
|
+
}, signal);
|
|
1938
|
+
}
|
|
1939
|
+
async gitPush(sessionId, input = {}) {
|
|
1940
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1941
|
+
input = parseGitPushInput(input);
|
|
1942
|
+
const meta = await this.ensureSession(sessionId);
|
|
1943
|
+
// Resolve the push credential: explicit > host resolver (by repositoryId) >
|
|
1944
|
+
// none (ambient workspace auth). The token never lands in argv / the URL /
|
|
1945
|
+
// `.git/config` — it is fed via a credential helper from the CHILD env only.
|
|
1946
|
+
let credential = input.credential;
|
|
1947
|
+
if (!credential && this.pushCredentialResolver && meta.repositoryId) {
|
|
1948
|
+
credential = (await this.pushCredentialResolver(meta.repositoryId)) ?? undefined;
|
|
1949
|
+
}
|
|
1950
|
+
const cred = codeSessionCredentialArgs(credential);
|
|
1951
|
+
const args = ['git', ...cred.args, 'push'];
|
|
1952
|
+
if (input.setUpstream)
|
|
1953
|
+
args.push('-u');
|
|
1954
|
+
if (input.remote)
|
|
1955
|
+
args.push(input.remote);
|
|
1956
|
+
if (input.branch)
|
|
1957
|
+
args.push(input.branch);
|
|
1958
|
+
const pushOptions = {};
|
|
1959
|
+
if (Object.keys(cred.env).length > 0)
|
|
1960
|
+
pushOptions.env = cred.env;
|
|
1961
|
+
const result = await this.exec(meta.workspacePath, args, pushOptions, meta.hostTarget);
|
|
1962
|
+
if (result.exitCode !== 0) {
|
|
1963
|
+
throw new CapabilityError('The code-session git push command failed.', 'UNAVAILABLE', {
|
|
1964
|
+
reason: 'code-session-host-unavailable', retryable: false, details: { exitCode: result.exitCode }
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
async exposePort(sessionId, input) {
|
|
1969
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1970
|
+
input = parseExposePortInput(input);
|
|
1971
|
+
const meta = await this.ensureSession(sessionId);
|
|
1972
|
+
if (!meta.exposedPorts.includes(input.port))
|
|
1973
|
+
meta.exposedPorts.push(input.port);
|
|
1974
|
+
// On a local Host the preview is reachable on localhost. A future remote
|
|
1975
|
+
// Host daemon would publish a tunneled URL here.
|
|
1976
|
+
const previewUrl = `http://localhost:${input.port}`;
|
|
1977
|
+
meta.previewUrl = previewUrl;
|
|
1978
|
+
await this.persistSessionMetadata(meta);
|
|
1979
|
+
return { port: input.port, previewUrl };
|
|
1980
|
+
}
|
|
1981
|
+
async teardown(sessionId) {
|
|
1982
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1983
|
+
requireSafeSessionId(sessionId);
|
|
1984
|
+
const known = this.sessions.get(sessionId);
|
|
1985
|
+
const meta = known?.workspaceOwnership === 'owned' && known.hostTarget.kind === 'host' && known.hostTarget.hostId !== 'local'
|
|
1986
|
+
? await this.loadPersistedSessionMeta(sessionId, true)
|
|
1987
|
+
: known ?? await this.loadPersistedSessionMeta(sessionId, true);
|
|
1988
|
+
if (!meta)
|
|
1989
|
+
return;
|
|
1990
|
+
const controlFs = await this.filesystem();
|
|
1991
|
+
const key = meta.operationId ? await codeSessionOperationKey(meta.operationId) : undefined;
|
|
1992
|
+
const record = key ? await this.readOperationRecord(controlFs, key) : null;
|
|
1993
|
+
const operation = key && record ? { key, record, fs: controlFs } : undefined;
|
|
1994
|
+
const cleanup = async () => {
|
|
1995
|
+
const current = operation?.record ?? record;
|
|
1996
|
+
const resumingCleanup = current && (current.stage === 'cleanup-pending' || current.stage === 'torn-down') &&
|
|
1997
|
+
current.sessionId === sessionId && current.fingerprint === meta.operationFingerprint;
|
|
1998
|
+
if (!resumingCleanup)
|
|
1999
|
+
await this.requireReadySession(meta);
|
|
2000
|
+
for (const entry of this.background.get(sessionId) ?? []) {
|
|
2001
|
+
if (entry.running === false)
|
|
2002
|
+
continue;
|
|
2003
|
+
if (entry.running === null) {
|
|
2004
|
+
throw new CapabilityError('Background process liveness is unknown.', 'UNAVAILABLE', {
|
|
2005
|
+
reason: 'code-session-cleanup-unconfirmed', retryable: false, details: { procId: entry.procId }
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
try {
|
|
2009
|
+
// The process's own exit is the owner event; the wait only bounds it.
|
|
2010
|
+
await this.runtime.lifecycle.wait(async () => {
|
|
2011
|
+
await entry.proc.kill('SIGTERM');
|
|
2012
|
+
await entry.proc.exited;
|
|
2013
|
+
}, { timeoutMs: 1_000, reason: 'code-session-background-exit' });
|
|
2014
|
+
}
|
|
2015
|
+
catch (caught) {
|
|
2016
|
+
entry.running = null;
|
|
2017
|
+
throw new CapabilityError('Background process cleanup is unconfirmed.', 'UNAVAILABLE', {
|
|
2018
|
+
reason: 'code-session-cleanup-unconfirmed',
|
|
2019
|
+
retryable: false,
|
|
2020
|
+
details: { procId: entry.procId },
|
|
2021
|
+
cause: valueFreeFailure(CaughtFailureSchema.parse(caught))
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
if (operation)
|
|
2026
|
+
await this.writeOperationRecord(operation, { stage: 'cleanup-pending', failure: undefined });
|
|
2027
|
+
if (meta.workspaceOwnership === 'owned') {
|
|
2028
|
+
const fs = await this.workspaceFilesystem(meta.hostTarget);
|
|
2029
|
+
assertOwnedWorkspacePath(fs, await this.targetSessionRoot(fs, meta.hostTarget), sessionId, meta.workspacePath);
|
|
2030
|
+
if (meta.hostTarget.kind === 'host' && meta.hostTarget.hostId !== 'local') {
|
|
2031
|
+
if (!operation)
|
|
2032
|
+
throw new CapabilityError('Remote owned cleanup requires its durable operation', 'CONFLICT', { retryable: false });
|
|
2033
|
+
const root = await this.targetSessionRoot(fs, meta.hostTarget);
|
|
2034
|
+
let cleanup = operation.record.cleanup;
|
|
2035
|
+
if (!cleanup) {
|
|
2036
|
+
const cleanupRoot = fs.join(root, '.cleanup', operation.key);
|
|
2037
|
+
await fs.mkdir(cleanupRoot, { recursive: true });
|
|
2038
|
+
const allocation = fs.join(cleanupRoot, crypto.randomUUID());
|
|
2039
|
+
// Exclusive parent allocation keeps rename's destination private and absent.
|
|
2040
|
+
await fs.mkdir(allocation);
|
|
2041
|
+
cleanup = { path: fs.join(allocation, 'workspace'), phase: 'intent' };
|
|
2042
|
+
await this.writeOperationRecord(operation, { cleanup });
|
|
2043
|
+
}
|
|
2044
|
+
const quarantine = this.cleanupPath(fs, root, operation.key, cleanup);
|
|
2045
|
+
if (cleanup.phase === 'intent') {
|
|
2046
|
+
const [originalExists, quarantineExists] = await Promise.all([fs.exists(meta.workspacePath), fs.exists(quarantine)]);
|
|
2047
|
+
if (originalExists && quarantineExists)
|
|
2048
|
+
throw new CapabilityError('Remote cleanup rename is ambiguous; explicit recovery is required', 'CONFLICT', { retryable: false });
|
|
2049
|
+
if (originalExists)
|
|
2050
|
+
await fs.rename(meta.workspacePath, quarantine);
|
|
2051
|
+
if (originalExists || quarantineExists) {
|
|
2052
|
+
const content = await fs.readFile(this.metadataPath(fs, quarantine), 'utf8');
|
|
2053
|
+
const moved = parseCodeSessionMetadata(utf8Text(content));
|
|
2054
|
+
if (!moved || moved.sessionId !== sessionId || moved.operationId !== meta.operationId ||
|
|
2055
|
+
moved.operationFingerprint !== meta.operationFingerprint || moved.workspacePath !== meta.workspacePath ||
|
|
2056
|
+
moved.workspaceOwnership !== 'owned' || moved.hostTarget.kind !== 'host' || moved.hostTarget.hostId !== meta.hostTarget.hostId) {
|
|
2057
|
+
throw new CapabilityError('Quarantined workspace identity changed; deletion is not authorized', 'CONFLICT', { retryable: false });
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
cleanup = { ...cleanup, phase: quarantineExists || originalExists ? 'quarantined' : 'removed' };
|
|
2061
|
+
await this.writeOperationRecord(operation, { cleanup });
|
|
2062
|
+
}
|
|
2063
|
+
if (cleanup.phase === 'quarantined') {
|
|
2064
|
+
if (await fs.exists(quarantine))
|
|
2065
|
+
await fs.remove(quarantine, { recursive: true });
|
|
2066
|
+
cleanup = { ...cleanup, phase: 'removed' };
|
|
2067
|
+
await this.writeOperationRecord(operation, { cleanup });
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
else
|
|
2071
|
+
await fs.remove(meta.workspacePath, { recursive: true });
|
|
2072
|
+
}
|
|
2073
|
+
else {
|
|
2074
|
+
const fs = await this.filesystem();
|
|
2075
|
+
try {
|
|
2076
|
+
await fs.remove(this.borrowedMetadataPath(fs, sessionId));
|
|
2077
|
+
}
|
|
2078
|
+
catch (caught) {
|
|
2079
|
+
const parsed = CaughtFailureSchema.parse(caught);
|
|
2080
|
+
const failure = valueFreeFailure(parsed);
|
|
2081
|
+
if (failure.code !== 'ENOENT')
|
|
2082
|
+
throw CapabilityError.from(parsed);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
if (operation)
|
|
2086
|
+
await this.writeOperationRecord(operation, { stage: 'torn-down', failure: undefined });
|
|
2087
|
+
if (meta.workspaceOwnership === 'owned' && meta.hostTarget.kind === 'host' && meta.hostTarget.hostId !== 'local') {
|
|
2088
|
+
const pointer = this.borrowedMetadataPath(controlFs, sessionId);
|
|
2089
|
+
if (await controlFs.exists(pointer))
|
|
2090
|
+
await controlFs.remove(pointer);
|
|
2091
|
+
}
|
|
2092
|
+
this.background.delete(sessionId);
|
|
2093
|
+
this.sessions.delete(sessionId);
|
|
2094
|
+
this.log.info?.('code session torn down', { sessionId });
|
|
2095
|
+
};
|
|
2096
|
+
return operation ? this.withOperationLease(operation, cleanup, true) : cleanup();
|
|
2097
|
+
}
|
|
2098
|
+
toHandle(meta) {
|
|
2099
|
+
return {
|
|
2100
|
+
sessionId: meta.sessionId,
|
|
2101
|
+
operationId: meta.operationId,
|
|
2102
|
+
workspacePath: meta.workspacePath,
|
|
2103
|
+
workspaceOwnership: meta.workspaceOwnership,
|
|
2104
|
+
createdAt: meta.createdAt,
|
|
2105
|
+
previewUrl: meta.previewUrl,
|
|
2106
|
+
hostTarget: meta.hostTarget,
|
|
2107
|
+
repositoryId: meta.repositoryId,
|
|
2108
|
+
ephemeral: meta.ephemeral
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
/** A paused session refuses new commands until resumed. */
|
|
2113
|
+
function sessionPaused(sessionId) {
|
|
2114
|
+
return new CapabilityError('The code session is paused; resume it to run commands.', 'CONFLICT', {
|
|
2115
|
+
reason: 'code-session-paused',
|
|
2116
|
+
retryable: false,
|
|
2117
|
+
details: { sessionId }
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
class ConsumerCodeSessionImpl extends ConsumerCodeSession {
|
|
2121
|
+
constructor(descriptor, context) {
|
|
2122
|
+
super(AgentCodeSessionDescriptorSchema.parse(descriptor ?? consumerCodeSessionDescriptor), context.runtime);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
export default createRuntimeImplementation({
|
|
2126
|
+
kind: 'agent/code-session',
|
|
2127
|
+
id: consumerCodeSessionDescriptor.id,
|
|
2128
|
+
constraint: { hardware: ['consumer'] },
|
|
2129
|
+
implementation: ConsumerCodeSessionImpl
|
|
2130
|
+
});
|
|
2131
|
+
//# sourceMappingURL=agent-code-session.impl.consumer.js.map
|