@buyong/pi-devin 0.1.5 → 0.1.6
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/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { clampThinkingLevel, type AssistantMessageEventStream, type Model, type SimpleStreamOptions, type TranscriptContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Cause, Effect, Stream } from "effect";
|
|
3
|
+
import { assertCanInfer } from "../../../core/auth/access.ts";
|
|
4
|
+
import { abortWith } from "../../../core/cancellation.ts";
|
|
5
|
+
import { Cancelled, isTransientConnectionFailure, RPCError, toDevinFailure, type DevinFailure } from "../../../core/errors.ts";
|
|
6
|
+
import { ChatAccumulator } from "../../../core/inference/accumulator.ts";
|
|
7
|
+
import { chatRequest } from "../../../core/inference/request.ts";
|
|
8
|
+
import type { BackendModelSelection, EffectiveThinkingLevel } from "../../../core/models/variants.ts";
|
|
9
|
+
import { endpoint } from "../../../core/protocol/endpoint.ts";
|
|
10
|
+
import { CHAT_METHOD } from "../../../core/protocol/protobuf.ts";
|
|
11
|
+
import { safeError } from "../../../core/redaction.ts";
|
|
12
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
13
|
+
import { failWith, object, requiredString, tokenCount } from "../../../core/validation.ts";
|
|
14
|
+
import { DevinResponseStream } from "../../adapters/pi/response-stream.ts";
|
|
15
|
+
import { toDevinTranscript } from "../../adapters/pi/transcript.ts";
|
|
16
|
+
import type { AccountsShape } from "../accounts/types.ts";
|
|
17
|
+
import type { AuthStateShape, CredentialGuardShape } from "../auth/types.ts";
|
|
18
|
+
import { isFusionModelID } from "../catalog/constants.ts";
|
|
19
|
+
import { hasExtendedPromptCacheRetention } from "../catalog/projection.ts";
|
|
20
|
+
import type { DevinAPI, ModelCatalogShape } from "../catalog/types.ts";
|
|
21
|
+
import type { DevinClientsShape } from "../clients/types.ts";
|
|
22
|
+
import type { ProviderLifetimeShape } from "../lifetime/types.ts";
|
|
23
|
+
import type { DevinLifecycle } from "../runs/lifecycle.ts";
|
|
24
|
+
import { FUSION_LEAD_GUIDANCE } from "../sidekick/prompts.ts";
|
|
25
|
+
|
|
26
|
+
/** Services one request uses, resolved once when the inference service is built. */
|
|
27
|
+
export interface InferenceDependencies {
|
|
28
|
+
auth: AuthStateShape;
|
|
29
|
+
guard: CredentialGuardShape;
|
|
30
|
+
catalog: ModelCatalogShape;
|
|
31
|
+
accounts: AccountsShape;
|
|
32
|
+
clients: DevinClientsShape;
|
|
33
|
+
runs: DevinLifecycle;
|
|
34
|
+
lifetime: ProviderLifetimeShape;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One provider call: Pi's event stream, returned at once, and the request that feeds it. */
|
|
38
|
+
export interface InferenceRequest {
|
|
39
|
+
stream: AssistantMessageEventStream;
|
|
40
|
+
/** Never fails: every ending closes the stream with exactly one terminal event. */
|
|
41
|
+
run: Effect.Effect<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Connect codes of passing server trouble, in the words Pi's host retry matches. */
|
|
45
|
+
const CONNECT_RETRY_LABELS: Readonly<Record<string, string>> = {
|
|
46
|
+
resource_exhausted: "Rate limit", internal: "Internal error", deadline_exceeded: "Request timed out",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pi decides host retries from the message text alone. Node and Connect report failures as codes ("read ECONNRESET",
|
|
51
|
+
* "resource_exhausted: …") and an error body replaces the HTTP status, so the status or code is kept in words Pi matches.
|
|
52
|
+
* The original detail stays, so Pi's rules against retrying quota and billing errors still apply.
|
|
53
|
+
*/
|
|
54
|
+
function hostRetryableMessage(error: unknown, message: string): string {
|
|
55
|
+
if (isTransientConnectionFailure(error)) return `Connection error (${message})`;
|
|
56
|
+
if (!(error instanceof RPCError)) return message;
|
|
57
|
+
if (error.httpStatus !== undefined && error.httpStatus >= 400) return message.includes(String(error.httpStatus)) ? message : `HTTP ${error.httpStatus} (${message})`;
|
|
58
|
+
const label = CONNECT_RETRY_LABELS[error.code];
|
|
59
|
+
return label ? `${label} (${message})` : message;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Like Pi's built-in providers: the caller option wins; otherwise only PI_CACHE_RETENTION=long (provider env first) means long retention. */
|
|
63
|
+
function resolveCacheRetention(options: SimpleStreamOptions): NonNullable<SimpleStreamOptions["cacheRetention"]> {
|
|
64
|
+
if (options.cacheRetention) return options.cacheRetention;
|
|
65
|
+
return (options.env?.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION) === "long" ? "long" : "short";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A host hook may return a value or a promise and may throw; it is withdrawn when the request stops. */
|
|
69
|
+
const hook = <A>(evaluate: () => A | PromiseLike<A>) => Effect.tryPromise({ try: async () => await evaluate(), catch: toDevinFailure });
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Prepares one request synchronously: the response stream (with its start event), the sign-in generation
|
|
73
|
+
* and the provider lifetime current at the call. The returned `run` sends the request and reports into the stream.
|
|
74
|
+
*/
|
|
75
|
+
export const prepareRequest = (deps: InferenceDependencies, model: Model<DevinAPI>, context: TranscriptContext, requestOptions: SimpleStreamOptions = {}): Effect.Effect<InferenceRequest> => Effect.gen(function* () {
|
|
76
|
+
const effectiveLevel = (requestOptions.reasoning ?? clampThinkingLevel(model, "off")) as EffectiveThinkingLevel;
|
|
77
|
+
const response = new DevinResponseStream(model, "");
|
|
78
|
+
const accumulator = new ChatAccumulator();
|
|
79
|
+
const generation = yield* deps.auth.generation;
|
|
80
|
+
const lifetime = yield* deps.lifetime.current;
|
|
81
|
+
let credential: Credential | undefined, actualUID = "", isRedacted = false;
|
|
82
|
+
|
|
83
|
+
const run = Effect.gen(function* () {
|
|
84
|
+
const current: Credential = {
|
|
85
|
+
version: 1, sessionToken: yield* requiredString(requestOptions.apiKey, "Devin account authentication (/login required)"),
|
|
86
|
+
apiServerURL: yield* endpoint(model.baseUrl), createdAt: new Date().toISOString(),
|
|
87
|
+
};
|
|
88
|
+
credential = current;
|
|
89
|
+
// Pi can override the OAuth value with an apiKey in models.json or a caller option. Only OAuth sessions are accepted.
|
|
90
|
+
yield* deps.auth.assertOAuthSession(current);
|
|
91
|
+
yield* deps.auth.assertUsable(current);
|
|
92
|
+
let selected = yield* deps.catalog.resolve(model.id, effectiveLevel, current);
|
|
93
|
+
actualUID = selected.uid;
|
|
94
|
+
response.message.providerThinkingLevel = actualUID;
|
|
95
|
+
// Retries belong to Pi and the only transport is a Connect stream, so `maxRetries` and `transport` preferences are ignored.
|
|
96
|
+
const cacheRetention = resolveCacheRetention(requestOptions);
|
|
97
|
+
const assertCacheRetention = (selection: BackendModelSelection) => cacheRetention === "long" && !hasExtendedPromptCacheRetention(selection.variant)
|
|
98
|
+
? failWith("Long cache lifetime is not confirmed for this Devin model. Long retention is supported only for models whose catalog Prompt Cache Retention is 24h.")
|
|
99
|
+
: Effect.void;
|
|
100
|
+
yield* assertCacheRetention(selected);
|
|
101
|
+
|
|
102
|
+
// The per-request account check (permission and quota) runs here, outside Pi's shared credential lock.
|
|
103
|
+
const known = yield* deps.accounts.get(current);
|
|
104
|
+
yield* deps.accounts.check(current, {
|
|
105
|
+
expectedGeneration: generation,
|
|
106
|
+
connection: { fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs },
|
|
107
|
+
beforeRecord: deps.auth.assertUsable(current),
|
|
108
|
+
}).pipe(
|
|
109
|
+
Effect.flatMap((outcome) => outcome === "stale" ? Effect.fail(new Cancelled({ message: "The Devin authentication generation changed." })) : Effect.void),
|
|
110
|
+
// A check that cannot reach the server keeps the last known account; the model request reports its own failure.
|
|
111
|
+
Effect.catch((error) => known && isTransientConnectionFailure(error) ? Effect.void : Effect.fail(error)),
|
|
112
|
+
);
|
|
113
|
+
const account = yield* deps.accounts.get(current);
|
|
114
|
+
if (!account) return yield* failWith("The Devin account check was interrupted.");
|
|
115
|
+
yield* assertCanInfer(account.status);
|
|
116
|
+
|
|
117
|
+
const lease = yield* Effect.acquireRelease(deps.runs.acquire(account.ownerHash), (acquired) => acquired.release);
|
|
118
|
+
const transcript = yield* toDevinTranscript(context, selected.model);
|
|
119
|
+
if (isFusionModelID(model.id)) transcript.systemPrompt = [transcript.systemPrompt, FUSION_LEAD_GUIDANCE].filter(Boolean).join("\n\n");
|
|
120
|
+
const maximum = requestOptions.maxTokens === undefined ? selected.model.maxOutputTokens : yield* tokenCount(requestOptions.maxTokens, "maxTokens");
|
|
121
|
+
if (maximum < 1 || maximum > selected.model.maxOutputTokens) return yield* failWith("The output limit is outside the selected Devin model range.");
|
|
122
|
+
const client = yield* deps.clients.open(current, { fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs,
|
|
123
|
+
onRequest: (attempt) => attempt.isMetered ? lease.assertAttempt : Effect.void,
|
|
124
|
+
});
|
|
125
|
+
yield* lease.addCleanup(client.close);
|
|
126
|
+
const request = chatRequest({ metadata: yield* client.metadata(), model: selected.uid, ...transcript, maxTokens: maximum,
|
|
127
|
+
cascadeId: lease.cascadeId, trajectoryId: lease.trajectoryId, stepIndex: lease.stepIndex,
|
|
128
|
+
executionId: lease.executionId, clientSessionId: requestOptions.sessionId ?? lease.sessionId,
|
|
129
|
+
isAuxiliary: lease.isAuxiliary, isRecovery: lease.isRecovery, includeContext: false,
|
|
130
|
+
});
|
|
131
|
+
if (requestOptions.temperature !== undefined) {
|
|
132
|
+
if (!Number.isFinite(requestOptions.temperature) || requestOptions.temperature < 0) return yield* failWith("temperature must be a finite value of 0 or more.");
|
|
133
|
+
(yield* object(request.configuration, "configuration")).temperature = requestOptions.temperature;
|
|
134
|
+
}
|
|
135
|
+
if (cacheRetention === "none") for (const prompt of request.chatMessagePrompts as JsonObject[]) delete prompt.promptCacheOptions;
|
|
136
|
+
const identity = JSON.stringify([request.cascadeId, request.trajectoryReference, request.executionId, request.clientSessionId]);
|
|
137
|
+
|
|
138
|
+
const frames = client.transport.stream(CHAT_METHOD, request, {
|
|
139
|
+
headers: requestOptions.headers,
|
|
140
|
+
onPayload: (payload) => Effect.gen(function* () {
|
|
141
|
+
const replacement = requestOptions.onPayload ? yield* hook(() => requestOptions.onPayload!(payload, model)) : undefined;
|
|
142
|
+
const finalPayload = replacement === undefined ? payload : yield* object(replacement, "provider payload");
|
|
143
|
+
if (JSON.stringify([finalPayload.cascadeId, finalPayload.trajectoryReference, finalPayload.executionId, finalPayload.clientSessionId]) !== identity) {
|
|
144
|
+
return yield* failWith("A payload hook cannot change the owned run, session or step identifiers.");
|
|
145
|
+
}
|
|
146
|
+
actualUID = yield* requiredString(finalPayload.chatModelUid, "chatModelUid");
|
|
147
|
+
if (actualUID !== selected.uid) {
|
|
148
|
+
// An exact UID hidden from the list is re-resolved at that UID's level when the current catalog has it.
|
|
149
|
+
if (!(yield* deps.catalog.exactLevel(actualUID))) return yield* failWith("A payload hook selected an unavailable model UID.");
|
|
150
|
+
selected = yield* deps.catalog.resolveUID(actualUID, current);
|
|
151
|
+
yield* toDevinTranscript(context, selected.model); // A model change re-checks existing image and tool limits.
|
|
152
|
+
yield* assertCacheRetention(selected);
|
|
153
|
+
}
|
|
154
|
+
const outputLimit = yield* tokenCount((yield* object(finalPayload.configuration, "configuration")).maxTokens, "maxTokens");
|
|
155
|
+
if (outputLimit < 1 || outputLimit > selected.model.maxOutputTokens) return yield* failWith("The payload hook output limit is outside the model range.");
|
|
156
|
+
response.message.providerThinkingLevel = actualUID;
|
|
157
|
+
return finalPayload;
|
|
158
|
+
}),
|
|
159
|
+
onResponse: (info) => Effect.gen(function* () {
|
|
160
|
+
if (info.status === 401) yield* deps.guard.reportFailure(current, RPCError.make("unauthenticated", "HTTP 401", 401), generation);
|
|
161
|
+
if (requestOptions.onResponse) yield* hook(() => requestOptions.onResponse!(info, model));
|
|
162
|
+
}),
|
|
163
|
+
onFrame: (frame) => Effect.gen(function* () {
|
|
164
|
+
if (requestOptions.onProviderStreamEvent) yield* hook(() => requestOptions.onProviderStreamEvent!(structuredClone(frame.data), model));
|
|
165
|
+
if (frame.isEnd && frame.data.error) return; // Connect error classification belongs to the transport.
|
|
166
|
+
if (frame.data.redact === true) { isRedacted = true; yield* response.redact; }
|
|
167
|
+
yield* accumulator.accept(frame.data, frame.isEnd);
|
|
168
|
+
if (!frame.isEnd) yield* response.accept(frame.data, accumulator);
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
yield* lease.cancellation.guard(Stream.runDrain(frames));
|
|
172
|
+
const result = yield* accumulator.finish;
|
|
173
|
+
result.message.modelUid = actualUID;
|
|
174
|
+
yield* response.finish(result);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The scope encloses the failure report as well, so Pi sees the terminal event before the client closes and the lease lets the next request in.
|
|
178
|
+
const settled = Effect.scoped(lifetime.guard(run).pipe(
|
|
179
|
+
abortWith(requestOptions.signal),
|
|
180
|
+
// Every ending, including a defect, closes Pi's stream with exactly one terminal event.
|
|
181
|
+
Effect.catchCause((cause) => Effect.gen(function* () {
|
|
182
|
+
const error: DevinFailure = Cause.hasInterruptsOnly(cause) ? new Cancelled({ message: "The Devin request was interrupted." }) : toDevinFailure(Cause.squash(cause));
|
|
183
|
+
if (credential) yield* deps.guard.reportFailure(credential, error, generation);
|
|
184
|
+
accumulator.message.modelUid = actualUID || undefined;
|
|
185
|
+
yield* response.fail(hostRetryableMessage(error, safeError(error, credential ? [credential.sessionToken] : [])),
|
|
186
|
+
error.name === "AbortError", isRedacted ? undefined : accumulator.message);
|
|
187
|
+
})),
|
|
188
|
+
));
|
|
189
|
+
return { stream: response.stream, run: settled };
|
|
190
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Context, Effect, FiberSet, Layer } from "effect";
|
|
2
|
+
import { Accounts } from "../accounts/service.ts";
|
|
3
|
+
import { CredentialGuard } from "../auth/guard.ts";
|
|
4
|
+
import { AuthState } from "../auth/state.ts";
|
|
5
|
+
import { ModelCatalog } from "../catalog/service.ts";
|
|
6
|
+
import { DevinClients } from "../clients/service.ts";
|
|
7
|
+
import { onDefaultScheduler } from "../default-scheduler.ts";
|
|
8
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
9
|
+
import { Runs } from "../runs/service.ts";
|
|
10
|
+
import { prepareRequest, type InferenceDependencies } from "./request.ts";
|
|
11
|
+
import type { InferenceShape } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Devin requests behind Pi's provider calls. Each request owns its scope (lease, request client) and runs in a fiber of this
|
|
15
|
+
* service, so it outlives the synchronous provider call that returned its stream. It stops through its own guards, never by Pi returning.
|
|
16
|
+
*/
|
|
17
|
+
export class Inference extends Context.Service<Inference, InferenceShape>()("pi-devin/Inference") {}
|
|
18
|
+
|
|
19
|
+
export const InferenceLive = Layer.effect(Inference, Effect.gen(function* () {
|
|
20
|
+
const deps: InferenceDependencies = {
|
|
21
|
+
auth: yield* AuthState, guard: yield* CredentialGuard, catalog: yield* ModelCatalog, accounts: yield* Accounts,
|
|
22
|
+
clients: yield* DevinClients, runs: yield* Runs, lifetime: yield* ProviderLifetime,
|
|
23
|
+
};
|
|
24
|
+
const requests = yield* FiberSet.make<void, never>();
|
|
25
|
+
return {
|
|
26
|
+
start: (model, context, options) => Effect.gen(function* () {
|
|
27
|
+
const request = yield* prepareRequest(deps, model, context, options);
|
|
28
|
+
// Starts at once, as the previous synchronous prefix did: validation failures reach the stream before the call returns.
|
|
29
|
+
yield* FiberSet.run(requests, onDefaultScheduler(request.run), { startImmediately: true });
|
|
30
|
+
return request.stream;
|
|
31
|
+
}),
|
|
32
|
+
} satisfies InferenceShape;
|
|
33
|
+
}));
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AssistantMessageEventStream, Model, SimpleStreamOptions, TranscriptContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { DevinAPI } from "../catalog/types.ts";
|
|
4
|
+
|
|
5
|
+
export interface InferenceShape {
|
|
6
|
+
/**
|
|
7
|
+
* Returns Pi's event stream for one Devin request. Runs synchronously up to the first wait: the stream carries its
|
|
8
|
+
* start event, and the request already holds its sign-in generation and provider lifetime. The request runs on in a fiber the service owns.
|
|
9
|
+
*/
|
|
10
|
+
readonly start: (model: Model<DevinAPI>, context: TranscriptContext, options?: SimpleStreamOptions) => Effect.Effect<AssistantMessageEventStream>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { makeCancellation } from "../../../core/cancellation.ts";
|
|
3
|
+
import type { ProviderLifetimeShape } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long the provider's work may run: from a session attach until shutdown. Shutdown ends it with a reason
|
|
7
|
+
* instead of interrupting fibers, so stopped requests report that reason. The next session attach starts a new one.
|
|
8
|
+
*/
|
|
9
|
+
export class ProviderLifetime extends Context.Service<ProviderLifetime, ProviderLifetimeShape>()("pi-devin/ProviderLifetime") {}
|
|
10
|
+
|
|
11
|
+
export const ProviderLifetimeLive = Layer.sync(ProviderLifetime, () => {
|
|
12
|
+
let current = makeCancellation();
|
|
13
|
+
return {
|
|
14
|
+
current: Effect.sync(() => current),
|
|
15
|
+
isActive: Effect.sync(() => !current.isCancelled()),
|
|
16
|
+
end: (reason) => Effect.sync(() => current.cancelUnsafe(reason)),
|
|
17
|
+
renew: Effect.sync(() => { if (current.isCancelled()) current = makeCancellation(); }),
|
|
18
|
+
} satisfies ProviderLifetimeShape;
|
|
19
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { Cancellation } from "../../../core/cancellation.ts";
|
|
3
|
+
import type { DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface ProviderLifetimeShape {
|
|
6
|
+
/** The lifetime current at the call. Work captures it when it starts, so a later session's lifetime never stops it. */
|
|
7
|
+
readonly current: Effect.Effect<Cancellation>;
|
|
8
|
+
readonly isActive: Effect.Effect<boolean>;
|
|
9
|
+
/** Ends the current lifetime; work guarded by it fails with this reason. */
|
|
10
|
+
readonly end: (reason: DevinFailure) => Effect.Effect<void>;
|
|
11
|
+
/** Starts a new lifetime when the current one has ended. */
|
|
12
|
+
readonly renew: Effect.Effect<void>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Deferred, Effect, Exit } from "effect";
|
|
2
|
+
|
|
3
|
+
interface Ticket {
|
|
4
|
+
turn: Deferred.Deferred<void>;
|
|
5
|
+
hasLeft: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AdmissionTicket {
|
|
9
|
+
/** Waits until everyone who entered earlier has left. */
|
|
10
|
+
readonly turn: Effect.Effect<void>;
|
|
11
|
+
/** Idempotent. Also used by a request that gives up before its turn; those behind it still wait for everyone ahead of it. */
|
|
12
|
+
readonly leave: Effect.Effect<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Admits one request at a time, strictly in the order they entered. A new arrival never takes a turn ahead of a waiting one. */
|
|
16
|
+
export class AdmissionLine {
|
|
17
|
+
private readonly line: Ticket[] = [];
|
|
18
|
+
|
|
19
|
+
enter(): AdmissionTicket {
|
|
20
|
+
const ticket: Ticket = { turn: Deferred.makeUnsafe<void>(), hasLeft: false };
|
|
21
|
+
this.line.push(ticket);
|
|
22
|
+
if (this.line.length === 1) Deferred.doneUnsafe(ticket.turn, Exit.void);
|
|
23
|
+
return {
|
|
24
|
+
turn: Deferred.await(ticket.turn),
|
|
25
|
+
leave: Effect.sync(() => this.leave(ticket)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private leave(ticket: Ticket): void {
|
|
30
|
+
if (ticket.hasLeft) return;
|
|
31
|
+
ticket.hasLeft = true;
|
|
32
|
+
while (this.line[0]?.hasLeft) this.line.shift();
|
|
33
|
+
const front = this.line[0];
|
|
34
|
+
if (front) Deferred.doneUnsafe(front.turn, Exit.void);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Exit } from "effect";
|
|
3
|
+
import { makeCancellation, type Cancellation } from "../../../core/cancellation.ts";
|
|
4
|
+
import { Cancelled, type DevinError, type DevinFailure } from "../../../core/errors.ts";
|
|
5
|
+
import { failWith } from "../../../core/validation.ts";
|
|
6
|
+
import { AdmissionLine, type AdmissionTicket } from "./admission.ts";
|
|
7
|
+
import { freshState, MAX_STEP_INDEX, restoreState } from "./state.ts";
|
|
8
|
+
import type { DevinSessionState, ExecutionKind, ExecutionState, RequestLease, SessionBinding } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
interface Execution {
|
|
11
|
+
data: ExecutionState;
|
|
12
|
+
/** Ends the run's work with a reason: a finished run or an invalidated login. */
|
|
13
|
+
cancellation: Cancellation;
|
|
14
|
+
/** Requests of one run are admitted one at a time, in order. */
|
|
15
|
+
admission: AdmissionLine;
|
|
16
|
+
cleanups: Set<Effect.Effect<void>>;
|
|
17
|
+
activeRequests: number;
|
|
18
|
+
hasStarted: boolean;
|
|
19
|
+
nextIsRecovery: boolean;
|
|
20
|
+
hasCheckpoint: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cancelled = () => new Cancelled({ message: "The previous Devin run ended." });
|
|
24
|
+
const runQuietly = (cleanup: Effect.Effect<void>) => Effect.catchCause(cleanup, () => Effect.void);
|
|
25
|
+
|
|
26
|
+
/** Runs of one extension instance and its attached session. It never builds its own tool or retry loop. */
|
|
27
|
+
export class DevinLifecycle {
|
|
28
|
+
private binding?: SessionBinding;
|
|
29
|
+
private state: DevinSessionState = freshState(randomUUID());
|
|
30
|
+
private execution?: Execution;
|
|
31
|
+
private isClosed = false;
|
|
32
|
+
private isAuxiliary = false;
|
|
33
|
+
|
|
34
|
+
private constructor() {}
|
|
35
|
+
|
|
36
|
+
static readonly make: Effect.Effect<DevinLifecycle> = Effect.sync(() => new DevinLifecycle());
|
|
37
|
+
|
|
38
|
+
get snapshot(): Effect.Effect<DevinSessionState> {
|
|
39
|
+
return Effect.sync(() => structuredClone(this.state));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private checkpoint(): Effect.Effect<void, Cancelled> {
|
|
43
|
+
return Effect.suspend(() => {
|
|
44
|
+
if (this.binding && this.binding.getSessionId() !== this.state.sessionId) return Effect.fail(cancelled());
|
|
45
|
+
this.binding?.append(structuredClone(this.state));
|
|
46
|
+
return Effect.void;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private activate(data: ExecutionState, hasCheckpoint = false): Execution {
|
|
51
|
+
this.state.execution = data;
|
|
52
|
+
return this.execution = {
|
|
53
|
+
data, cancellation: makeCancellation(), admission: new AdmissionLine(), cleanups: new Set(),
|
|
54
|
+
activeRequests: 0, hasStarted: false, nextIsRecovery: false, hasCheckpoint,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
attach(binding: SessionBinding): Effect.Effect<void, DevinError> {
|
|
59
|
+
return Effect.gen({ self: this }, function* () {
|
|
60
|
+
yield* this.abort(cancelled());
|
|
61
|
+
this.binding = binding;
|
|
62
|
+
this.state = yield* restoreState(binding);
|
|
63
|
+
this.execution = undefined; this.isClosed = false; this.isAuxiliary = false;
|
|
64
|
+
if (this.state.execution && !this.state.execution.settled) this.activate({ ...this.state.execution, kind: "recovered" }, true);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
begin(kind: ExecutionKind): Effect.Effect<void, Cancelled> {
|
|
69
|
+
return Effect.gen({ self: this }, function* () {
|
|
70
|
+
if (this.isClosed) return yield* Effect.fail(cancelled());
|
|
71
|
+
const current = this.execution;
|
|
72
|
+
if (current && !current.data.settled) {
|
|
73
|
+
if (kind === "agent" && ["pending", "recovered"].includes(current.data.kind)) current.data.kind = "agent";
|
|
74
|
+
else if (kind === "agent" && ["manual", "tree", "direct"].includes(current.data.kind) && current.activeRequests === 0) yield* this.finish;
|
|
75
|
+
else return; // Retries and tool follow-up calls stay in the same run.
|
|
76
|
+
if (this.execution) return;
|
|
77
|
+
}
|
|
78
|
+
this.activate({ id: randomUUID(), settled: false, kind });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get agentStarted(): Effect.Effect<void, Cancelled> {
|
|
83
|
+
return Effect.gen({ self: this }, function* () {
|
|
84
|
+
if (!this.execution) yield* this.begin("agent");
|
|
85
|
+
const execution = this.execution!;
|
|
86
|
+
execution.nextIsRecovery = execution.hasStarted;
|
|
87
|
+
execution.hasStarted = true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeCompaction(reason: "manual" | "threshold" | "overflow"): Effect.Effect<void, Cancelled> {
|
|
92
|
+
return Effect.andThen(this.begin(reason === "manual" ? "manual" : "pending"), Effect.sync(() => { this.isAuxiliary = true; }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
afterCompaction(reason: "manual" | "threshold" | "overflow"): Effect.Effect<void, Cancelled> {
|
|
96
|
+
return Effect.suspend(() => {
|
|
97
|
+
this.isAuxiliary = false;
|
|
98
|
+
return reason === "manual" && this.execution?.data.kind === "manual" ? this.finish : Effect.void;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get beforeTree(): Effect.Effect<void, Cancelled> {
|
|
103
|
+
return Effect.andThen(this.begin("tree"), Effect.sync(() => { this.isAuxiliary = true; }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get finish(): Effect.Effect<void, Cancelled> {
|
|
107
|
+
return Effect.gen({ self: this }, function* () {
|
|
108
|
+
const execution = this.execution;
|
|
109
|
+
if (!execution) return;
|
|
110
|
+
execution.data.settled = true;
|
|
111
|
+
yield* this.abort(cancelled());
|
|
112
|
+
if (execution.hasCheckpoint) yield* this.checkpoint();
|
|
113
|
+
this.execution = undefined; this.isAuxiliary = false;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stops the run's waiting and running requests with the reason. A cleanup failure never skips releasing other resources. */
|
|
118
|
+
abort(reason: DevinFailure): Effect.Effect<void> {
|
|
119
|
+
return Effect.suspend(() => {
|
|
120
|
+
const active = this.execution;
|
|
121
|
+
if (!active) return Effect.void;
|
|
122
|
+
active.cancellation.cancelUnsafe(reason);
|
|
123
|
+
return Effect.forEach([...active.cleanups], runQuietly, { discard: true });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get shutdown(): Effect.Effect<void> {
|
|
128
|
+
return Effect.suspend(() => {
|
|
129
|
+
if (this.isClosed) return Effect.void;
|
|
130
|
+
// An unfinished run stays in the checkpoint so a reload resumes it with the same execution ID.
|
|
131
|
+
return Effect.andThen(this.abort(cancelled()), Effect.sync(() => { this.isClosed = true; }));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get treeChanged(): Effect.Effect<void, DevinError | Cancelled> {
|
|
136
|
+
return Effect.gen({ self: this }, function* () {
|
|
137
|
+
if (!this.binding) return;
|
|
138
|
+
yield* this.abort(cancelled());
|
|
139
|
+
this.state = yield* restoreState(this.binding);
|
|
140
|
+
this.state.trajectoryId = randomUUID(); this.state.nextStepIndex = 0;
|
|
141
|
+
this.state.execution = undefined; this.execution = undefined; this.isAuxiliary = false;
|
|
142
|
+
if (this.state.ownerHash) yield* this.checkpoint();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
acquire(ownerHash: string): Effect.Effect<RequestLease, DevinFailure> {
|
|
147
|
+
return Effect.gen({ self: this }, function* () {
|
|
148
|
+
if (this.isClosed) return yield* Effect.fail(cancelled());
|
|
149
|
+
if (!/^[a-f0-9]{64}$/.test(ownerHash)) return yield* failWith("A verified Devin account scope is required.");
|
|
150
|
+
if (this.state.ownerHash && this.state.ownerHash !== ownerHash) return yield* failWith("This Pi session is bound to a different Devin account or endpoint. Start a new session.");
|
|
151
|
+
this.state.ownerHash = ownerHash;
|
|
152
|
+
if (!this.execution) yield* this.begin("direct");
|
|
153
|
+
const active = this.execution!, state = this.state;
|
|
154
|
+
const ownCleanups = new Set<Effect.Effect<void>>();
|
|
155
|
+
let isCounted = false, hasReleased = false;
|
|
156
|
+
let ticket: AdmissionTicket | undefined;
|
|
157
|
+
const release = Effect.suspend(() => {
|
|
158
|
+
if (hasReleased) return Effect.void;
|
|
159
|
+
hasReleased = true;
|
|
160
|
+
for (const cleanup of ownCleanups) active.cleanups.delete(cleanup);
|
|
161
|
+
return Effect.forEach([...ownCleanups], runQuietly, { discard: true }).pipe(Effect.andThen(Effect.suspend(() => {
|
|
162
|
+
if (isCounted) active.activeRequests--;
|
|
163
|
+
return ticket ? ticket.leave : Effect.void;
|
|
164
|
+
})));
|
|
165
|
+
});
|
|
166
|
+
const assertCurrent = Effect.suspend(() => {
|
|
167
|
+
const reason = active.cancellation.reason();
|
|
168
|
+
if (reason) return Effect.fail(reason);
|
|
169
|
+
if (this.execution !== active || this.state !== state || this.isClosed || (this.binding && this.binding.getSessionId() !== state.sessionId)) return Effect.fail(cancelled());
|
|
170
|
+
return Effect.void;
|
|
171
|
+
});
|
|
172
|
+
const admitted = Effect.gen({ self: this }, function* () {
|
|
173
|
+
// Entered inside the effect whose exit releases it, so an interruption cannot leave the line blocked.
|
|
174
|
+
ticket = active.admission.enter();
|
|
175
|
+
yield* active.cancellation.guard(ticket.turn);
|
|
176
|
+
yield* assertCurrent;
|
|
177
|
+
if (state.nextStepIndex >= MAX_STEP_INDEX) return yield* failWith("The Devin step number limit was reached. Start a new session.");
|
|
178
|
+
active.activeRequests++; isCounted = true;
|
|
179
|
+
const stepIndex = state.nextStepIndex++;
|
|
180
|
+
active.hasCheckpoint = true;
|
|
181
|
+
yield* this.checkpoint();
|
|
182
|
+
const isAuxiliary = this.isAuxiliary;
|
|
183
|
+
const isRecovery = !isAuxiliary && active.nextIsRecovery;
|
|
184
|
+
if (!isAuxiliary) active.nextIsRecovery = false;
|
|
185
|
+
const lease: RequestLease = {
|
|
186
|
+
executionId: active.data.id, cascadeId: state.cascadeId, trajectoryId: state.trajectoryId,
|
|
187
|
+
sessionId: state.sessionId, stepIndex, isAuxiliary, isRecovery,
|
|
188
|
+
cancellation: active.cancellation,
|
|
189
|
+
assertAttempt: assertCurrent,
|
|
190
|
+
addCleanup: (cleanup) => Effect.suspend(() => {
|
|
191
|
+
let hasCleaned = false;
|
|
192
|
+
const once = Effect.suspend(() => {
|
|
193
|
+
if (hasCleaned) return Effect.void;
|
|
194
|
+
hasCleaned = true;
|
|
195
|
+
return cleanup;
|
|
196
|
+
});
|
|
197
|
+
ownCleanups.add(once); active.cleanups.add(once);
|
|
198
|
+
return active.cancellation.isCancelled() ? runQuietly(once) : Effect.void;
|
|
199
|
+
}),
|
|
200
|
+
release,
|
|
201
|
+
};
|
|
202
|
+
return lease;
|
|
203
|
+
});
|
|
204
|
+
return yield* admitted.pipe(Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : release));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context, Layer } from "effect";
|
|
2
|
+
import { DevinLifecycle } from "./lifecycle.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs of the attached Pi session: admission order, request leases and the stored session state.
|
|
6
|
+
* A run spans many provider calls and ends at `agent_settled`, never when one response ends.
|
|
7
|
+
*/
|
|
8
|
+
export class Runs extends Context.Service<Runs, DevinLifecycle>()("pi-devin/Runs") {}
|
|
9
|
+
|
|
10
|
+
export const RunsLive = Layer.effect(Runs, DevinLifecycle.make);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
4
|
+
import { failWith } from "../../../core/validation.ts";
|
|
5
|
+
import type { DevinSessionState, SessionBinding } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const DEVIN_STATE_ENTRY = "pi-devin.state.v1";
|
|
8
|
+
export const MAX_STEP_INDEX = 2_147_483_647;
|
|
9
|
+
|
|
10
|
+
const UUID = Schema.String.check(Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/));
|
|
11
|
+
const isStoredState = Schema.is(Schema.Struct({
|
|
12
|
+
version: Schema.Literal(1),
|
|
13
|
+
sessionId: Schema.String,
|
|
14
|
+
ownerHash: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/))),
|
|
15
|
+
cascadeId: UUID,
|
|
16
|
+
trajectoryId: UUID,
|
|
17
|
+
nextStepIndex: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: MAX_STEP_INDEX })),
|
|
18
|
+
// `used` is the request budget count saved by 0.1.5 and earlier; it is accepted and dropped.
|
|
19
|
+
execution: Schema.optionalKey(Schema.Struct({ id: UUID, used: Schema.optionalKey(Schema.Int), settled: Schema.Boolean })),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
export function freshState(sessionId: string): DevinSessionState {
|
|
23
|
+
return { version: 1, sessionId, cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The newest state of this session's branch. Another session's state starts fresh; a corrupt one stops instead of resetting the IDs. */
|
|
27
|
+
export function restoreState(binding: SessionBinding): Effect.Effect<DevinSessionState, DevinError> {
|
|
28
|
+
return Effect.suspend(() => {
|
|
29
|
+
const sessionId = binding.getSessionId();
|
|
30
|
+
const entry = [...binding.getBranch()].reverse().find((item) => item.type === "custom" && item.customType === DEVIN_STATE_ENTRY);
|
|
31
|
+
if (!entry) return Effect.succeed(freshState(sessionId));
|
|
32
|
+
if (!isStoredState(entry.data)) return failWith("The Devin session state is corrupt or an unsupported version. Start a new session.");
|
|
33
|
+
if (entry.data.sessionId !== sessionId) return Effect.succeed(freshState(sessionId));
|
|
34
|
+
const { execution, ...state } = structuredClone(entry.data);
|
|
35
|
+
return Effect.succeed({ ...state, ...(execution ? { execution: { id: execution.id, settled: execution.settled } } : {}) } as DevinSessionState);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { Cancellation } from "../../../core/cancellation.ts";
|
|
3
|
+
import type { DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
|
|
5
|
+
export type ExecutionKind = "agent" | "pending" | "manual" | "tree" | "direct" | "recovered";
|
|
6
|
+
export interface ExecutionState { id: string; settled: boolean; kind: ExecutionKind }
|
|
7
|
+
|
|
8
|
+
/** The `pi-devin.state.v1` session entry. Holds no token, fingerprint or message. */
|
|
9
|
+
export interface DevinSessionState {
|
|
10
|
+
version: 1;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
ownerHash?: string;
|
|
13
|
+
cascadeId: string;
|
|
14
|
+
trajectoryId: string;
|
|
15
|
+
nextStepIndex: number;
|
|
16
|
+
execution?: ExecutionState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pi's current session as the run service sees it: its ID, its branch entries and a way to append a state entry. */
|
|
20
|
+
export interface SessionBinding {
|
|
21
|
+
getSessionId(): string;
|
|
22
|
+
getBranch(): readonly { type: string; customType?: string; data?: unknown }[];
|
|
23
|
+
append(data: DevinSessionState): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One admitted model request of a run. */
|
|
27
|
+
export interface RequestLease {
|
|
28
|
+
executionId: string;
|
|
29
|
+
cascadeId: string;
|
|
30
|
+
trajectoryId: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
stepIndex: number;
|
|
33
|
+
isAuxiliary: boolean;
|
|
34
|
+
isRecovery: boolean;
|
|
35
|
+
/** Ends when the run is aborted, failing with its reason. */
|
|
36
|
+
cancellation: Cancellation;
|
|
37
|
+
/** Checked before each model HTTP request: fails once the run has ended or the session has changed. */
|
|
38
|
+
assertAttempt: Effect.Effect<void, DevinFailure>;
|
|
39
|
+
/** Runs once when the run is aborted or the lease is released. */
|
|
40
|
+
addCleanup(cleanup: Effect.Effect<void>): Effect.Effect<void>;
|
|
41
|
+
/** Idempotent. Lets the next request of the run in. */
|
|
42
|
+
release: Effect.Effect<void>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { FusionSelection } from "../catalog/types.ts";
|
|
3
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
4
|
+
import type { DevinPreferences, DisplaySettingsShape, FusionSettingsShape, UsageStoreShape } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PREFERENCES: DevinPreferences = { recommendedOnly: false, showUsageStatus: false };
|
|
7
|
+
|
|
8
|
+
export class FusionSettings extends Context.Service<FusionSettings, FusionSettingsShape>()("pi-devin/FusionSettings") {}
|
|
9
|
+
export class DisplaySettings extends Context.Service<DisplaySettings, DisplaySettingsShape>()("pi-devin/DisplaySettings") {}
|
|
10
|
+
export class UsageStore extends Context.Service<UsageStore, UsageStoreShape>()("pi-devin/UsageStore") {}
|
|
11
|
+
|
|
12
|
+
/** A newer record replaces an older one of the same scope; another scope's record is always replaced. */
|
|
13
|
+
export const isNotOlder = (current: AccountUsage | undefined, next: AccountUsage) => !current || next.fetchedAtMs >= current.fetchedAtMs;
|
|
14
|
+
|
|
15
|
+
/** Memory stores, so SDK callers and tests never write to the real agent directory. */
|
|
16
|
+
export function memoryFusionSettings(initial?: FusionSelection): FusionSettingsShape {
|
|
17
|
+
let current = initial;
|
|
18
|
+
return {
|
|
19
|
+
read: Effect.sync(() => current),
|
|
20
|
+
write: (selection) => Effect.sync(() => { current = { ...selection }; }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function memoryDisplaySettings(initial: Partial<DevinPreferences> = {}): DisplaySettingsShape {
|
|
25
|
+
let current: DevinPreferences = { ...DEFAULT_PREFERENCES, ...initial };
|
|
26
|
+
return {
|
|
27
|
+
read: Effect.sync(() => ({ ...current })),
|
|
28
|
+
write: (preferences) => Effect.sync(() => { current = { ...preferences }; }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function memoryUsageStore(): UsageStoreShape {
|
|
33
|
+
let record: { scope: string; usage: AccountUsage } | undefined;
|
|
34
|
+
return {
|
|
35
|
+
read: (scope) => Effect.sync(() => record?.scope === scope ? { ...record.usage } : undefined),
|
|
36
|
+
write: (scope, usage) => Effect.sync(() => {
|
|
37
|
+
if (record?.scope !== scope || isNotOlder(record.usage, usage)) record = { scope, usage: { ...usage } };
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const MemorySettings = (initial: { fusion?: FusionSelection; display?: Partial<DevinPreferences> } = {}) => Layer.mergeAll(
|
|
43
|
+
Layer.sync(FusionSettings, () => memoryFusionSettings(initial.fusion)),
|
|
44
|
+
Layer.sync(DisplaySettings, () => memoryDisplaySettings(initial.display)),
|
|
45
|
+
Layer.sync(UsageStore, memoryUsageStore),
|
|
46
|
+
);
|