@vibesdotdev/agent-execution 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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,1392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet-host Agent Code Session — relay impl.
|
|
3
|
+
*
|
|
4
|
+
* Implements `CodeSessionInterface` against an enrolled machine's fleet daemon,
|
|
5
|
+
* reached through the HostRelay broker: `connectRelay` → `wireConsumer` → the
|
|
6
|
+
* daemon root's `runtime/execution` kind (the daemon's `LocalExecution`).
|
|
7
|
+
*
|
|
8
|
+
* Operations use the existing invoke/stream relay wire and pass the daemon's
|
|
9
|
+
* L5 pipeline (authz + friction) — the machine grants remote execution only
|
|
10
|
+
* when its daemon runs with `--labels sandbox:code-execution`, and the platform
|
|
11
|
+
* route has already owner-checked + label-checked the host before this impl is
|
|
12
|
+
* resolved.
|
|
13
|
+
*
|
|
14
|
+
* Every dial acts for the invocation's user (`runtime.scope.actor.userId`) and
|
|
15
|
+
* names that user to the broker on every leg, which authorizes the user against
|
|
16
|
+
* the host. Dials are cached per host AND user, so one user never rides another
|
|
17
|
+
* user's authorized connection. An invocation without a user fails with
|
|
18
|
+
* `AUTH_REQUIRED` before anything is dialed. The reverse leg (callbacks) is deliberately NOT wired: v1 needs no
|
|
19
|
+
* daemon→consumer invokes, so no reverse SSE channel is ever opened.
|
|
20
|
+
*
|
|
21
|
+
* workerd-safe: imports ONLY Node-free subpaths — the interface/descriptor from
|
|
22
|
+
* `@vibesdotdev/agent-execution/code-session`, the consumer transport from
|
|
23
|
+
* `@vibesdotdev/kernel-rpc(-relay)`, and the runner-agnostic git core from
|
|
24
|
+
* `@vibesdotdev/git-operations/core`. Never the Node-only consumer impl.
|
|
25
|
+
*
|
|
26
|
+
* Session ids are self-describing: `rly.<hostId>.<random>`. hostIds are
|
|
27
|
+
* platform-validated kebab-case (`[a-z0-9-]{2,64}` — never a dot), so parsing
|
|
28
|
+
* the machine back out of a sessionId is unambiguous. That makes `attach`/
|
|
29
|
+
* `runCommand`/etc. work from a fresh isolate with no session→host lookup —
|
|
30
|
+
* the same move the cloudflare impl makes by using the sessionId as the
|
|
31
|
+
* Sandbox DO name.
|
|
32
|
+
*
|
|
33
|
+
* Workspace: `<activeHome>/code-sessions/<sessionId>` on the receiving machine,
|
|
34
|
+
* using that runtime's canonical path roots. POSIX hosts only for
|
|
35
|
+
* now (commands run via the daemon's `sh`/`git`/`base64`).
|
|
36
|
+
*/
|
|
37
|
+
import { getLogger } from '@vibesdotdev/logging';
|
|
38
|
+
import { AgentCodeSessionDescriptorSchema } from '@vibesdotdev/agent-execution/code-session/descriptor';
|
|
39
|
+
import { PersistedCodeSessionOperationSchema, codeSessionOperationFingerprint, codeSessionOperationKey, codeSessionTempName, parseCodeSessionCreateOrGetInput, parseCodeSessionId, parseCodeSessionInitializeInput, parseCodeSessionPath, parseExposePortInput, parseGitApplyInput, parseGitCommitInput, parseGitCommitOptions, parseGitPushInput, parsePatchFileInput, parseProcessId, parseProcessSignal, parsePushCredentialResolver, parseRunCommandInput, parseWriteFileInput } from '@vibesdotdev/agent-execution/code-session/interface';
|
|
40
|
+
import { CODE_SESSION_METADATA_FILENAME, parseCodeSessionMetadata, serializeCodeSessionMetadata } from '@vibesdotdev/agent-execution/code-session/session-metadata';
|
|
41
|
+
import { ExecuteResultSchema } from '@vibesdotdev/kernel/execution';
|
|
42
|
+
import { acquireWireProcess } from '@vibesdotdev/kernel-rpc/execution';
|
|
43
|
+
import { wireConsumer, asRefId, RPC_ERROR } from '@vibesdotdev/kernel-rpc';
|
|
44
|
+
import { connectRelay } from '@vibesdotdev/kernel-rpc/relay';
|
|
45
|
+
import * as z from 'zod/v4';
|
|
46
|
+
import { gitClone, gitInit, gitStatus, gitCommit, gitPush, gitRaw } from '@vibesdotdev/git-operations/core';
|
|
47
|
+
import { CapabilityError, CaughtFailureSchema, createRuntimeImplementation, isCapabilityError, valueFreeFailure } from '@vibesdotdev/kernel';
|
|
48
|
+
import { getRemotePathRoots } from '@vibesdotdev/paths';
|
|
49
|
+
import { toGitCredential } from "../code-session/git-credential.js";
|
|
50
|
+
import { OperationLeaseSchema } from "../schemas/session-operation.js";
|
|
51
|
+
import { pollUntil, withLeaseHeartbeat } from "../code-session/operation-lease.js";
|
|
52
|
+
import { RELAY_ON_BEHALF_OF_HEADER, resolveRelayBrokerTarget } from "./relay-dial.js";
|
|
53
|
+
/** The relay session's logger on the runtime that owns it (C083a): never an ambient default-bound one. */
|
|
54
|
+
const relayLogger = (runtime) => getLogger('agent.code-session.relay', runtime);
|
|
55
|
+
/** The daemon-exposed execution kind id (see fleet-daemon's daemon-root). */
|
|
56
|
+
const EXECUTION_KIND_ID = 'runtime/execution';
|
|
57
|
+
/** Root of every relay code-session workspace on the machine. */
|
|
58
|
+
const SESSION_DIRECTORY = 'code-sessions';
|
|
59
|
+
/**
|
|
60
|
+
* Per-invoke wall-clock budget. Must stay UNDER the broker DO's pending-entry
|
|
61
|
+
* timeout (PENDING_ENTRY_TIMEOUT_MS in platform-web's host-relay.do.ts) or the
|
|
62
|
+
* broker gives up on the invoke before the daemon answers.
|
|
63
|
+
*/
|
|
64
|
+
const RELAY_UNARY_BUDGET_MS = 290_000;
|
|
65
|
+
const SESSION_ID_PATTERN = /^rly\.([a-z0-9-]{2,64})\.([a-z0-9]+)$/;
|
|
66
|
+
const RelayHostIdSchema = z.string().regex(/^[a-z0-9-]{2,64}$/);
|
|
67
|
+
const RelaySessionIdSchema = z.string().regex(SESSION_ID_PATTERN).transform((sessionId) => ({
|
|
68
|
+
sessionId,
|
|
69
|
+
hostId: RelayHostIdSchema.parse(sessionId.split('.').at(1))
|
|
70
|
+
}));
|
|
71
|
+
const RemoteExecutionProxySchema = z.custom((value) => z.object({ execute: z.function() }).safeParse(value).success);
|
|
72
|
+
const RelayRemoteRootSchema = z.custom((value) => z.object({ query: z.function() }).safeParse(value).success);
|
|
73
|
+
const RelayWireFailureSchema = z.object({ code: z.string(), reason: z.string().optional() });
|
|
74
|
+
const REMOTE_ABSENT_EXIT_CODE = 44;
|
|
75
|
+
function requestInvalid(field, failure) {
|
|
76
|
+
return new CapabilityError('The relay code-session request is invalid.', 'INVALID_INPUT', {
|
|
77
|
+
reason: 'code-session-relay-request-invalid',
|
|
78
|
+
retryable: false,
|
|
79
|
+
details: { field },
|
|
80
|
+
cause: failure
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function metadataInvalid(record, failure) {
|
|
84
|
+
return new CapabilityError('Relay code-session durable state is invalid.', 'INVALID_INPUT', {
|
|
85
|
+
reason: 'code-session-metadata-invalid',
|
|
86
|
+
retryable: false,
|
|
87
|
+
details: { record },
|
|
88
|
+
cause: failure
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function hostUnavailable(hostId, failure) {
|
|
92
|
+
return new CapabilityError('The selected code-session host is unavailable.', 'UNAVAILABLE', {
|
|
93
|
+
reason: 'code-session-host-unavailable',
|
|
94
|
+
details: { hostId },
|
|
95
|
+
cause: failure
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** Admit one untrusted execution reply before any relay operation reads it. */
|
|
99
|
+
function parseRemoteExecuteResult(value, hostId) {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = ExecuteResultSchema.safeParse(value);
|
|
102
|
+
if (!parsed.success) {
|
|
103
|
+
throw hostUnavailable(hostId, valueFreeFailure(CaughtFailureSchema.parse(parsed.error)));
|
|
104
|
+
}
|
|
105
|
+
return parsed.data;
|
|
106
|
+
}
|
|
107
|
+
catch (caught) {
|
|
108
|
+
const failure = CaughtFailureSchema.parse(caught);
|
|
109
|
+
if (isCapabilityError(failure))
|
|
110
|
+
throw failure;
|
|
111
|
+
throw hostUnavailable(hostId, valueFreeFailure(failure));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function hostOffline(hostId, failure) {
|
|
115
|
+
return new CapabilityError(`The relay machine '${hostId}' is offline.`, 'UNAVAILABLE', {
|
|
116
|
+
reason: 'code-session-host-unavailable',
|
|
117
|
+
details: { hostId },
|
|
118
|
+
cause: failure
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function setupFailed(hostId, failure) {
|
|
122
|
+
return new CapabilityError('Relay code-session setup failed.', 'UNAVAILABLE', {
|
|
123
|
+
reason: 'code-session-host-unavailable',
|
|
124
|
+
details: { hostId },
|
|
125
|
+
cause: failure
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function operationConflict(operationId, kind = 'state') {
|
|
129
|
+
let message = 'The code-session operation conflicts with durable state.';
|
|
130
|
+
if (kind === 'configuration') {
|
|
131
|
+
message = 'The code-session operation was already claimed with a different configuration.';
|
|
132
|
+
}
|
|
133
|
+
else if (kind === 'torn-down') {
|
|
134
|
+
message = 'The code-session operation was torn down and cannot allocate again.';
|
|
135
|
+
}
|
|
136
|
+
else if (kind === 'attention') {
|
|
137
|
+
message = 'The code-session operation requires attention after setup failed or preparation was interrupted.';
|
|
138
|
+
}
|
|
139
|
+
return new CapabilityError(message, 'CONFLICT', {
|
|
140
|
+
reason: 'code-session-operation-conflict',
|
|
141
|
+
retryable: false,
|
|
142
|
+
details: { operationId }
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function cleanupUnconfirmed(operation, failures) {
|
|
146
|
+
return new CapabilityError('Code-session cleanup could not be confirmed.', 'UNAVAILABLE', {
|
|
147
|
+
reason: 'code-session-cleanup-unconfirmed',
|
|
148
|
+
retryable: true,
|
|
149
|
+
details: { operation, failureCount: failures.length },
|
|
150
|
+
cause: failures
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/** The user this invocation acts for; the broker authorizes that user against the host. */
|
|
154
|
+
function actingUserId(runtime, hostId) {
|
|
155
|
+
const userId = runtime.scope.actor?.userId;
|
|
156
|
+
if (userId)
|
|
157
|
+
return userId;
|
|
158
|
+
throw new CapabilityError('The relay code-session invocation has no acting user.', 'AUTH_REQUIRED', {
|
|
159
|
+
reason: 'code-session-relay-user-required',
|
|
160
|
+
recoveryHint: 'Run the call inside runtime.runInvocation with an actor that names the user.',
|
|
161
|
+
details: { hostId }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Cache key for one user's dial to one machine. hostIds never contain a NUL. */
|
|
165
|
+
function dialKey(hostId, userId) {
|
|
166
|
+
return `${hostId}\u0000${userId}`;
|
|
167
|
+
}
|
|
168
|
+
async function operationOutcome(run) {
|
|
169
|
+
try {
|
|
170
|
+
return { ok: true, value: await run() };
|
|
171
|
+
}
|
|
172
|
+
catch (caught) {
|
|
173
|
+
return { ok: false, failure: CaughtFailureSchema.parse(caught) };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function newSessionId(hostId) {
|
|
177
|
+
const admittedHost = parseRelayHostId(hostId);
|
|
178
|
+
const random = globalThis.crypto.randomUUID().replaceAll('-', '').slice(0, 12);
|
|
179
|
+
return `rly.${admittedHost}.${random}`;
|
|
180
|
+
}
|
|
181
|
+
function parseRelayHostId(value) {
|
|
182
|
+
try {
|
|
183
|
+
const admitted = RelayHostIdSchema.safeParse(value);
|
|
184
|
+
if (!admitted.success)
|
|
185
|
+
throw requestInvalid('hostTarget.hostId');
|
|
186
|
+
return admitted.data;
|
|
187
|
+
}
|
|
188
|
+
catch (caught) {
|
|
189
|
+
const failure = CaughtFailureSchema.parse(caught);
|
|
190
|
+
if (isCapabilityError(failure))
|
|
191
|
+
throw failure;
|
|
192
|
+
throw new CapabilityError('The relay host id could not be read.', 'INVALID_INPUT', {
|
|
193
|
+
reason: 'code-session-relay-request-invalid',
|
|
194
|
+
retryable: false,
|
|
195
|
+
details: { field: 'hostTarget.hostId' },
|
|
196
|
+
cause: valueFreeFailure(failure)
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Recover the machine a session lives on from its self-describing id. */
|
|
201
|
+
export function hostIdFromSessionId(sessionId) {
|
|
202
|
+
try {
|
|
203
|
+
const admitted = RelaySessionIdSchema.safeParse(sessionId);
|
|
204
|
+
if (!admitted.success)
|
|
205
|
+
throw requestInvalid('sessionId');
|
|
206
|
+
return admitted.data.hostId;
|
|
207
|
+
}
|
|
208
|
+
catch (caught) {
|
|
209
|
+
const failure = CaughtFailureSchema.parse(caught);
|
|
210
|
+
if (isCapabilityError(failure))
|
|
211
|
+
throw failure;
|
|
212
|
+
throw new CapabilityError('The relay session id could not be read.', 'INVALID_INPUT', {
|
|
213
|
+
reason: 'code-session-relay-request-invalid',
|
|
214
|
+
retryable: false,
|
|
215
|
+
details: { field: 'sessionId' },
|
|
216
|
+
cause: valueFreeFailure(failure)
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function shellQuote(value) {
|
|
221
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
222
|
+
}
|
|
223
|
+
/** Reject absolute paths and `..` escapes; return the workspace-relative path. */
|
|
224
|
+
function normalizeRelativePath(workspaceDir, path) {
|
|
225
|
+
const stripped = path.startsWith(`${workspaceDir}/`) ? path.slice(workspaceDir.length + 1) : path;
|
|
226
|
+
if (stripped.startsWith('/')) {
|
|
227
|
+
throw new CapabilityError('The requested path escapes the session workspace.', 'INVALID_INPUT', {
|
|
228
|
+
reason: 'code-session-relay-request-invalid', retryable: false, details: { field: 'path' }
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const parts = [];
|
|
232
|
+
for (const segment of stripped.split('/')) {
|
|
233
|
+
if (!segment || segment === '.')
|
|
234
|
+
continue;
|
|
235
|
+
if (segment === '..') {
|
|
236
|
+
if (parts.length === 0) {
|
|
237
|
+
throw new CapabilityError('The requested path escapes the session workspace.', 'INVALID_INPUT', {
|
|
238
|
+
reason: 'code-session-relay-request-invalid', retryable: false, details: { field: 'path' }
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
parts.pop();
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
parts.push(segment);
|
|
245
|
+
}
|
|
246
|
+
if (parts.length === 0) {
|
|
247
|
+
throw requestInvalid('path');
|
|
248
|
+
}
|
|
249
|
+
return parts.join('/');
|
|
250
|
+
}
|
|
251
|
+
function toBase64(text) {
|
|
252
|
+
const bytes = new TextEncoder().encode(text);
|
|
253
|
+
let binary = '';
|
|
254
|
+
const CHUNK = 0x8000;
|
|
255
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
256
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
257
|
+
}
|
|
258
|
+
return btoa(binary);
|
|
259
|
+
}
|
|
260
|
+
function isDisconnected(failure) {
|
|
261
|
+
const wire = RelayWireFailureSchema.safeParse(failure);
|
|
262
|
+
return wire.success && wire.data.code === RPC_ERROR.DISCONNECTED;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A `GitCommandRunner` over the machine's remote `runtime/execution` — the
|
|
266
|
+
* relay-side analog of `ExecutionGitRunner`, reusing the runner-agnostic
|
|
267
|
+
* `git-operations/core` functions instead of hand-rolling git strings.
|
|
268
|
+
*/
|
|
269
|
+
class RemoteExecutionGitRunner {
|
|
270
|
+
exec;
|
|
271
|
+
timeoutMs;
|
|
272
|
+
constructor(exec, timeoutMs) {
|
|
273
|
+
this.exec = exec;
|
|
274
|
+
this.timeoutMs = timeoutMs;
|
|
275
|
+
}
|
|
276
|
+
async run(cwd, args, options) {
|
|
277
|
+
const execOptions = {
|
|
278
|
+
command: 'git',
|
|
279
|
+
args,
|
|
280
|
+
cwd,
|
|
281
|
+
timeout: Math.min(options?.timeoutMs ?? this.timeoutMs, RELAY_UNARY_BUDGET_MS)
|
|
282
|
+
};
|
|
283
|
+
if (options?.env)
|
|
284
|
+
execOptions.env = options.env;
|
|
285
|
+
const result = await this.exec(execOptions);
|
|
286
|
+
return {
|
|
287
|
+
exitCode: result.exitCode,
|
|
288
|
+
stdout: result.stdout,
|
|
289
|
+
stderr: result.stderr,
|
|
290
|
+
timedOut: result.timedOut
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Invoke `onAllocated` without letting it take down the initialize. A caller's
|
|
296
|
+
* bookkeeping failure must not strand an allocated workspace.
|
|
297
|
+
*/
|
|
298
|
+
async function notifyAllocated(input, sessionId) {
|
|
299
|
+
if (!input.onAllocated)
|
|
300
|
+
return undefined;
|
|
301
|
+
try {
|
|
302
|
+
await input.onAllocated(sessionId);
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
catch (caught) {
|
|
306
|
+
// Declared-optional: carry a value-free outcome so allocation is not stranded.
|
|
307
|
+
return valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const CallerBearerContextSchema = z.object({ resolve: z.function() });
|
|
311
|
+
/**
|
|
312
|
+
* The bearer auth's caller-bearer context answers for the active invocation
|
|
313
|
+
* (the run-bound bearer on a run that carries one); null when the host
|
|
314
|
+
* declares no such context or nobody is signed in. Never a service credential.
|
|
315
|
+
*/
|
|
316
|
+
async function invocationBearer(runtime) {
|
|
317
|
+
if (!runtime.hasContext('auth/caller-bearer'))
|
|
318
|
+
return null;
|
|
319
|
+
const context = CallerBearerContextSchema.parse(await runtime.context('auth/caller-bearer'));
|
|
320
|
+
const answered = z.string().min(1).nullish().parse(await context.resolve());
|
|
321
|
+
return answered ?? null;
|
|
322
|
+
}
|
|
323
|
+
/** A paused session refuses new commands until resumed. */
|
|
324
|
+
function sessionPaused(sessionId) {
|
|
325
|
+
return new CapabilityError('The code session is paused; resume it to run commands.', 'CONFLICT', {
|
|
326
|
+
reason: 'code-session-paused',
|
|
327
|
+
retryable: false,
|
|
328
|
+
details: { sessionId }
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
export class RelayCodeSession {
|
|
332
|
+
descriptor;
|
|
333
|
+
runtime;
|
|
334
|
+
dialOptions;
|
|
335
|
+
pushCredentialResolver;
|
|
336
|
+
/** sessionId -> repositoryId, recorded at initialize() for the push-credential fallback. */
|
|
337
|
+
sessionRepositoryIds = new Map();
|
|
338
|
+
/** (hostId, userId) -> in-flight/settled dial; evicted on close or DISCONNECTED. */
|
|
339
|
+
hosts = new Map();
|
|
340
|
+
pinnedRoots = new Map();
|
|
341
|
+
constructor(descriptor, runtime, dialOptions = {}) {
|
|
342
|
+
this.descriptor = descriptor;
|
|
343
|
+
this.runtime = runtime;
|
|
344
|
+
this.dialOptions = dialOptions;
|
|
345
|
+
}
|
|
346
|
+
/** This session's logger, on the runtime that owns it. */
|
|
347
|
+
get log() {
|
|
348
|
+
return relayLogger(this.runtime);
|
|
349
|
+
}
|
|
350
|
+
setPushCredentialResolver(resolver) {
|
|
351
|
+
this.pushCredentialResolver = parsePushCredentialResolver(resolver);
|
|
352
|
+
}
|
|
353
|
+
// ------------------------------------------------------------------
|
|
354
|
+
// Remote execution plumbing
|
|
355
|
+
// ------------------------------------------------------------------
|
|
356
|
+
async dialHost(hostId, userId) {
|
|
357
|
+
const target = await resolveRelayBrokerTarget(this.runtime, {
|
|
358
|
+
...this.dialOptions,
|
|
359
|
+
runBearer: this.dialOptions.runBearer ?? (() => invocationBearer(this.runtime))
|
|
360
|
+
});
|
|
361
|
+
// Read-only option fields: reassignment (not mutation) keeps the omission
|
|
362
|
+
// semantics of the previously conditional spreads.
|
|
363
|
+
let connectOptions = {
|
|
364
|
+
lifecycle: this.runtime.lifecycle,
|
|
365
|
+
headers: { [RELAY_ON_BEHALF_OF_HEADER]: userId }
|
|
366
|
+
};
|
|
367
|
+
if (target.token)
|
|
368
|
+
connectOptions = { ...connectOptions, token: target.token };
|
|
369
|
+
if (target.fetchImpl)
|
|
370
|
+
connectOptions = { ...connectOptions, fetchImpl: target.fetchImpl };
|
|
371
|
+
const connection = await connectRelay(target.brokerOrigin, hostId, connectOptions);
|
|
372
|
+
// No `onRequest`: v1 sends no function refs, so the reverse SSE channel
|
|
373
|
+
// (daemon→consumer callbacks) is never opened.
|
|
374
|
+
const { root } = wireConsumer({
|
|
375
|
+
rootRef: connection.rootRef,
|
|
376
|
+
send: (req) => connection.send(req),
|
|
377
|
+
openStream: (ref, channel) => connection.openStream(asRefId(String(ref)), channel),
|
|
378
|
+
closed: connection.closed
|
|
379
|
+
});
|
|
380
|
+
const opened = await operationOutcome(async () => {
|
|
381
|
+
const remoteRoot = RelayRemoteRootSchema.parse(root);
|
|
382
|
+
const execution = RemoteExecutionProxySchema.parse(await remoteRoot.query(EXECUTION_KIND_ID));
|
|
383
|
+
const roots = await getRemotePathRoots(remoteRoot);
|
|
384
|
+
if (!roots.activeHome.startsWith('/') || !roots.tempDir.startsWith('/'))
|
|
385
|
+
throw new CapabilityError('Relay code sessions require absolute POSIX host profile and temp roots', 'UNAVAILABLE', { retryable: false });
|
|
386
|
+
const sessionRoot = `${roots.activeHome.replace(/\/$/, '')}/${SESSION_DIRECTORY}`;
|
|
387
|
+
const key = dialKey(hostId, userId);
|
|
388
|
+
const previous = this.pinnedRoots.get(key);
|
|
389
|
+
if (previous && previous !== sessionRoot) {
|
|
390
|
+
throw new CapabilityError('Remote profile changed; explicit session recovery is required', 'CONFLICT', { retryable: false });
|
|
391
|
+
}
|
|
392
|
+
this.pinnedRoots.set(key, sessionRoot);
|
|
393
|
+
const tempRoot = roots.tempDir.replace(/\/$/, '');
|
|
394
|
+
return { connection, execution, sessionRoot, tempRoot };
|
|
395
|
+
});
|
|
396
|
+
if (opened.ok)
|
|
397
|
+
return opened.value;
|
|
398
|
+
const closed = await operationOutcome(() => connection.close());
|
|
399
|
+
if (!closed.ok) {
|
|
400
|
+
throw cleanupUnconfirmed('relay-dial', [
|
|
401
|
+
valueFreeFailure(opened.failure),
|
|
402
|
+
valueFreeFailure(closed.failure)
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
if (isCapabilityError(opened.failure))
|
|
406
|
+
throw opened.failure;
|
|
407
|
+
throw hostUnavailable(hostId, valueFreeFailure(opened.failure));
|
|
408
|
+
}
|
|
409
|
+
remoteHost(hostId, userId) {
|
|
410
|
+
const key = dialKey(hostId, userId);
|
|
411
|
+
let handle = this.hosts.get(key);
|
|
412
|
+
if (!handle) {
|
|
413
|
+
handle = this.dialHost(hostId, userId);
|
|
414
|
+
this.hosts.set(key, handle);
|
|
415
|
+
const pending = handle;
|
|
416
|
+
// The original pending promise carries dial failure to callers. This observer
|
|
417
|
+
// owns only cache eviction and reports closure failures value-free.
|
|
418
|
+
void pending.then((remote) => remote.connection.closed.then(() => {
|
|
419
|
+
if (this.hosts.get(key) === pending)
|
|
420
|
+
this.hosts.delete(key);
|
|
421
|
+
}, (caught) => {
|
|
422
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
423
|
+
this.log.warn('relay connection closed with failure', { hostId, failure });
|
|
424
|
+
if (this.hosts.get(key) === pending)
|
|
425
|
+
this.hosts.delete(key);
|
|
426
|
+
return failure;
|
|
427
|
+
}), (caught) => {
|
|
428
|
+
const failure = valueFreeFailure(CaughtFailureSchema.parse(caught));
|
|
429
|
+
if (this.hosts.get(key) === pending)
|
|
430
|
+
this.hosts.delete(key);
|
|
431
|
+
return failure;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return handle;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Run `fn` against the machine's remote execution; on a DISCONNECTED wire
|
|
438
|
+
* (stale cached socket), evict + redial ONCE before giving up.
|
|
439
|
+
*/
|
|
440
|
+
async withHost(hostId, fn) {
|
|
441
|
+
const userId = actingUserId(this.runtime, hostId);
|
|
442
|
+
const attempt = async () => fn(await this.remoteHost(hostId, userId));
|
|
443
|
+
const first = await operationOutcome(attempt);
|
|
444
|
+
if (first.ok)
|
|
445
|
+
return first.value;
|
|
446
|
+
if (!isDisconnected(first.failure)) {
|
|
447
|
+
if (isCapabilityError(first.failure))
|
|
448
|
+
throw first.failure;
|
|
449
|
+
throw hostUnavailable(hostId, valueFreeFailure(first.failure));
|
|
450
|
+
}
|
|
451
|
+
this.hosts.delete(dialKey(hostId, userId));
|
|
452
|
+
const retry = await operationOutcome(attempt);
|
|
453
|
+
if (retry.ok)
|
|
454
|
+
return retry.value;
|
|
455
|
+
if (isDisconnected(retry.failure)) {
|
|
456
|
+
throw hostOffline(hostId, valueFreeFailure(retry.failure));
|
|
457
|
+
}
|
|
458
|
+
if (isCapabilityError(retry.failure))
|
|
459
|
+
throw retry.failure;
|
|
460
|
+
throw hostUnavailable(hostId, valueFreeFailure(retry.failure));
|
|
461
|
+
}
|
|
462
|
+
async prepareEnvironment(hostId, host, cwd, env) {
|
|
463
|
+
const key = await codeSessionOperationKey(cwd ?? host.sessionRoot);
|
|
464
|
+
const home = `${host.sessionRoot}/.environments/${key}/home`;
|
|
465
|
+
const temp = `${host.tempRoot}/${codeSessionTempName(key)}`;
|
|
466
|
+
const prepared = parseRemoteExecuteResult(await host.execution.execute({
|
|
467
|
+
command: 'mkdir', args: ['-p', home, temp], declaration: {},
|
|
468
|
+
timeout: RELAY_UNARY_BUDGET_MS
|
|
469
|
+
}), hostId);
|
|
470
|
+
if (prepared.exitCode !== 0)
|
|
471
|
+
throw new CapabilityError('Remote session environment could not be prepared', 'UNAVAILABLE', { retryable: false });
|
|
472
|
+
return { HOME: home, TMPDIR: temp, ...env };
|
|
473
|
+
}
|
|
474
|
+
async withExecution(hostId, fn) {
|
|
475
|
+
return this.withHost(hostId, (host) => fn(async ({ env, ...options }) => {
|
|
476
|
+
const prepared = await this.prepareEnvironment(hostId, host, options.cwd, env);
|
|
477
|
+
return parseRemoteExecuteResult(await host.execution.execute({
|
|
478
|
+
...options, declaration: { env: prepared }
|
|
479
|
+
}), hostId);
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
async exec(hostId, options) {
|
|
483
|
+
return this.withExecution(hostId, (exec) => exec({
|
|
484
|
+
...options,
|
|
485
|
+
timeout: Math.min(options.timeout ?? this.descriptor.timeoutMs, RELAY_UNARY_BUDGET_MS)
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
/** Run a compound shell script on the machine (paths pre-validated). */
|
|
489
|
+
async shell(hostId, script, options = {}) {
|
|
490
|
+
return this.exec(hostId, { command: 'sh', args: ['-c', script], ...options });
|
|
491
|
+
}
|
|
492
|
+
/** A foreground Git stage owns its remote process through exit and output EOF. */
|
|
493
|
+
async execGit(hostId, options, signal) {
|
|
494
|
+
signal.throwIfAborted();
|
|
495
|
+
const { host, env } = await this.withHost(hostId, async (host) => {
|
|
496
|
+
signal.throwIfAborted();
|
|
497
|
+
const env = await this.prepareEnvironment(hostId, host, options.cwd, options.env);
|
|
498
|
+
signal.throwIfAborted();
|
|
499
|
+
return { host, env };
|
|
500
|
+
});
|
|
501
|
+
signal.throwIfAborted();
|
|
502
|
+
const started = performance.now();
|
|
503
|
+
const acquisition = acquireWireProcess(this.runtime, () => host.execution.spawn({
|
|
504
|
+
command: options.command,
|
|
505
|
+
args: options.args,
|
|
506
|
+
cwd: options.cwd,
|
|
507
|
+
stdin: 'ignore', stdout: 'pipe', stderr: 'pipe',
|
|
508
|
+
timeout: Math.min(options.timeout ?? this.descriptor.timeoutMs, RELAY_UNARY_BUDGET_MS),
|
|
509
|
+
killTree: true,
|
|
510
|
+
declaration: { env }
|
|
511
|
+
}));
|
|
512
|
+
// Setup stays owned if cancellation precedes the port. The late process is
|
|
513
|
+
// stopped gracefully, rather than abandoned with Git's lock still owned.
|
|
514
|
+
const setup = await operationOutcome(() => acquisition.ready);
|
|
515
|
+
if (!setup.ok) {
|
|
516
|
+
const cleanup = await acquisition.settled;
|
|
517
|
+
if (cleanup.outcome === 'cleanup-failed' || cleanup.outcome === 'unconfirmed') {
|
|
518
|
+
const failures = [valueFreeFailure(setup.failure)];
|
|
519
|
+
if (cleanup.failure)
|
|
520
|
+
failures.push(valueFreeFailure(cleanup.failure));
|
|
521
|
+
throw cleanupUnconfirmed('foreground-git-setup', failures);
|
|
522
|
+
}
|
|
523
|
+
throw setup.failure;
|
|
524
|
+
}
|
|
525
|
+
const process = setup.value;
|
|
526
|
+
let stopping;
|
|
527
|
+
const stop = () => {
|
|
528
|
+
stopping ??= operationOutcome(async () => { await process.kill('SIGTERM'); });
|
|
529
|
+
};
|
|
530
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
531
|
+
if (signal.aborted)
|
|
532
|
+
stop();
|
|
533
|
+
return this.withRequiredCleanup(hostId, 'foreground-git', async () => {
|
|
534
|
+
const results = await Promise.allSettled([
|
|
535
|
+
process.exited,
|
|
536
|
+
process.stdout ? new Response(process.stdout).text() : '',
|
|
537
|
+
process.stderr ? new Response(process.stderr).text() : ''
|
|
538
|
+
]);
|
|
539
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
|
|
540
|
+
if (failures.length)
|
|
541
|
+
throw new AggregateError(failures, 'Remote Git process observation failed.');
|
|
542
|
+
const [exit, stdout, stderr] = await Promise.all([
|
|
543
|
+
process.exited,
|
|
544
|
+
results[1].status === 'fulfilled' ? results[1].value : '',
|
|
545
|
+
results[2].status === 'fulfilled' ? results[2].value : ''
|
|
546
|
+
]);
|
|
547
|
+
signal.throwIfAborted();
|
|
548
|
+
return { exitCode: exit.code, stdout, stderr, durationMs: performance.now() - started, timedOut: false };
|
|
549
|
+
}, async () => {
|
|
550
|
+
signal.removeEventListener('abort', stop);
|
|
551
|
+
const stopped = await stopping;
|
|
552
|
+
if (stopped && !stopped.ok)
|
|
553
|
+
throw stopped.failure;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
gitRunner(hostId, signal) {
|
|
557
|
+
return new RemoteExecutionGitRunner((options) => signal ? this.execGit(hostId, options, signal) : this.exec(hostId, options), this.descriptor.timeoutMs);
|
|
558
|
+
}
|
|
559
|
+
/** Join required cleanup to the operation without masking either failure. */
|
|
560
|
+
async withRequiredCleanup(hostId, operation, run, cleanup) {
|
|
561
|
+
const outcome = await operationOutcome(run);
|
|
562
|
+
try {
|
|
563
|
+
await cleanup();
|
|
564
|
+
}
|
|
565
|
+
catch (caught) {
|
|
566
|
+
const failures = [valueFreeFailure(CaughtFailureSchema.parse(caught))];
|
|
567
|
+
if (!outcome.ok)
|
|
568
|
+
failures.unshift(valueFreeFailure(outcome.failure));
|
|
569
|
+
throw cleanupUnconfirmed(operation, failures);
|
|
570
|
+
}
|
|
571
|
+
if (!outcome.ok) {
|
|
572
|
+
if (isCapabilityError(outcome.failure))
|
|
573
|
+
throw outcome.failure;
|
|
574
|
+
throw hostUnavailable(hostId, valueFreeFailure(outcome.failure));
|
|
575
|
+
}
|
|
576
|
+
return outcome.value;
|
|
577
|
+
}
|
|
578
|
+
// ------------------------------------------------------------------
|
|
579
|
+
// Metadata
|
|
580
|
+
// ------------------------------------------------------------------
|
|
581
|
+
async sessionRoot(hostId) {
|
|
582
|
+
return (await this.remoteHost(hostId, actingUserId(this.runtime, hostId))).sessionRoot;
|
|
583
|
+
}
|
|
584
|
+
async sessionDir(sessionId) {
|
|
585
|
+
return `${await this.sessionRoot(hostIdFromSessionId(sessionId))}/${sessionId}`;
|
|
586
|
+
}
|
|
587
|
+
async metadataPath(sessionId) {
|
|
588
|
+
return `${await this.sessionDir(sessionId)}/${CODE_SESSION_METADATA_FILENAME}`;
|
|
589
|
+
}
|
|
590
|
+
async persistSessionMetadata(sessionId, metadata, storageDirectory) {
|
|
591
|
+
await this.writeRemoteFile(sessionId, `${storageDirectory ?? await this.sessionDir(sessionId)}/${CODE_SESSION_METADATA_FILENAME}`, serializeCodeSessionMetadata(metadata));
|
|
592
|
+
}
|
|
593
|
+
async readPersistedSessionMetadata(sessionId) {
|
|
594
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
595
|
+
return parseCodeSessionMetadata(await this.readRemoteText(hostId, await this.metadataPath(sessionId)));
|
|
596
|
+
}
|
|
597
|
+
async requireReadySessionMetadata(sessionId) {
|
|
598
|
+
const metadata = await this.readPersistedSessionMetadata(sessionId);
|
|
599
|
+
if (!metadata) {
|
|
600
|
+
throw new CapabilityError('The relay code session is not ready.', 'NOT_FOUND', {
|
|
601
|
+
reason: 'code-session-workspace-not-found',
|
|
602
|
+
retryable: false,
|
|
603
|
+
details: { sessionId }
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
if (metadata.sessionId !== sessionId || metadata.hostTarget.kind !== 'host' || metadata.hostTarget.hostId !== hostIdFromSessionId(sessionId)) {
|
|
607
|
+
throw new CapabilityError('Remote session metadata identity does not match the requested session.', 'CONFLICT', {
|
|
608
|
+
reason: 'code-session-profile-conflict',
|
|
609
|
+
retryable: false,
|
|
610
|
+
details: { sessionId }
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
614
|
+
const operationId = metadata.operationId ?? `teardown:${sessionId}`;
|
|
615
|
+
const key = await codeSessionOperationKey(operationId);
|
|
616
|
+
if (metadata.operationId) {
|
|
617
|
+
const operation = await this.readOperationRecord(hostId, key);
|
|
618
|
+
if (!operation ||
|
|
619
|
+
operation.stage !== 'ready' ||
|
|
620
|
+
operation.sessionId !== sessionId ||
|
|
621
|
+
operation.fingerprint !== metadata.operationFingerprint) {
|
|
622
|
+
throw operationConflict(operationId);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
// Legacy metadata remains unchanged during adoption. Its durable owner
|
|
627
|
+
// must still prevent ordinary work once teardown has begun.
|
|
628
|
+
const adopted = await this.readOperationRecord(hostId, key);
|
|
629
|
+
if (adopted && (adopted.stage !== 'ready' || adopted.sessionId !== sessionId)) {
|
|
630
|
+
throw operationConflict(operationId);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return metadata;
|
|
634
|
+
}
|
|
635
|
+
async operationDirectory(hostId, key) {
|
|
636
|
+
return `${await this.sessionRoot(hostId)}/.operations/${key}`;
|
|
637
|
+
}
|
|
638
|
+
async operationRecordPath(hostId, key) {
|
|
639
|
+
return `${await this.operationDirectory(hostId, key)}/operation.json`;
|
|
640
|
+
}
|
|
641
|
+
async readRemoteText(hostId, path) {
|
|
642
|
+
const result = await this.shell(hostId, `if test ! -e ${shellQuote(path)}; then exit ${REMOTE_ABSENT_EXIT_CODE}; fi; cat ${shellQuote(path)}`);
|
|
643
|
+
if (result.exitCode === 0)
|
|
644
|
+
return result.stdout;
|
|
645
|
+
if (result.exitCode === REMOTE_ABSENT_EXIT_CODE)
|
|
646
|
+
return null;
|
|
647
|
+
throw hostUnavailable(hostId);
|
|
648
|
+
}
|
|
649
|
+
async readOperationRecord(hostId, key) {
|
|
650
|
+
const value = await this.readRemoteText(hostId, await this.operationRecordPath(hostId, key));
|
|
651
|
+
if (value === null)
|
|
652
|
+
return null;
|
|
653
|
+
let decoded;
|
|
654
|
+
try {
|
|
655
|
+
decoded = JSON.parse(value);
|
|
656
|
+
}
|
|
657
|
+
catch (caught) {
|
|
658
|
+
throw metadataInvalid('operation', valueFreeFailure(CaughtFailureSchema.parse(caught)));
|
|
659
|
+
}
|
|
660
|
+
const parsed = PersistedCodeSessionOperationSchema.safeParse(decoded);
|
|
661
|
+
if (!parsed.success) {
|
|
662
|
+
throw metadataInvalid('operation', valueFreeFailure(CaughtFailureSchema.parse(parsed.error)));
|
|
663
|
+
}
|
|
664
|
+
return parsed.data;
|
|
665
|
+
}
|
|
666
|
+
async writeOperationRecord(hostId, key, record, patch, leaseHolderPath) {
|
|
667
|
+
const updated = PersistedCodeSessionOperationSchema.parse({ ...record, ...patch, updatedAt: new Date().toISOString() });
|
|
668
|
+
const path = await this.operationRecordPath(hostId, key);
|
|
669
|
+
const temporary = `${leaseHolderPath ? leaseHolderPath + '/record' : path}.${crypto.randomUUID()}.tmp`;
|
|
670
|
+
if (leaseHolderPath) {
|
|
671
|
+
const written = await this.shell(hostId, 'printf %s "$VIBES_B64" | base64 -d > "$VIBES_DEST"', { env: { VIBES_B64: toBase64(JSON.stringify(updated, null, 2)), VIBES_DEST: temporary } });
|
|
672
|
+
if (written.exitCode !== 0)
|
|
673
|
+
throw operationConflict(record.operationId);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
await this.writeRemoteFile(updated.sessionId, temporary, JSON.stringify(updated, null, 2));
|
|
677
|
+
}
|
|
678
|
+
const published = await this.shell(hostId, `mv ${shellQuote(temporary)} ${shellQuote(path)}`);
|
|
679
|
+
if (published.exitCode !== 0)
|
|
680
|
+
throw hostUnavailable(hostId);
|
|
681
|
+
return updated;
|
|
682
|
+
}
|
|
683
|
+
async prepareOperation(input, hostId) {
|
|
684
|
+
const operationId = input.operationId.trim();
|
|
685
|
+
if (!operationId)
|
|
686
|
+
throw requestInvalid('operationId');
|
|
687
|
+
if (input.sessionId) {
|
|
688
|
+
throw requestInvalid('sessionId');
|
|
689
|
+
}
|
|
690
|
+
const [key, fingerprint] = await Promise.all([
|
|
691
|
+
codeSessionOperationKey(operationId),
|
|
692
|
+
codeSessionOperationFingerprint(input, { authorityId: this.descriptor.id })
|
|
693
|
+
]);
|
|
694
|
+
const sessionId = `rly.${hostId}.${key.slice(0, 12)}`;
|
|
695
|
+
const operationDirectory = await this.operationDirectory(hostId, key);
|
|
696
|
+
const prepared = await this.shell(hostId, `mkdir -p ${shellQuote(operationDirectory)}`);
|
|
697
|
+
if (prepared.exitCode !== 0) {
|
|
698
|
+
throw hostUnavailable(hostId);
|
|
699
|
+
}
|
|
700
|
+
const record = await this.readOperationRecord(hostId, key);
|
|
701
|
+
if (record) {
|
|
702
|
+
if (record.operationId !== operationId || record.fingerprint !== fingerprint) {
|
|
703
|
+
throw operationConflict(operationId, 'configuration');
|
|
704
|
+
}
|
|
705
|
+
return { key, record };
|
|
706
|
+
}
|
|
707
|
+
const now = new Date().toISOString();
|
|
708
|
+
return this.claimOperationRecord(hostId, key, {
|
|
709
|
+
operationId, fingerprint, sessionId, stage: 'reserved',
|
|
710
|
+
generation: 1, createdAt: now, updatedAt: now
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
/** Initial publication is serialized for both create-or-get and legacy adoption. */
|
|
714
|
+
async claimOperationRecord(hostId, key, proposed) {
|
|
715
|
+
const directory = await this.operationDirectory(hostId, key);
|
|
716
|
+
const prepared = await this.shell(hostId, `mkdir -p ${shellQuote(directory)}`);
|
|
717
|
+
if (prepared.exitCode !== 0)
|
|
718
|
+
throw hostUnavailable(hostId);
|
|
719
|
+
const operation = { key, record: proposed };
|
|
720
|
+
return this.withOperationLease(hostId, operation, async (holderPath) => {
|
|
721
|
+
const existing = await this.readOperationRecord(hostId, key);
|
|
722
|
+
if (existing) {
|
|
723
|
+
if (existing.operationId !== proposed.operationId || existing.fingerprint !== proposed.fingerprint ||
|
|
724
|
+
existing.sessionId !== proposed.sessionId)
|
|
725
|
+
throw operationConflict(proposed.operationId, 'configuration');
|
|
726
|
+
return { key, record: existing };
|
|
727
|
+
}
|
|
728
|
+
const marker = `${directory}/fingerprints/${proposed.fingerprint.slice('sha256:'.length)}`;
|
|
729
|
+
const marked = await this.shell(hostId, `mkdir -p ${shellQuote(marker)}`);
|
|
730
|
+
if (marked.exitCode !== 0)
|
|
731
|
+
throw hostUnavailable(hostId);
|
|
732
|
+
const record = await this.writeOperationRecord(hostId, key, proposed, {}, holderPath);
|
|
733
|
+
return { key, record };
|
|
734
|
+
}, false);
|
|
735
|
+
}
|
|
736
|
+
async withOperationLease(hostId, operation, run, replayTerminal = true) {
|
|
737
|
+
const operationDirectory = await this.operationDirectory(hostId, operation.key);
|
|
738
|
+
const leaseDirectory = `${operationDirectory}/lease`;
|
|
739
|
+
const leasePath = `${leaseDirectory}/lease.json`;
|
|
740
|
+
const holderId = globalThis.crypto.randomUUID();
|
|
741
|
+
const holderPath = `${leaseDirectory}/${holderId}`;
|
|
742
|
+
const leaseDurationMs = 15_000;
|
|
743
|
+
const readLease = (value) => {
|
|
744
|
+
let decoded;
|
|
745
|
+
try {
|
|
746
|
+
decoded = JSON.parse(value);
|
|
747
|
+
}
|
|
748
|
+
catch (caught) {
|
|
749
|
+
throw metadataInvalid('operation-lease', valueFreeFailure(CaughtFailureSchema.parse(caught)));
|
|
750
|
+
}
|
|
751
|
+
const lease = OperationLeaseSchema.safeParse(decoded);
|
|
752
|
+
if (!lease.success)
|
|
753
|
+
throw metadataInvalid('operation-lease', valueFreeFailure(CaughtFailureSchema.parse(lease.error)));
|
|
754
|
+
return lease.data;
|
|
755
|
+
};
|
|
756
|
+
// Each holder has one permanent retirement destination. A second mv cannot
|
|
757
|
+
// replace its nonempty lease directory, even if a successor has acquired.
|
|
758
|
+
const retire = async (owner, expected, valuePath) => {
|
|
759
|
+
const retired = `${operationDirectory}/retired-${owner}`;
|
|
760
|
+
return this.shell(hostId, `mkdir -p ${shellQuote(retired)} && { IFS= read -r current < ${shellQuote(valuePath)} || test -n "$current"; } && test "$current" = "$VIBES_LEASE" && mv ${shellQuote(leaseDirectory)} ${shellQuote(retired)}/`, { env: { VIBES_LEASE: expected } });
|
|
761
|
+
};
|
|
762
|
+
const acquired = await pollUntil(this.runtime.lifecycle, async () => {
|
|
763
|
+
// Prepare outside the shared path. Only a complete, expiring holder
|
|
764
|
+
// becomes visible; interrupted preparation cannot block a successor.
|
|
765
|
+
const candidate = `${operationDirectory}/lease-candidate-${crypto.randomUUID()}`;
|
|
766
|
+
const candidateLease = JSON.stringify({ holderId, expiresAt: Date.now() + leaseDurationMs });
|
|
767
|
+
const attempt = await this.shell(hostId, `test ! -e ${shellQuote(leaseDirectory)} && mkdir ${shellQuote(candidate)} && mkdir ${shellQuote(candidate + '/' + holderId)} && printf '%s\n' "$VIBES_LEASE" > ${shellQuote(candidate + '/lease.json')} && printf '%s\n' "$VIBES_LEASE" > ${shellQuote(candidate + '/' + holderId + '/lease.json')} && mv ${shellQuote(candidate)} ${shellQuote(leaseDirectory)} && test -f ${shellQuote(holderPath + '/lease.json')}`, { env: { VIBES_LEASE: candidateLease } });
|
|
768
|
+
if (attempt.exitCode === 0)
|
|
769
|
+
return 'acquired';
|
|
770
|
+
const exists = await this.shell(hostId, `test -d ${shellQuote(leaseDirectory)}`);
|
|
771
|
+
if (exists.exitCode !== 0)
|
|
772
|
+
return null;
|
|
773
|
+
const latest = await this.readOperationRecord(hostId, operation.key);
|
|
774
|
+
if (replayTerminal && (latest?.stage === 'ready' || latest?.stage === 'attention-required' || latest?.stage === 'torn-down'))
|
|
775
|
+
return latest;
|
|
776
|
+
const value = await this.readRemoteText(hostId, leasePath);
|
|
777
|
+
if (value === null) {
|
|
778
|
+
// Compatibility with an old owner lost immediately after mkdir.
|
|
779
|
+
// rmdir can reclaim only the empty directory, never a live holder.
|
|
780
|
+
await this.shell(hostId, `rmdir ${shellQuote(leaseDirectory)}`);
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
const initial = readLease(value);
|
|
784
|
+
const renewalPath = `${leaseDirectory}/${initial.holderId}/lease.json`;
|
|
785
|
+
const renewed = await this.readRemoteText(hostId, renewalPath);
|
|
786
|
+
const lease = renewed === null ? initial : readLease(renewed);
|
|
787
|
+
if (lease.holderId !== initial.holderId)
|
|
788
|
+
throw metadataInvalid('operation-lease');
|
|
789
|
+
if (lease.expiresAt <= Date.now()) {
|
|
790
|
+
await retire(lease.holderId, (renewed ?? value).trimEnd(), renewed === null ? leasePath : renewalPath);
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}, { intervalMs: 50, timeoutMs: 10 * 60_000, reason: 'code-session-relay-operation-lease' });
|
|
794
|
+
if (acquired !== 'acquired') {
|
|
795
|
+
operation.record = acquired;
|
|
796
|
+
return run();
|
|
797
|
+
}
|
|
798
|
+
const renew = async () => {
|
|
799
|
+
const temporary = `${holderPath}/renew-${crypto.randomUUID()}`;
|
|
800
|
+
const result = await this.shell(hostId, `printf '%s\n' "$VIBES_LEASE" > ${shellQuote(temporary)} && mv ${shellQuote(temporary)} ${shellQuote(holderPath + '/lease.json')}`, { env: { VIBES_LEASE: JSON.stringify({ holderId, expiresAt: Date.now() + leaseDurationMs }) } });
|
|
801
|
+
if (result.exitCode !== 0)
|
|
802
|
+
throw operationConflict(operation.record.operationId);
|
|
803
|
+
};
|
|
804
|
+
return this.withRequiredCleanup(hostId, 'operation-lease', () => withLeaseHeartbeat(this.runtime.lifecycle, { periodMs: leaseDurationMs / 3, operationId: operation.record.operationId, renew, logger: this.log }, async () => {
|
|
805
|
+
const latest = await this.readOperationRecord(hostId, operation.key);
|
|
806
|
+
if (latest)
|
|
807
|
+
operation.record = latest;
|
|
808
|
+
return run(holderPath);
|
|
809
|
+
}), async () => {
|
|
810
|
+
const value = await this.readRemoteText(hostId, `${holderPath}/lease.json`);
|
|
811
|
+
if (value === null)
|
|
812
|
+
return;
|
|
813
|
+
const released = await retire(holderId, value.trimEnd(), `${holderPath}/lease.json`);
|
|
814
|
+
if (released.exitCode !== 0) {
|
|
815
|
+
const remains = await this.readRemoteText(hostId, `${holderPath}/lease.json`);
|
|
816
|
+
if (remains !== null)
|
|
817
|
+
throw cleanupUnconfirmed('operation-lease-release', []);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
/** Write a file at an ABSOLUTE machine path via base64 through the env (no quoting risk). */
|
|
822
|
+
async writeRemoteFile(sessionId, absolutePath, content) {
|
|
823
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
824
|
+
// Compute the parent dir in JS — do NOT use `$(dirname …)` in the shell
|
|
825
|
+
// payload: command substitution is denied by the daemon's friction gate
|
|
826
|
+
// (it can hide a banned command), so a `$(…)` here would be vetoed. Pass
|
|
827
|
+
// the parent as a plain env var instead.
|
|
828
|
+
const slash = absolutePath.lastIndexOf('/');
|
|
829
|
+
const parentDir = slash > 0 ? absolutePath.slice(0, slash) : await this.sessionDir(sessionId);
|
|
830
|
+
const result = await this.shell(hostId, 'mkdir -p "$VIBES_DIR" && printf %s "$VIBES_B64" | base64 -d > "$VIBES_DEST"', { env: { VIBES_B64: toBase64(content), VIBES_DEST: absolutePath, VIBES_DIR: parentDir } });
|
|
831
|
+
if (result.exitCode !== 0) {
|
|
832
|
+
throw hostUnavailable(hostId);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// ------------------------------------------------------------------
|
|
836
|
+
// CodeSessionInterface
|
|
837
|
+
// ------------------------------------------------------------------
|
|
838
|
+
async initialize(input) {
|
|
839
|
+
input = parseCodeSessionInitializeInput(input);
|
|
840
|
+
if (input.sessionId) {
|
|
841
|
+
if (input.operationId) {
|
|
842
|
+
throw requestInvalid('sessionId');
|
|
843
|
+
}
|
|
844
|
+
const hostTarget = input.hostTarget;
|
|
845
|
+
if (hostTarget?.kind === 'host' &&
|
|
846
|
+
hostTarget.hostId &&
|
|
847
|
+
hostIdFromSessionId(input.sessionId) !== hostTarget.hostId) {
|
|
848
|
+
throw new CapabilityError('The relay session id and requested host disagree.', 'CONFLICT', {
|
|
849
|
+
reason: 'code-session-relay-host-conflict',
|
|
850
|
+
retryable: false,
|
|
851
|
+
details: { sessionId: input.sessionId, hostId: hostTarget.hostId }
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
return this.attach(input.sessionId);
|
|
855
|
+
}
|
|
856
|
+
return this.createOrGet({
|
|
857
|
+
...input,
|
|
858
|
+
operationId: input.operationId?.trim() || `initialize:${crypto.randomUUID()}`
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
async createOrGet(input) {
|
|
862
|
+
input = parseCodeSessionCreateOrGetInput(input);
|
|
863
|
+
const hostTarget = input.hostTarget;
|
|
864
|
+
if (!hostTarget || hostTarget.kind !== 'host' || !hostTarget.hostId || hostTarget.hostId === 'local') {
|
|
865
|
+
throw requestInvalid('hostTarget');
|
|
866
|
+
}
|
|
867
|
+
const hostId = parseRelayHostId(hostTarget.hostId);
|
|
868
|
+
if (input.workspacePath) {
|
|
869
|
+
throw requestInvalid('workspacePath');
|
|
870
|
+
}
|
|
871
|
+
input = {
|
|
872
|
+
...input,
|
|
873
|
+
runtimeConfig: { ...this.descriptor.runtimeConfig, ...input.runtimeConfig },
|
|
874
|
+
exposedPorts: input.exposedPorts ?? [...this.descriptor.exposedPorts]
|
|
875
|
+
};
|
|
876
|
+
const operation = await this.prepareOperation(input, hostId);
|
|
877
|
+
return this.withOperationLease(hostId, operation, async (leaseHolderPath) => {
|
|
878
|
+
let record = operation.record;
|
|
879
|
+
const update = async (patch) => {
|
|
880
|
+
record = await this.writeOperationRecord(hostId, operation.key, record, patch, leaseHolderPath);
|
|
881
|
+
operation.record = record;
|
|
882
|
+
};
|
|
883
|
+
if (record.stage === 'cleanup-pending') {
|
|
884
|
+
throw operationConflict(record.operationId, 'cleanup-pending');
|
|
885
|
+
}
|
|
886
|
+
if (record.stage === 'torn-down') {
|
|
887
|
+
throw operationConflict(record.operationId, 'torn-down');
|
|
888
|
+
}
|
|
889
|
+
if (record.stage === 'attention-required') {
|
|
890
|
+
throw operationConflict(record.operationId, 'attention');
|
|
891
|
+
}
|
|
892
|
+
if (record.stage === 'setup-intent' || record.stage === 'setup-running') {
|
|
893
|
+
await update({
|
|
894
|
+
stage: 'attention-required',
|
|
895
|
+
failure: 'setup start was recorded without a durable success receipt; refusing to rerun it'
|
|
896
|
+
});
|
|
897
|
+
throw operationConflict(record.operationId, 'attention');
|
|
898
|
+
}
|
|
899
|
+
const sessionId = record.sessionId;
|
|
900
|
+
const allocationFailure = await notifyAllocated(input, sessionId);
|
|
901
|
+
if (allocationFailure) {
|
|
902
|
+
this.log.warn('onAllocated callback failed', { sessionId, failure: allocationFailure });
|
|
903
|
+
}
|
|
904
|
+
if (record.stage === 'ready')
|
|
905
|
+
return this.attach(sessionId);
|
|
906
|
+
const dir = await this.sessionDir(sessionId);
|
|
907
|
+
const readyMetadata = await this.readPersistedSessionMetadata(sessionId);
|
|
908
|
+
if (readyMetadata?.operationId === record.operationId &&
|
|
909
|
+
readyMetadata.operationFingerprint === record.fingerprint) {
|
|
910
|
+
await update({ stage: 'ready', failure: undefined });
|
|
911
|
+
return this.attach(sessionId);
|
|
912
|
+
}
|
|
913
|
+
const stagingPath = record.stagingPath ?? `${await this.sessionRoot(hostId)}/.staging/${operation.key}/generation-${record.generation}`;
|
|
914
|
+
await update({ stagingPath });
|
|
915
|
+
const runner = this.gitRunner(hostId);
|
|
916
|
+
if (record.stage === 'reserved' || record.stage === 'source-preparing') {
|
|
917
|
+
await update({ stage: 'source-preparing' });
|
|
918
|
+
const prepared = await this.shell(hostId, `mkdir -p ${shellQuote(stagingPath)}`);
|
|
919
|
+
if (prepared.exitCode !== 0) {
|
|
920
|
+
throw hostUnavailable(hostId);
|
|
921
|
+
}
|
|
922
|
+
const gitDirectory = await this.shell(hostId, `test -d ${shellQuote(`${stagingPath}/.git`)}`);
|
|
923
|
+
const hasGit = gitDirectory.exitCode === 0;
|
|
924
|
+
if (input.cloneUrl) {
|
|
925
|
+
if (hasGit) {
|
|
926
|
+
const remote = await gitRaw(runner, stagingPath, ['remote', 'get-url', 'origin']);
|
|
927
|
+
if (remote.exitCode !== 0 || remote.stdout.trim() !== input.cloneUrl) {
|
|
928
|
+
await update({
|
|
929
|
+
stage: 'attention-required',
|
|
930
|
+
failure: 'the preserved staging repository does not match the requested clone URL'
|
|
931
|
+
});
|
|
932
|
+
throw operationConflict(record.operationId);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
const contents = await this.shell(hostId, `find ${shellQuote(stagingPath)} -mindepth 1 -maxdepth 1 -print -quit`);
|
|
937
|
+
if (contents.stdout.trim()) {
|
|
938
|
+
await update({
|
|
939
|
+
stage: 'attention-required',
|
|
940
|
+
failure: 'clone was interrupted and left an unverified staging workspace'
|
|
941
|
+
});
|
|
942
|
+
throw operationConflict(record.operationId);
|
|
943
|
+
}
|
|
944
|
+
const cloneInput = {
|
|
945
|
+
url: input.cloneUrl,
|
|
946
|
+
dir: stagingPath
|
|
947
|
+
};
|
|
948
|
+
if (input.checkoutRef)
|
|
949
|
+
cloneInput.branch = input.checkoutRef;
|
|
950
|
+
if (input.cloneCredential) {
|
|
951
|
+
cloneInput.credential = toGitCredential(input.cloneCredential);
|
|
952
|
+
}
|
|
953
|
+
const clone = await gitClone(runner, cloneInput);
|
|
954
|
+
if (clone.exitCode !== 0) {
|
|
955
|
+
await update({
|
|
956
|
+
stage: 'attention-required',
|
|
957
|
+
failure: `clone failed with exit code ${clone.exitCode}`
|
|
958
|
+
});
|
|
959
|
+
throw hostUnavailable(hostId);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
else if ((input.repositoryId || input.ephemeral) && !hasGit) {
|
|
964
|
+
const init = await gitInit(runner, stagingPath);
|
|
965
|
+
if (init.exitCode !== 0) {
|
|
966
|
+
await update({
|
|
967
|
+
stage: 'attention-required',
|
|
968
|
+
failure: `git init failed with exit code ${init.exitCode}`
|
|
969
|
+
});
|
|
970
|
+
throw hostUnavailable(hostId);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
await update({ stage: 'source-ready', failure: undefined });
|
|
974
|
+
}
|
|
975
|
+
const setupScript = input.runtimeConfig?.setupScript?.trim();
|
|
976
|
+
if (setupScript && record.stage === 'source-ready') {
|
|
977
|
+
await update({ stage: 'setup-intent' });
|
|
978
|
+
await update({ stage: 'setup-running' });
|
|
979
|
+
let setup;
|
|
980
|
+
try {
|
|
981
|
+
setup = await this.shell(hostId, setupScript, { cwd: stagingPath, env: input.initialEnv });
|
|
982
|
+
}
|
|
983
|
+
catch (caught) {
|
|
984
|
+
const failure = CaughtFailureSchema.parse(caught);
|
|
985
|
+
const facts = valueFreeFailure(failure);
|
|
986
|
+
await update({ stage: 'attention-required', failure: `setup failed: ${facts.name}` });
|
|
987
|
+
throw setupFailed(hostId, valueFreeFailure(failure));
|
|
988
|
+
}
|
|
989
|
+
if (setup.exitCode !== 0) {
|
|
990
|
+
await update({
|
|
991
|
+
stage: 'attention-required',
|
|
992
|
+
failure: `setup failed with exit code ${setup.exitCode}`
|
|
993
|
+
});
|
|
994
|
+
throw setupFailed(hostId);
|
|
995
|
+
}
|
|
996
|
+
await update({ stage: 'setup-succeeded', failure: undefined });
|
|
997
|
+
}
|
|
998
|
+
else if (!setupScript && record.stage === 'source-ready') {
|
|
999
|
+
await update({ stage: 'setup-succeeded', failure: undefined });
|
|
1000
|
+
}
|
|
1001
|
+
if (input.exposedPorts?.length) {
|
|
1002
|
+
this.log.warn?.('exposed ports are not supported on fleet hosts yet — skipping', {
|
|
1003
|
+
sessionId,
|
|
1004
|
+
ports: input.exposedPorts
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
await this.persistSessionMetadata(sessionId, {
|
|
1008
|
+
sessionId,
|
|
1009
|
+
operationId: record.operationId,
|
|
1010
|
+
operationFingerprint: record.fingerprint,
|
|
1011
|
+
createdAt: record.createdAt,
|
|
1012
|
+
hostTarget,
|
|
1013
|
+
repositoryId: input.repositoryId,
|
|
1014
|
+
cloneUrl: input.cloneUrl,
|
|
1015
|
+
ephemeral: input.ephemeral,
|
|
1016
|
+
checkoutRef: input.checkoutRef
|
|
1017
|
+
}, stagingPath);
|
|
1018
|
+
const authoritative = await this.readOperationRecord(hostId, operation.key);
|
|
1019
|
+
if (!authoritative || authoritative.generation !== record.generation) {
|
|
1020
|
+
throw operationConflict(record.operationId);
|
|
1021
|
+
}
|
|
1022
|
+
const published = await this.shell(hostId, `if test -e ${shellQuote(dir)}; then exit 17; else mkdir -p ${shellQuote(await this.sessionRoot(hostId))} && mv ${shellQuote(stagingPath)} ${shellQuote(dir)}; fi`);
|
|
1023
|
+
if (published.exitCode !== 0) {
|
|
1024
|
+
if (published.exitCode !== 17)
|
|
1025
|
+
throw hostUnavailable(hostId);
|
|
1026
|
+
const existing = await this.readPersistedSessionMetadata(sessionId);
|
|
1027
|
+
if (existing?.operationId !== record.operationId ||
|
|
1028
|
+
existing.operationFingerprint !== record.fingerprint) {
|
|
1029
|
+
await update({
|
|
1030
|
+
stage: 'attention-required',
|
|
1031
|
+
failure: 'the final workspace exists but was not proven to belong to this operation'
|
|
1032
|
+
});
|
|
1033
|
+
throw operationConflict(record.operationId);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (input.repositoryId)
|
|
1037
|
+
this.sessionRepositoryIds.set(sessionId, input.repositoryId);
|
|
1038
|
+
await update({ stage: 'ready', failure: undefined });
|
|
1039
|
+
return {
|
|
1040
|
+
sessionId,
|
|
1041
|
+
operationId: record.operationId,
|
|
1042
|
+
workspacePath: dir,
|
|
1043
|
+
createdAt: record.createdAt,
|
|
1044
|
+
hostTarget,
|
|
1045
|
+
repositoryId: input.repositoryId,
|
|
1046
|
+
ephemeral: input.ephemeral
|
|
1047
|
+
};
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
async attach(sessionId) {
|
|
1051
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1052
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1053
|
+
const liveness = await this.exec(hostId, { command: 'true', timeout: 10_000 });
|
|
1054
|
+
if (liveness.exitCode !== 0) {
|
|
1055
|
+
throw hostUnavailable(hostId);
|
|
1056
|
+
}
|
|
1057
|
+
const metadata = await this.requireReadySessionMetadata(sessionId);
|
|
1058
|
+
if (metadata.repositoryId) {
|
|
1059
|
+
this.sessionRepositoryIds.set(sessionId, metadata.repositoryId);
|
|
1060
|
+
}
|
|
1061
|
+
return {
|
|
1062
|
+
sessionId,
|
|
1063
|
+
operationId: metadata.operationId,
|
|
1064
|
+
workspacePath: await this.sessionDir(sessionId),
|
|
1065
|
+
createdAt: metadata.createdAt,
|
|
1066
|
+
hostTarget: metadata.hostTarget ?? { kind: 'host', hostId },
|
|
1067
|
+
repositoryId: metadata.repositoryId,
|
|
1068
|
+
ephemeral: metadata.ephemeral
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
/** Persist the paused flag; the remote workspace is kept and `runCommand` refuses until resume. */
|
|
1072
|
+
async pause(sessionId) {
|
|
1073
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1074
|
+
const metadata = await this.requireReadySessionMetadata(sessionId);
|
|
1075
|
+
if (!metadata.pausedAt) {
|
|
1076
|
+
await this.persistSessionMetadata(sessionId, { ...metadata, pausedAt: new Date().toISOString() });
|
|
1077
|
+
}
|
|
1078
|
+
return this.attach(sessionId);
|
|
1079
|
+
}
|
|
1080
|
+
async resume(sessionId) {
|
|
1081
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1082
|
+
const metadata = await this.requireReadySessionMetadata(sessionId);
|
|
1083
|
+
if (metadata.pausedAt) {
|
|
1084
|
+
await this.persistSessionMetadata(sessionId, { ...metadata, pausedAt: undefined });
|
|
1085
|
+
}
|
|
1086
|
+
return this.attach(sessionId);
|
|
1087
|
+
}
|
|
1088
|
+
async runCommand(sessionId, input) {
|
|
1089
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1090
|
+
input = parseRunCommandInput(input);
|
|
1091
|
+
if (input.background) {
|
|
1092
|
+
throw requestInvalid('background');
|
|
1093
|
+
}
|
|
1094
|
+
const ready = await this.requireReadySessionMetadata(sessionId);
|
|
1095
|
+
if (ready.pausedAt)
|
|
1096
|
+
throw sessionPaused(sessionId);
|
|
1097
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1098
|
+
const dir = await this.sessionDir(sessionId);
|
|
1099
|
+
const cwd = input.cwd ? `${dir}/${normalizeRelativePath(dir, input.cwd)}` : dir;
|
|
1100
|
+
const [command, ...args] = input.command;
|
|
1101
|
+
const execOptions = {
|
|
1102
|
+
command,
|
|
1103
|
+
args,
|
|
1104
|
+
cwd,
|
|
1105
|
+
timeout: input.timeoutMs ?? this.descriptor.timeoutMs
|
|
1106
|
+
};
|
|
1107
|
+
if (input.env)
|
|
1108
|
+
execOptions.env = input.env;
|
|
1109
|
+
const result = await this.exec(hostId, execOptions);
|
|
1110
|
+
return {
|
|
1111
|
+
exitCode: result.exitCode,
|
|
1112
|
+
stdout: result.stdout,
|
|
1113
|
+
stderr: result.stderr,
|
|
1114
|
+
timedOut: result.timedOut,
|
|
1115
|
+
durationMs: result.durationMs
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async listBackgroundProcesses(sessionId) {
|
|
1119
|
+
parseCodeSessionId(sessionId);
|
|
1120
|
+
throw requestInvalid('background');
|
|
1121
|
+
}
|
|
1122
|
+
async stopBackgroundProcess(sessionId, procId, signal) {
|
|
1123
|
+
parseCodeSessionId(sessionId);
|
|
1124
|
+
parseProcessId(procId);
|
|
1125
|
+
parseProcessSignal(signal);
|
|
1126
|
+
throw requestInvalid('background');
|
|
1127
|
+
}
|
|
1128
|
+
async readFile(sessionId, path) {
|
|
1129
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1130
|
+
path = parseCodeSessionPath(path);
|
|
1131
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1132
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1133
|
+
const dir = await this.sessionDir(sessionId);
|
|
1134
|
+
const result = await this.exec(hostId, {
|
|
1135
|
+
command: 'cat',
|
|
1136
|
+
args: [`${dir}/${normalizeRelativePath(dir, path)}`]
|
|
1137
|
+
});
|
|
1138
|
+
if (result.exitCode !== 0) {
|
|
1139
|
+
throw hostUnavailable(hostId);
|
|
1140
|
+
}
|
|
1141
|
+
return result.stdout;
|
|
1142
|
+
}
|
|
1143
|
+
async writeFile(sessionId, path, content) {
|
|
1144
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1145
|
+
({ path, content } = parseWriteFileInput(path, content));
|
|
1146
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1147
|
+
const dir = await this.sessionDir(sessionId);
|
|
1148
|
+
await this.writeRemoteFile(sessionId, `${dir}/${normalizeRelativePath(dir, path)}`, content);
|
|
1149
|
+
}
|
|
1150
|
+
async patchFile(sessionId, input) {
|
|
1151
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1152
|
+
input = parsePatchFileInput(input);
|
|
1153
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1154
|
+
const dir = await this.sessionDir(sessionId);
|
|
1155
|
+
normalizeRelativePath(dir, input.path);
|
|
1156
|
+
const result = await this.applyDiff(sessionId, input.diff, false);
|
|
1157
|
+
if (!result.ok) {
|
|
1158
|
+
throw hostUnavailable(hostIdFromSessionId(sessionId));
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async gitApply(sessionId, input) {
|
|
1162
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1163
|
+
input = parseGitApplyInput(input);
|
|
1164
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1165
|
+
return this.applyDiff(sessionId, input.diff, input.check ?? false);
|
|
1166
|
+
}
|
|
1167
|
+
async applyDiff(sessionId, diff, check) {
|
|
1168
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1169
|
+
const dir = await this.sessionDir(sessionId);
|
|
1170
|
+
const patchPath = `${dir}/.vibes-patch-${newSessionId(hostId).split('.')[2]}.diff`;
|
|
1171
|
+
await this.writeRemoteFile(sessionId, patchPath, diff);
|
|
1172
|
+
return this.withRequiredCleanup(hostId, 'temporary-patch', async () => {
|
|
1173
|
+
const args = check ? ['apply', '--check', patchPath] : ['apply', patchPath];
|
|
1174
|
+
const result = await gitRaw(this.gitRunner(hostId), dir, args);
|
|
1175
|
+
return {
|
|
1176
|
+
ok: result.exitCode === 0,
|
|
1177
|
+
stdout: result.stdout,
|
|
1178
|
+
stderr: result.stderr,
|
|
1179
|
+
exitCode: result.exitCode
|
|
1180
|
+
};
|
|
1181
|
+
}, async () => {
|
|
1182
|
+
// Direct `rm <path>` (no shell and no force flag) passes the daemon's friction gate.
|
|
1183
|
+
const removed = await this.exec(hostId, { command: 'rm', args: [patchPath] });
|
|
1184
|
+
if (removed.exitCode !== 0)
|
|
1185
|
+
throw cleanupUnconfirmed('temporary-patch-remove', []);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
async gitStatus(sessionId) {
|
|
1189
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1190
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1191
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1192
|
+
return gitStatus(this.gitRunner(hostId), await this.sessionDir(sessionId));
|
|
1193
|
+
}
|
|
1194
|
+
async gitCommit(sessionId, input, options = {}) {
|
|
1195
|
+
const signal = parseGitCommitOptions(options).signal;
|
|
1196
|
+
signal?.throwIfAborted();
|
|
1197
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1198
|
+
input = parseGitCommitInput(input);
|
|
1199
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1200
|
+
signal?.throwIfAborted();
|
|
1201
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1202
|
+
const dir = await this.sessionDir(sessionId);
|
|
1203
|
+
const commitInput = { message: input.message };
|
|
1204
|
+
if (input.author)
|
|
1205
|
+
commitInput.author = input.author;
|
|
1206
|
+
if (input.paths) {
|
|
1207
|
+
commitInput.paths = input.paths.map((p) => normalizeRelativePath(dir, p));
|
|
1208
|
+
}
|
|
1209
|
+
return gitCommit(this.gitRunner(hostId, signal), dir, commitInput);
|
|
1210
|
+
}
|
|
1211
|
+
async gitPush(sessionId, input = {}) {
|
|
1212
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1213
|
+
input = parseGitPushInput(input);
|
|
1214
|
+
await this.requireReadySessionMetadata(sessionId);
|
|
1215
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1216
|
+
let credential = input.credential;
|
|
1217
|
+
if (!credential && this.pushCredentialResolver) {
|
|
1218
|
+
const repositoryId = this.sessionRepositoryIds.get(sessionId);
|
|
1219
|
+
if (repositoryId) {
|
|
1220
|
+
credential = (await this.pushCredentialResolver(repositoryId)) ?? undefined;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const pushInput = {};
|
|
1224
|
+
if (input.remote)
|
|
1225
|
+
pushInput.remote = input.remote;
|
|
1226
|
+
if (input.branch)
|
|
1227
|
+
pushInput.branch = input.branch;
|
|
1228
|
+
if (input.setUpstream)
|
|
1229
|
+
pushInput.setUpstream = true;
|
|
1230
|
+
if (credential)
|
|
1231
|
+
pushInput.credential = toGitCredential(credential);
|
|
1232
|
+
await gitPush(this.gitRunner(hostId), await this.sessionDir(sessionId), pushInput);
|
|
1233
|
+
}
|
|
1234
|
+
async exposePort(sessionId, input) {
|
|
1235
|
+
parseCodeSessionId(sessionId);
|
|
1236
|
+
parseExposePortInput(input);
|
|
1237
|
+
throw new CapabilityError('The relay code-session host cannot expose a preview port.', 'UNAVAILABLE', {
|
|
1238
|
+
reason: 'code-session-host-unavailable',
|
|
1239
|
+
retryable: false
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
/** Find durable ownership without depending on metadata inside a deleting tree. */
|
|
1243
|
+
async teardownOperation(sessionId) {
|
|
1244
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1245
|
+
const root = await this.sessionRoot(hostId);
|
|
1246
|
+
const metadata = await this.readPersistedSessionMetadata(sessionId);
|
|
1247
|
+
if (metadata && (metadata.sessionId !== sessionId || metadata.hostTarget.kind !== 'host' || metadata.hostTarget.hostId !== hostId)) {
|
|
1248
|
+
throw operationConflict('teardown');
|
|
1249
|
+
}
|
|
1250
|
+
const directory = `${root}/.operations`;
|
|
1251
|
+
const listed = await this.shell(hostId, `if test -d ${shellQuote(directory)}; then find ${shellQuote(directory)} -mindepth 2 -maxdepth 2 -name operation.json -print; fi`);
|
|
1252
|
+
if (listed.exitCode !== 0)
|
|
1253
|
+
throw hostUnavailable(hostId);
|
|
1254
|
+
const candidates = [];
|
|
1255
|
+
for (const path of listed.stdout.split('\n').filter(Boolean)) {
|
|
1256
|
+
const key = path.slice(directory.length + 1, -'/operation.json'.length);
|
|
1257
|
+
if (path !== `${directory}/${key}/operation.json` || !/^[0-9a-f]{64}$/.test(key))
|
|
1258
|
+
throw metadataInvalid('operation');
|
|
1259
|
+
const record = await this.readOperationRecord(hostId, key);
|
|
1260
|
+
if (!record)
|
|
1261
|
+
throw metadataInvalid('operation');
|
|
1262
|
+
if (record.sessionId === sessionId || sessionId === `rly.${hostId}.${key.slice(0, 12)}`)
|
|
1263
|
+
candidates.push({ key, record });
|
|
1264
|
+
}
|
|
1265
|
+
if (candidates.length > 1)
|
|
1266
|
+
throw operationConflict('teardown');
|
|
1267
|
+
if (candidates.length === 1) {
|
|
1268
|
+
const operation = candidates[0];
|
|
1269
|
+
const { key, record } = operation;
|
|
1270
|
+
if (record.sessionId !== sessionId || await codeSessionOperationKey(record.operationId) !== key ||
|
|
1271
|
+
(metadata && metadata.operationId && (metadata.operationId !== record.operationId || metadata.operationFingerprint !== record.fingerprint))) {
|
|
1272
|
+
throw operationConflict('teardown');
|
|
1273
|
+
}
|
|
1274
|
+
const markers = `${await this.operationDirectory(hostId, key)}/fingerprints`;
|
|
1275
|
+
const checked = await this.shell(hostId, `find ${shellQuote(markers)} -mindepth 1 -maxdepth 1 -print`);
|
|
1276
|
+
if (checked.exitCode !== 0 || checked.stdout.trim() !== `${markers}/${record.fingerprint.slice('sha256:'.length)}`)
|
|
1277
|
+
throw operationConflict('teardown');
|
|
1278
|
+
return operation;
|
|
1279
|
+
}
|
|
1280
|
+
// Older initialized sessions have metadata but no operation record. Adopt
|
|
1281
|
+
// their validated identity into the same durable owner before moving them.
|
|
1282
|
+
if (!metadata || metadata.operationId)
|
|
1283
|
+
throw operationConflict('teardown');
|
|
1284
|
+
const operationId = `teardown:${sessionId}`;
|
|
1285
|
+
const key = await codeSessionOperationKey(operationId);
|
|
1286
|
+
const fingerprint = await codeSessionOperationFingerprint({ operationId, hostTarget: metadata.hostTarget }, { authorityId: this.descriptor.id });
|
|
1287
|
+
const now = new Date().toISOString();
|
|
1288
|
+
const record = { operationId, fingerprint, sessionId, generation: 1, stage: 'ready', createdAt: now, updatedAt: now };
|
|
1289
|
+
return this.claimOperationRecord(hostId, key, record);
|
|
1290
|
+
}
|
|
1291
|
+
async teardown(sessionId) {
|
|
1292
|
+
sessionId = parseCodeSessionId(sessionId);
|
|
1293
|
+
const hostId = hostIdFromSessionId(sessionId);
|
|
1294
|
+
const dir = await this.sessionDir(sessionId);
|
|
1295
|
+
const operation = await this.teardownOperation(sessionId);
|
|
1296
|
+
await this.withOperationLease(hostId, operation, async (leaseHolderPath) => {
|
|
1297
|
+
let record = operation.record;
|
|
1298
|
+
const update = async (patch) => {
|
|
1299
|
+
record = await this.writeOperationRecord(hostId, operation.key, record, patch, leaseHolderPath);
|
|
1300
|
+
operation.record = record;
|
|
1301
|
+
};
|
|
1302
|
+
const root = await this.sessionRoot(hostId);
|
|
1303
|
+
const quarantine = `${await this.operationDirectory(hostId, operation.key)}/cleanup-${record.generation}/workspace`;
|
|
1304
|
+
if (record.stage === 'torn-down')
|
|
1305
|
+
return;
|
|
1306
|
+
if (record.stage !== 'cleanup-pending') {
|
|
1307
|
+
if (record.stage !== 'ready')
|
|
1308
|
+
throw operationConflict('teardown');
|
|
1309
|
+
const metadata = await this.readPersistedSessionMetadata(sessionId);
|
|
1310
|
+
if (!metadata || metadata.sessionId !== sessionId || metadata.hostTarget.kind !== 'host' || metadata.hostTarget.hostId !== hostId ||
|
|
1311
|
+
(metadata.operationId && (metadata.operationId !== record.operationId || metadata.operationFingerprint !== record.fingerprint)))
|
|
1312
|
+
throw operationConflict('teardown');
|
|
1313
|
+
await update({ stage: 'cleanup-pending', failure: undefined, cleanup: {
|
|
1314
|
+
path: quarantine, phase: 'intent', relay: { authorityId: this.descriptor.id, hostId, sessionRoot: root, legacy: !metadata.operationId }
|
|
1315
|
+
} });
|
|
1316
|
+
}
|
|
1317
|
+
const cleanup = record.cleanup;
|
|
1318
|
+
if (!cleanup || cleanup.path !== quarantine || cleanup.relay?.authorityId !== this.descriptor.id ||
|
|
1319
|
+
cleanup.relay.hostId !== hostId || cleanup.relay.sessionRoot !== root || record.sessionId !== sessionId ||
|
|
1320
|
+
(!cleanup.relay.legacy && sessionId !== `rly.${hostId}.${operation.key.slice(0, 12)}`))
|
|
1321
|
+
throw operationConflict('teardown');
|
|
1322
|
+
// Read back authorization before crossing the destructive boundary.
|
|
1323
|
+
const durable = await this.readOperationRecord(hostId, operation.key);
|
|
1324
|
+
if (!durable || JSON.stringify(durable) !== JSON.stringify(record))
|
|
1325
|
+
throw operationConflict('teardown');
|
|
1326
|
+
try {
|
|
1327
|
+
if (cleanup.phase === 'intent') {
|
|
1328
|
+
const exists = await this.shell(hostId, `test -e ${shellQuote(dir)} || test -L ${shellQuote(dir)}`);
|
|
1329
|
+
if (exists.exitCode !== 0 && exists.exitCode !== 1)
|
|
1330
|
+
throw hostUnavailable(hostId);
|
|
1331
|
+
if (exists.exitCode === 0) {
|
|
1332
|
+
const metadata = await this.readPersistedSessionMetadata(sessionId);
|
|
1333
|
+
if (!metadata || metadata.sessionId !== sessionId || metadata.hostTarget.kind !== 'host' || metadata.hostTarget.hostId !== hostId ||
|
|
1334
|
+
(cleanup.relay.legacy ? !!metadata.operationId : metadata.operationId !== record.operationId || metadata.operationFingerprint !== record.fingerprint))
|
|
1335
|
+
throw operationConflict('teardown');
|
|
1336
|
+
}
|
|
1337
|
+
const moved = await this.shell(hostId, `if test -e ${shellQuote(dir)} || test -L ${shellQuote(dir)}; then test ! -e ${shellQuote(quarantine)} && test ! -L ${shellQuote(quarantine)} && test ! -L ${shellQuote(dir)} && mkdir -p ${shellQuote(quarantine.slice(0, -'/workspace'.length))} && mv ${shellQuote(dir)} ${shellQuote(quarantine)}; fi`);
|
|
1338
|
+
if (moved.exitCode !== 0)
|
|
1339
|
+
throw cleanupUnconfirmed('session-teardown', []);
|
|
1340
|
+
await update({ cleanup: { ...cleanup, phase: 'quarantined' } });
|
|
1341
|
+
}
|
|
1342
|
+
const result = await this.shell(hostId, `if test -e ${shellQuote(quarantine)}; then test ! -L ${shellQuote(quarantine)} && find ${shellQuote(quarantine)} -depth -mindepth 1 -delete && rmdir ${shellQuote(quarantine)}; fi; test ! -e ${shellQuote(quarantine)} && test ! -L ${shellQuote(quarantine)} && test ! -e ${shellQuote(dir)} && test ! -L ${shellQuote(dir)}`);
|
|
1343
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
1344
|
+
throw cleanupUnconfirmed('session-teardown', []);
|
|
1345
|
+
const confirmed = await this.shell(hostId, `test ! -e ${shellQuote(quarantine)} && test ! -L ${shellQuote(quarantine)} && test ! -e ${shellQuote(dir)} && test ! -L ${shellQuote(dir)}`);
|
|
1346
|
+
if (confirmed.exitCode !== 0 || confirmed.timedOut)
|
|
1347
|
+
throw cleanupUnconfirmed('session-teardown', []);
|
|
1348
|
+
await update({ stage: 'torn-down', cleanup: { ...cleanup, phase: 'removed' }, failure: undefined });
|
|
1349
|
+
}
|
|
1350
|
+
catch (caught) {
|
|
1351
|
+
throw cleanupUnconfirmed('session-teardown', [valueFreeFailure(CaughtFailureSchema.parse(caught))]);
|
|
1352
|
+
}
|
|
1353
|
+
this.sessionRepositoryIds.delete(sessionId);
|
|
1354
|
+
}, false);
|
|
1355
|
+
}
|
|
1356
|
+
/** Close every cached relay connection (tests + orderly shutdown). */
|
|
1357
|
+
async close() {
|
|
1358
|
+
const handles = await Promise.allSettled(this.hosts.values());
|
|
1359
|
+
this.hosts.clear();
|
|
1360
|
+
const failures = [];
|
|
1361
|
+
const closing = [];
|
|
1362
|
+
for (const settled of handles) {
|
|
1363
|
+
if (settled.status === 'fulfilled') {
|
|
1364
|
+
closing.push(settled.value.connection.close());
|
|
1365
|
+
}
|
|
1366
|
+
else {
|
|
1367
|
+
failures.push(valueFreeFailure(CaughtFailureSchema.parse(settled.reason)));
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const closed = await Promise.allSettled(closing);
|
|
1371
|
+
for (const settled of closed) {
|
|
1372
|
+
if (settled.status === 'rejected') {
|
|
1373
|
+
failures.push(valueFreeFailure(CaughtFailureSchema.parse(settled.reason)));
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (failures.length > 0)
|
|
1377
|
+
throw cleanupUnconfirmed('relay-close', failures);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
import relayCodeSessionDescriptor from "./agent-code-session-relay.descriptor.js";
|
|
1381
|
+
class RelayCodeSessionImpl extends RelayCodeSession {
|
|
1382
|
+
constructor(descriptor, context) {
|
|
1383
|
+
super(AgentCodeSessionDescriptorSchema.parse(descriptor), context.runtime);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
export default createRuntimeImplementation({
|
|
1387
|
+
kind: 'agent/code-session',
|
|
1388
|
+
id: relayCodeSessionDescriptor.id,
|
|
1389
|
+
constraint: { hardware: ['cloud'] },
|
|
1390
|
+
implementation: RelayCodeSessionImpl
|
|
1391
|
+
});
|
|
1392
|
+
//# sourceMappingURL=agent-code-session-relay.impl.js.map
|