@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,66 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Effect, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { DisplaySettings, DEFAULT_PREFERENCES, FusionSettings, isNotOlder, UsageStore } from "../../features/settings/service.ts";
|
|
4
|
+
import type { DisplaySettingsShape, FusionSettingsShape, UsageStoreShape } from "../../features/settings/types.ts";
|
|
5
|
+
import type { FusionSelection } from "../../features/catalog/types.ts";
|
|
6
|
+
import { readJsonFile, writeJsonFile } from "./json-file.ts";
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export const fusionSettingsPath = (agentDir: string) => join(agentDir, "pi-devin", "fusion.json");
|
|
11
|
+
export const devinSettingsPath = (agentDir: string) => join(agentDir, "pi-devin", "settings.json");
|
|
12
|
+
export const accountUsagePath = (agentDir: string) => join(agentDir, "pi-devin", "account-usage.json");
|
|
13
|
+
|
|
14
|
+
const ModelID = Schema.String.check(Schema.makeFilter((value: string) => value.trim() !== ""));
|
|
15
|
+
// Legacy IDs (`family:…`, `uid:…`) are read as is. `effectiveFusion` maps them to the current catalog's IDs.
|
|
16
|
+
const FusionFile = Schema.Struct({ schemaVersion: Schema.Literal(SCHEMA_VERSION), leadID: ModelID, sidekickID: ModelID });
|
|
17
|
+
const SettingsFile = Schema.Struct({ schemaVersion: Schema.Literal(SCHEMA_VERSION), recommendedOnly: Schema.Boolean, showUsageStatus: Schema.Boolean });
|
|
18
|
+
const Percent = Schema.optionalKey(Schema.Finite);
|
|
19
|
+
const UsageRecord = Schema.Struct({
|
|
20
|
+
schemaVersion: Schema.Literal(SCHEMA_VERSION),
|
|
21
|
+
scope: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
|
|
22
|
+
usage: Schema.Struct({
|
|
23
|
+
fetchedAtMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
|
|
24
|
+
billingStrategy: Schema.optionalKey(Schema.String),
|
|
25
|
+
dailyRemainingPercent: Percent,
|
|
26
|
+
weeklyRemainingPercent: Percent,
|
|
27
|
+
dailyResetAtMs: Percent,
|
|
28
|
+
weeklyResetAtMs: Percent,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** An unreadable or malformed file reads as no saved value and is not overwritten. Writes are replaced atomically within the same directory. */
|
|
33
|
+
export function fileFusionSettings(path: string): FusionSettingsShape {
|
|
34
|
+
return {
|
|
35
|
+
read: Effect.map(readJsonFile(path, FusionFile), Option.match({
|
|
36
|
+
onNone: () => undefined,
|
|
37
|
+
onSome: (value): FusionSelection => ({ leadID: value.leadID, sidekickID: value.sidekickID }),
|
|
38
|
+
})),
|
|
39
|
+
write: (selection) => writeJsonFile(path, { schemaVersion: SCHEMA_VERSION, leadID: selection.leadID, sidekickID: selection.sidekickID }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An unreadable or malformed file reads as the defaults and is not overwritten. Writes are replaced atomically within the same directory. */
|
|
44
|
+
export function fileDisplaySettings(path: string): DisplaySettingsShape {
|
|
45
|
+
return {
|
|
46
|
+
read: Effect.map(readJsonFile(path, SettingsFile), Option.match({
|
|
47
|
+
onNone: () => ({ ...DEFAULT_PREFERENCES }),
|
|
48
|
+
onSome: (value) => ({ recommendedOnly: value.recommendedOnly, showUsageStatus: value.showUsageStatus }),
|
|
49
|
+
})),
|
|
50
|
+
write: (preferences) => writeJsonFile(path, { schemaVersion: SCHEMA_VERSION, ...preferences }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An unreadable or malformed file reads as no record and is not repaired by reading. Writes are replaced atomically within the same directory. */
|
|
55
|
+
export function fileUsageStore(path: string): UsageStoreShape {
|
|
56
|
+
const readRecord = readJsonFile(path, UsageRecord);
|
|
57
|
+
return {
|
|
58
|
+
read: (scope) => Effect.map(readRecord, (record) => Option.isSome(record) && record.value.scope === scope ? { ...record.value.usage } : undefined),
|
|
59
|
+
write: (scope, usage) => Effect.flatMap(readRecord, (current) => Option.isSome(current) && current.value.scope === scope && !isNotOlder(current.value.usage, usage)
|
|
60
|
+
? Effect.void : writeJsonFile(path, { schemaVersion: SCHEMA_VERSION, scope, usage })),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const FusionSettingsFile = (path: string) => Layer.succeed(FusionSettings, fileFusionSettings(path));
|
|
65
|
+
export const DisplaySettingsFile = (path: string) => Layer.succeed(DisplaySettings, fileDisplaySettings(path));
|
|
66
|
+
export const UsageStoreFile = (path: string) => Layer.succeed(UsageStore, fileUsageStore(path));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { Effect, Option, Schema } from "effect";
|
|
4
|
+
import { toDevinError, type DevinError } from "../../../core/errors.ts";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Pi reads these small global files from synchronous callbacks, such as TUI rendering and model list rebuilds,
|
|
8
|
+
* and other Pi processes may change them at any time. They are therefore read fresh and synchronously on each use;
|
|
9
|
+
* Effect's FileSystem service is asynchronous only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The file's value when it exists, parses and matches the schema. A missing or malformed file is never repaired by reading. */
|
|
13
|
+
export function readJsonFile<S extends Schema.ConstraintDecoder<unknown>>(path: string, schema: S): Effect.Effect<Option.Option<S["Type"]>> {
|
|
14
|
+
const decode = Schema.decodeUnknownOption(schema);
|
|
15
|
+
return Effect.sync(() => {
|
|
16
|
+
try { return decode(JSON.parse(readFileSync(path, "utf8"))); } catch { return Option.none(); }
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Replaces the file atomically within its directory. */
|
|
21
|
+
export function writeJsonFile(path: string, value: unknown): Effect.Effect<void, DevinError> {
|
|
22
|
+
return Effect.try({
|
|
23
|
+
try: () => {
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
26
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n");
|
|
27
|
+
renameSync(temporary, path);
|
|
28
|
+
},
|
|
29
|
+
catch: toDevinError,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface LoginSettings {
|
|
4
|
+
apiServerURL?: string;
|
|
5
|
+
webappURL?: string;
|
|
6
|
+
devinAPIURL?: string;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Values the entry point chooses for one extension instance. Read by the services that need them. */
|
|
11
|
+
export interface ExtensionConfigShape {
|
|
12
|
+
/** Clock of quota timestamps and session check times. */
|
|
13
|
+
readonly now: () => number;
|
|
14
|
+
/**
|
|
15
|
+
* This Pi is the Fusion sidekick child: it lists every exact UID, builds no Fusion entry, ignores the recommended filter,
|
|
16
|
+
* and registers no sidekick tool, `/devin`, `/devin-fusion` or status line.
|
|
17
|
+
*/
|
|
18
|
+
readonly isFusionChild: boolean;
|
|
19
|
+
/** Endpoints and the time limit of `/login`. */
|
|
20
|
+
readonly login: LoginSettings;
|
|
21
|
+
/** Pi agent directory that holds sidekick session files. Read when a handoff starts. */
|
|
22
|
+
readonly agentDir: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ExtensionConfig extends Context.Service<ExtensionConfig, ExtensionConfigShape>()("pi-devin/ExtensionConfig") {}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Layer } from "effect";
|
|
3
|
+
import { NodeDeviceIdentitySource } from "../../core/services/device-identity.ts";
|
|
4
|
+
import { NodeHttpTransport, nodeTransport } from "../../core/services/http-transport.ts";
|
|
5
|
+
import { NodePlatformLayer } from "../../core/services/platform.ts";
|
|
6
|
+
import { accountUsagePath, devinSettingsPath, DisplaySettingsFile, fusionSettingsPath, FusionSettingsFile, UsageStoreFile } from "../adapters/storage/files.ts";
|
|
7
|
+
import { LoginTransport } from "../features/auth/login.ts";
|
|
8
|
+
import { DisplaySettings, FusionSettings, memoryDisplaySettings, memoryFusionSettings } from "../features/settings/service.ts";
|
|
9
|
+
import { NodeSidekickLauncher } from "../features/sidekick/launcher.ts";
|
|
10
|
+
import { ExtensionConfig } from "./config.ts";
|
|
11
|
+
import type { ExtensionEnvironment } from "./layer.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The environment Pi loads the extension with: Node's network, process and file services, and the global stores in Pi's agent directory.
|
|
15
|
+
* The sidekick child keeps no Fusion or display settings, but records its quota so the lead shows what the sidekick used.
|
|
16
|
+
*/
|
|
17
|
+
export function productionEnvironment(options: { isFusionChild: boolean }): Layer.Layer<ExtensionEnvironment> {
|
|
18
|
+
const agentDir = getAgentDir();
|
|
19
|
+
const settings = options.isFusionChild
|
|
20
|
+
? Layer.mergeAll(Layer.sync(FusionSettings, () => memoryFusionSettings()), Layer.sync(DisplaySettings, () => memoryDisplaySettings()))
|
|
21
|
+
: Layer.mergeAll(FusionSettingsFile(fusionSettingsPath(agentDir)), DisplaySettingsFile(devinSettingsPath(agentDir)));
|
|
22
|
+
return Layer.mergeAll(
|
|
23
|
+
Layer.succeed(ExtensionConfig, { isFusionChild: options.isFusionChild, now: Date.now, login: {}, agentDir: getAgentDir }),
|
|
24
|
+
NodeHttpTransport(),
|
|
25
|
+
Layer.succeed(LoginTransport, nodeTransport()),
|
|
26
|
+
settings,
|
|
27
|
+
UsageStoreFile(accountUsagePath(agentDir)),
|
|
28
|
+
NodeSidekickLauncher,
|
|
29
|
+
).pipe(Layer.provideMerge(Layer.mergeAll(NodeDeviceIdentitySource.pipe(Layer.provide(NodePlatformLayer)), NodePlatformLayer)));
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Layer, type FileSystem } from "effect";
|
|
2
|
+
import type { DeviceIdentitySource } from "../../core/services/device-identity.ts";
|
|
3
|
+
import type { HttpTransport } from "../../core/services/http-transport.ts";
|
|
4
|
+
import { UsageStatusLive } from "../adapters/pi/usage-status.ts";
|
|
5
|
+
import { AccountsLive } from "../features/accounts/service.ts";
|
|
6
|
+
import { CredentialGuardLive } from "../features/auth/guard.ts";
|
|
7
|
+
import { AccountLoginLive, type LoginTransport } from "../features/auth/login.ts";
|
|
8
|
+
import { AuthStateLive } from "../features/auth/state.ts";
|
|
9
|
+
import { RemoteCatalogLive } from "../features/catalog/remote.ts";
|
|
10
|
+
import { ModelCatalogLive } from "../features/catalog/service.ts";
|
|
11
|
+
import { DevinClientsLive } from "../features/clients/service.ts";
|
|
12
|
+
import { InferenceLive } from "../features/inference/service.ts";
|
|
13
|
+
import { ProviderLifetimeLive } from "../features/lifetime/service.ts";
|
|
14
|
+
import { RunsLive } from "../features/runs/service.ts";
|
|
15
|
+
import type { DisplaySettings, FusionSettings, UsageStore } from "../features/settings/service.ts";
|
|
16
|
+
import type { SidekickLauncher } from "../features/sidekick/launcher.ts";
|
|
17
|
+
import { SidekicksLive } from "../features/sidekick/service.ts";
|
|
18
|
+
import { UsageChangesLive } from "../features/usage/changes.ts";
|
|
19
|
+
import type { ExtensionConfig } from "./config.ts";
|
|
20
|
+
|
|
21
|
+
/** What the entry point provides for the provider: its values and external I/O. */
|
|
22
|
+
export type ProviderEnvironment = ExtensionConfig | HttpTransport | LoginTransport | DeviceIdentitySource | FusionSettings | DisplaySettings | UsageStore;
|
|
23
|
+
/** The provider environment plus what the extension's host features need. */
|
|
24
|
+
export type ExtensionEnvironment = ProviderEnvironment | SidekickLauncher | FileSystem.FileSystem;
|
|
25
|
+
|
|
26
|
+
/** State owners that need no other feature. */
|
|
27
|
+
const StateLive = Layer.mergeAll(ProviderLifetimeLive, AuthStateLive, UsageChangesLive, RunsLive);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Features behind Pi's provider contract. Each layer receives the ones below it, so dependencies only point downward:
|
|
31
|
+
* requests and sign-in use the catalog, which uses the guard and accounts, which use clients and the state owners.
|
|
32
|
+
*/
|
|
33
|
+
export const ProviderLive = Layer.mergeAll(InferenceLive, AccountLoginLive).pipe(
|
|
34
|
+
Layer.provideMerge(ModelCatalogLive),
|
|
35
|
+
Layer.provideMerge(RemoteCatalogLive),
|
|
36
|
+
Layer.provideMerge(CredentialGuardLive),
|
|
37
|
+
Layer.provideMerge(AccountsLive),
|
|
38
|
+
Layer.provideMerge(DevinClientsLive),
|
|
39
|
+
Layer.provideMerge(StateLive),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The provider and the features that need Pi's host: the Fusion sidekick and the usage status line. */
|
|
43
|
+
export const ExtensionLive = Layer.mergeAll(SidekicksLive, UsageStatusLive).pipe(Layer.provideMerge(ProviderLive));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Layer, ManagedRuntime } from "effect";
|
|
2
|
+
import type { DevinError } from "../../core/errors.ts";
|
|
3
|
+
import type { SidekickHost } from "../features/sidekick/service.ts";
|
|
4
|
+
import { ExtensionLive, ProviderLive, type ExtensionEnvironment, type ProviderEnvironment } from "./layer.ts";
|
|
5
|
+
|
|
6
|
+
export type ProviderServices = Layer.Success<typeof ProviderLive> | ProviderEnvironment;
|
|
7
|
+
export type ExtensionServices = Layer.Success<typeof ExtensionLive> | ExtensionEnvironment | SidekickHost;
|
|
8
|
+
export type ProviderRuntime = ManagedRuntime.ManagedRuntime<ProviderServices, DevinError>;
|
|
9
|
+
export type ExtensionRuntime = ManagedRuntime.ManagedRuntime<ExtensionServices, DevinError>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One runtime per extension instance. It is never disposed on `session_shutdown`: Pi may still call the same provider
|
|
13
|
+
* afterwards, and the next `session_start` reuses it with a new provider lifetime. Its layers hold no operating system
|
|
14
|
+
* resources; connection pools close with their clients and sidekick children with their sessions.
|
|
15
|
+
*/
|
|
16
|
+
export const makeProviderRuntime = (environment: Layer.Layer<ProviderEnvironment>): ProviderRuntime =>
|
|
17
|
+
ManagedRuntime.make(ProviderLive.pipe(Layer.provideMerge(environment)));
|
|
18
|
+
|
|
19
|
+
export const makeExtensionRuntime = (environment: Layer.Layer<ExtensionEnvironment>, host: Layer.Layer<SidekickHost>): ExtensionRuntime =>
|
|
20
|
+
ManagedRuntime.make(ExtensionLive.pipe(Layer.provideMerge(Layer.mergeAll(environment, host))));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { Cancelled } from "../../core/errors.ts";
|
|
3
|
+
import { Accounts } from "../features/accounts/service.ts";
|
|
4
|
+
import { DevinClients } from "../features/clients/service.ts";
|
|
5
|
+
import { ProviderLifetime } from "../features/lifetime/service.ts";
|
|
6
|
+
import { Runs } from "../features/runs/service.ts";
|
|
7
|
+
import type { SessionBinding } from "../features/runs/types.ts";
|
|
8
|
+
|
|
9
|
+
/** Binds the provider to Pi's current session: a new provider lifetime if the last one ended, then the session's stored run state. Fails on a corrupt state. */
|
|
10
|
+
export const attachSession = (binding: SessionBinding) => Effect.gen(function* () {
|
|
11
|
+
yield* (yield* ProviderLifetime).renew;
|
|
12
|
+
yield* (yield* Runs).attach(binding);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stops the provider's work. The lifetime ends first, so running requests stop with its reason rather than an interruption;
|
|
17
|
+
* an unfinished run stays in the session's checkpoint. The extension instance and its runtime stay usable.
|
|
18
|
+
*/
|
|
19
|
+
export const shutdownProvider = Effect.gen(function* () {
|
|
20
|
+
const clients = yield* DevinClients;
|
|
21
|
+
yield* (yield* ProviderLifetime).end(new Cancelled({ message: "The Devin provider shut down." }));
|
|
22
|
+
yield* (yield* Runs).shutdown;
|
|
23
|
+
yield* clients.closeAll;
|
|
24
|
+
yield* clients.clearFingerprints;
|
|
25
|
+
yield* (yield* Accounts).clear;
|
|
26
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
3
|
+
import { requiredString } from "../../../core/validation.ts";
|
|
4
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
5
|
+
import { AuthState } from "../auth/state.ts";
|
|
6
|
+
import { evictOldest } from "../bounded.ts";
|
|
7
|
+
import { catalogScope } from "../catalog/projection.ts";
|
|
8
|
+
import { DevinClients } from "../clients/service.ts";
|
|
9
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
10
|
+
import { UsageStore } from "../settings/service.ts";
|
|
11
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
12
|
+
import { accountUsage } from "../usage/report.ts";
|
|
13
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
14
|
+
import type { AccountRecord, AccountsShape } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The only owner of the account status query (`GetUserStatus`): accounts this process verified, by credential scope,
|
|
18
|
+
* and the newest quota they reported. Request checks, the check after sign-in and the periodic session check all go through it.
|
|
19
|
+
*/
|
|
20
|
+
export class Accounts extends Context.Service<Accounts, AccountsShape>()("pi-devin/Accounts") {}
|
|
21
|
+
|
|
22
|
+
export const AccountsLive = Layer.effect(Accounts, Effect.gen(function* () {
|
|
23
|
+
const clients = yield* DevinClients;
|
|
24
|
+
const store = yield* UsageStore;
|
|
25
|
+
const changes = yield* UsageChanges;
|
|
26
|
+
const lifetime = yield* ProviderLifetime;
|
|
27
|
+
const auth = yield* AuthState;
|
|
28
|
+
const config = yield* ExtensionConfig;
|
|
29
|
+
const records = new Map<string, AccountRecord>();
|
|
30
|
+
let latestUsage: { scope: string; usage: AccountUsage } | undefined;
|
|
31
|
+
|
|
32
|
+
// A stopped provider records nothing more.
|
|
33
|
+
const record = (credential: Credential, ownerHash: string, status: JsonObject) => Effect.gen(function* () {
|
|
34
|
+
if (!(yield* lifetime.isActive)) return;
|
|
35
|
+
const scope = catalogScope(credential);
|
|
36
|
+
evictOldest(records, 16);
|
|
37
|
+
records.set(scope, { ownerHash, status });
|
|
38
|
+
const usage = accountUsage(status, config.now());
|
|
39
|
+
latestUsage = { scope, usage };
|
|
40
|
+
// Subagents and the Fusion sidekick run in other processes; a failed write only loses sharing.
|
|
41
|
+
yield* Effect.ignore(store.write(scope, usage));
|
|
42
|
+
yield* changes.publish;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
get: (credential) => Effect.sync(() => records.get(catalogScope(credential))),
|
|
47
|
+
check: (credential, check) => Effect.scoped(Effect.gen(function* () {
|
|
48
|
+
const client = yield* clients.open(credential, { profile: "login", ...check.connection });
|
|
49
|
+
const status = yield* client.status();
|
|
50
|
+
if ((yield* auth.generation) !== check.expectedGeneration) return "stale" as const;
|
|
51
|
+
if (check.beforeRecord) yield* check.beforeRecord;
|
|
52
|
+
yield* record(credential, yield* requiredString(yield* client.accountIdentityHash, "Devin account scope"), status);
|
|
53
|
+
return "recorded" as const;
|
|
54
|
+
})),
|
|
55
|
+
forget: (credential) => Effect.sync(() => {
|
|
56
|
+
records.delete(catalogScope(credential));
|
|
57
|
+
latestUsage = undefined;
|
|
58
|
+
}),
|
|
59
|
+
clear: Effect.sync(() => {
|
|
60
|
+
records.clear();
|
|
61
|
+
latestUsage = undefined;
|
|
62
|
+
}),
|
|
63
|
+
usage: (scope) => Effect.map(store.read(scope), (shared) => {
|
|
64
|
+
const local = latestUsage?.scope === scope ? latestUsage.usage : undefined;
|
|
65
|
+
const newest = shared && (!local || shared.fetchedAtMs > local.fetchedAtMs) ? shared : local;
|
|
66
|
+
return newest ? { ...newest } : undefined;
|
|
67
|
+
}),
|
|
68
|
+
} satisfies AccountsShape;
|
|
69
|
+
}));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { DevinFailure } from "../../../core/errors.ts";
|
|
3
|
+
import type { Credential, JsonObject } from "../../../core/types.ts";
|
|
4
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
5
|
+
|
|
6
|
+
export interface AccountRecord { ownerHash: string; status: JsonObject }
|
|
7
|
+
|
|
8
|
+
export interface AccountCheck {
|
|
9
|
+
/** Sign-in generation the caller started in. When a newer sign-in started meanwhile, nothing is recorded. */
|
|
10
|
+
expectedGeneration: number;
|
|
11
|
+
/** Connection settings of the lookup, such as Pi's per-request fetch and time limit. */
|
|
12
|
+
connection?: { fetch?: typeof fetch; timeoutMs?: number };
|
|
13
|
+
/** Runs after the generation check and before recording. Its failure leaves the result unrecorded. */
|
|
14
|
+
beforeRecord?: Effect.Effect<void, DevinFailure>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AccountsShape {
|
|
18
|
+
readonly get: (credential: Credential) => Effect.Effect<AccountRecord | undefined>;
|
|
19
|
+
/** Looks up the account status and records it. `"stale"` when a newer sign-in started meanwhile. Failure policy belongs to the caller. */
|
|
20
|
+
readonly check: (credential: Credential, check: AccountCheck) => Effect.Effect<"recorded" | "stale", DevinFailure>;
|
|
21
|
+
readonly forget: (credential: Credential) => Effect.Effect<void>;
|
|
22
|
+
readonly clear: Effect.Effect<void>;
|
|
23
|
+
/** Newest quota for the scope, received here or by another process sharing the usage store. Makes no request. */
|
|
24
|
+
readonly usage: (scope: string) => Effect.Effect<AccountUsage | undefined>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
5
|
+
import { endpoint } from "../../../core/protocol/endpoint.ts";
|
|
6
|
+
import type { Credential } from "../../../core/types.ts";
|
|
7
|
+
import { failWith, object, requiredString } from "../../../core/validation.ts";
|
|
8
|
+
|
|
9
|
+
export const OAUTH_ONLY_MESSAGE = "Devin uses only the OAuth account session from /login. API key authentication is not supported.";
|
|
10
|
+
/**
|
|
11
|
+
* Pi checks the stored session again after this interval. It is not a server TTL: the server expiry is unknown, no new token
|
|
12
|
+
* is issued, and each model request still checks the account outside Pi's credential lock.
|
|
13
|
+
*/
|
|
14
|
+
export const SESSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export function credentialFromOAuth(credential: OAuthCredential): Effect.Effect<Credential, DevinError> {
|
|
17
|
+
return Effect.gen(function* () {
|
|
18
|
+
// 0.1.4 and earlier stored `expires: 0`; Pi checks such a session again on its next use.
|
|
19
|
+
if (credential.type !== "oauth" || credential.refresh !== "" || !Number.isSafeInteger(credential.expires) || credential.expires < 0) {
|
|
20
|
+
return yield* failWith("Unsupported Devin account credential. Sign in again with /login.");
|
|
21
|
+
}
|
|
22
|
+
const metadata = yield* object(credential.devin, "Devin credential metadata");
|
|
23
|
+
if (metadata.version !== 1) return yield* failWith("The Devin account credential version differs. Sign in again with /login.");
|
|
24
|
+
return {
|
|
25
|
+
version: 1,
|
|
26
|
+
sessionToken: yield* requiredString(credential.access, "Devin credential access"),
|
|
27
|
+
apiServerURL: yield* endpoint(yield* requiredString(metadata.apiServerURL, "Devin credential API server")),
|
|
28
|
+
createdAt: yield* requiredString(metadata.createdAt, "Devin credential creation time"),
|
|
29
|
+
} satisfies Credential;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function credentialKey(credential: Credential): string {
|
|
34
|
+
return createHash("sha256").update(credential.apiServerURL + "\0" + credential.sessionToken).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Endpoint mismatches are rejected separately by the model list scope. This only checks that the session came from OAuth. */
|
|
38
|
+
export function sessionKey(credential: Credential): string {
|
|
39
|
+
return createHash("sha256").update(credential.sessionToken).digest("hex");
|
|
40
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { isAuthenticationError } from "../../../core/errors.ts";
|
|
3
|
+
import { Accounts } from "../accounts/service.ts";
|
|
4
|
+
import { Runs } from "../runs/service.ts";
|
|
5
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
6
|
+
import { AuthState } from "./state.ts";
|
|
7
|
+
import type { CredentialGuardShape } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** Turns a confirmed authentication failure into its consequences, in order and within the reporting effect. */
|
|
10
|
+
export class CredentialGuard extends Context.Service<CredentialGuard, CredentialGuardShape>()("pi-devin/CredentialGuard") {}
|
|
11
|
+
|
|
12
|
+
export const CredentialGuardLive = Layer.effect(CredentialGuard, Effect.gen(function* () {
|
|
13
|
+
const auth = yield* AuthState;
|
|
14
|
+
const accounts = yield* Accounts;
|
|
15
|
+
const runs = yield* Runs;
|
|
16
|
+
const changes = yield* UsageChanges;
|
|
17
|
+
return {
|
|
18
|
+
reportFailure: (credential, error, generation) => Effect.gen(function* () {
|
|
19
|
+
if (!isAuthenticationError(error) || !(yield* auth.markInvalid(credential, error, generation))) return;
|
|
20
|
+
yield* accounts.forget(credential);
|
|
21
|
+
yield* runs.abort(error);
|
|
22
|
+
yield* changes.publish;
|
|
23
|
+
}),
|
|
24
|
+
} satisfies CredentialGuardShape;
|
|
25
|
+
}));
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { AuthInteraction, OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Context, Duration, Effect, Layer } from "effect";
|
|
3
|
+
import { loginAccount } from "../../../core/auth/login.ts";
|
|
4
|
+
import { abortWith } from "../../../core/cancellation.ts";
|
|
5
|
+
import { Cancelled, isTransientConnectionFailure, toDevinFailure } from "../../../core/errors.ts";
|
|
6
|
+
import { HttpTransport, type HttpTransportShape } from "../../../core/services/http-transport.ts";
|
|
7
|
+
import { failWith } from "../../../core/validation.ts";
|
|
8
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
9
|
+
import { Accounts } from "../accounts/service.ts";
|
|
10
|
+
import { ModelCatalog } from "../catalog/service.ts";
|
|
11
|
+
import { DevinClients } from "../clients/service.ts";
|
|
12
|
+
import { ProviderLifetime } from "../lifetime/service.ts";
|
|
13
|
+
import { Runs } from "../runs/service.ts";
|
|
14
|
+
import { UsageChanges } from "../usage/changes.ts";
|
|
15
|
+
import { credentialFromOAuth, SESSION_CHECK_INTERVAL_MS } from "./credential.ts";
|
|
16
|
+
import { CredentialGuard } from "./guard.ts";
|
|
17
|
+
import { AuthState } from "./state.ts";
|
|
18
|
+
import type { AccountLoginShape } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Upper bound of the account check right after sign-in. Sign-in succeeds even when this check fails. */
|
|
21
|
+
const LOGIN_CHECK_TIMEOUT_MS = 15_000;
|
|
22
|
+
|
|
23
|
+
/** The transport of `/login` code exchanges, chosen by the environment like the provider's own transport. */
|
|
24
|
+
export class LoginTransport extends Context.Service<LoginTransport, HttpTransportShape>()("pi-devin/LoginTransport") {}
|
|
25
|
+
|
|
26
|
+
/** Pi's OAuth flows: sign-in, the periodic session check under Pi's credential lock, and request auth. */
|
|
27
|
+
export class AccountLogin extends Context.Service<AccountLogin, AccountLoginShape>()("pi-devin/AccountLogin") {}
|
|
28
|
+
|
|
29
|
+
/** A host prompt that is withdrawn, through its signal, when the waiting effect is interrupted. */
|
|
30
|
+
const prompt = <A>(request: (signal: AbortSignal) => Promise<A>) => Effect.tryPromise({ try: request, catch: toDevinFailure });
|
|
31
|
+
|
|
32
|
+
export const AccountLoginLive = Layer.effect(AccountLogin, Effect.gen(function* () {
|
|
33
|
+
const config = yield* ExtensionConfig;
|
|
34
|
+
const auth = yield* AuthState;
|
|
35
|
+
const accounts = yield* Accounts;
|
|
36
|
+
const guard = yield* CredentialGuard;
|
|
37
|
+
const catalog = yield* ModelCatalog;
|
|
38
|
+
const clients = yield* DevinClients;
|
|
39
|
+
const runs = yield* Runs;
|
|
40
|
+
const changes = yield* UsageChanges;
|
|
41
|
+
const lifetime = yield* ProviderLifetime;
|
|
42
|
+
const transport = yield* LoginTransport;
|
|
43
|
+
const now = config.now;
|
|
44
|
+
|
|
45
|
+
/** Stops the work when the caller's signal or the provider lifetime current at the start of the host call ends, failing with that reason. */
|
|
46
|
+
const stoppable = (signal: AbortSignal | undefined) => Effect.map(lifetime.current, (current) => <A, E>(effect: Effect.Effect<A, E>) =>
|
|
47
|
+
current.guard(effect).pipe(abortWith(signal)));
|
|
48
|
+
|
|
49
|
+
const signIn = (interaction: AuthInteraction) => Effect.gen(function* () {
|
|
50
|
+
const choice = yield* prompt((signal) => interaction.prompt({ type: "select", signal, message: "Devin account sign-in method", options: [
|
|
51
|
+
{ id: "browser", label: "Browser sign-in", description: "Choose an account, then return through a loopback callback." },
|
|
52
|
+
{ id: "manual", label: "Manual authorization code", description: "Enter a one-time code using the Devin CLI manual flow. This is not an API key prompt." },
|
|
53
|
+
] }));
|
|
54
|
+
if (choice !== "browser" && choice !== "manual") return yield* failWith("Unsupported Devin sign-in method.");
|
|
55
|
+
const credential = yield* loginAccount({
|
|
56
|
+
...config.login, flow: choice,
|
|
57
|
+
onURL: (url) => Effect.sync(() => interaction.notify({ type: "auth_url", url, instructions: "Return to the terminal after signing in. Signing in does not guarantee CLI access or quota." })),
|
|
58
|
+
promptCode: prompt((signal) => interaction.prompt({ type: "manual_code", message: "Paste the authorization code or callback URL. Do not enter an API key or session token.", signal })),
|
|
59
|
+
}).pipe(Effect.provideService(HttpTransport, transport));
|
|
60
|
+
return { choice, credential };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** A new session replaces everything learned from the previous one, in this order, before its own account is checked. */
|
|
64
|
+
const replaceSession = Effect.all([
|
|
65
|
+
accounts.clear, clients.clearFingerprints, catalog.invalidate,
|
|
66
|
+
runs.abort(new Cancelled({ message: "The Devin account was re-authenticated." })), changes.publish,
|
|
67
|
+
], { discard: true });
|
|
68
|
+
|
|
69
|
+
const login = (interaction: AuthInteraction) => Effect.gen(function* () {
|
|
70
|
+
const stop = yield* stoppable(interaction.signal);
|
|
71
|
+
const { choice, credential } = yield* stop(signIn(interaction));
|
|
72
|
+
const generation = yield* auth.acceptLogin(credential);
|
|
73
|
+
yield* replaceSession;
|
|
74
|
+
// Records the new session's account and quota so the same account's model list and usage are available at once.
|
|
75
|
+
// Sign-in is not a permission check: a failure here, including cancellation, is left to the first model request.
|
|
76
|
+
yield* stop(accounts.check(credential, { expectedGeneration: generation })).pipe(
|
|
77
|
+
Effect.timeoutOrElse({ duration: Duration.millis(LOGIN_CHECK_TIMEOUT_MS), orElse: () => Effect.void }),
|
|
78
|
+
Effect.ignore,
|
|
79
|
+
);
|
|
80
|
+
interaction.notify({ type: "info", message: "Signed in to the Devin account. The server expiry time and refresh grant are unknown. If authentication fails, sign in again with /login." });
|
|
81
|
+
return {
|
|
82
|
+
type: "oauth", access: credential.sessionToken, refresh: "", expires: now() + SESSION_CHECK_INTERVAL_MS,
|
|
83
|
+
devin: { version: 1, apiServerURL: credential.apiServerURL, createdAt: credential.createdAt, flow: choice },
|
|
84
|
+
} satisfies OAuthCredential;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const usableCredential = (current: OAuthCredential) => Effect.gen(function* () {
|
|
88
|
+
const credential = yield* credentialFromOAuth(current);
|
|
89
|
+
yield* auth.assertUsable(credential);
|
|
90
|
+
return yield* auth.rememberOAuth(credential);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const refresh = (current: OAuthCredential) => Effect.gen(function* () {
|
|
94
|
+
const credential = yield* usableCredential(current);
|
|
95
|
+
// Pi calls this while holding the shared auth.json lock once the check interval has passed, and saves the result.
|
|
96
|
+
// The new expiry only schedules the next check; the token is never renewed.
|
|
97
|
+
const checked = (): OAuthCredential => ({ ...current, expires: now() + SESSION_CHECK_INTERVAL_MS });
|
|
98
|
+
// Model requests already check the account outside the lock, so a process that checked it does not wait on the network.
|
|
99
|
+
if (yield* accounts.get(credential)) return checked();
|
|
100
|
+
const expectedGeneration = yield* auth.generation;
|
|
101
|
+
return yield* accounts.check(credential, { expectedGeneration }).pipe(
|
|
102
|
+
Effect.map(checked),
|
|
103
|
+
Effect.catch((error) => Effect.andThen(guard.reportFailure(credential, error, expectedGeneration),
|
|
104
|
+
// An unreachable check must not block the request: keep the stored session unchanged so the model request
|
|
105
|
+
// succeeds or reports its own retryable transport error, and the next use checks again.
|
|
106
|
+
isTransientConnectionFailure(error) ? Effect.succeed(current) : Effect.fail(error))),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
login,
|
|
112
|
+
refresh: (current, signal) => Effect.flatMap(stoppable(signal), (stop) => stop(refresh(current))),
|
|
113
|
+
// apiKey is only ModelAuth's transport field; the sign-in/storage auth type is always oauth.
|
|
114
|
+
toAuth: (current) => Effect.map(usableCredential(current), (credential) => ({ apiKey: credential.sessionToken, baseUrl: credential.apiServerURL })),
|
|
115
|
+
} satisfies AccountLoginShape;
|
|
116
|
+
}));
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { isAuthenticationError, RPCError } from "../../../core/errors.ts";
|
|
3
|
+
import type { Credential } from "../../../core/types.ts";
|
|
4
|
+
import { failWith } from "../../../core/validation.ts";
|
|
5
|
+
import { evictOldest } from "../bounded.ts";
|
|
6
|
+
import { credentialFromOAuth, credentialKey, OAUTH_ONLY_MESSAGE, sessionKey } from "./credential.ts";
|
|
7
|
+
import type { AuthStateShape } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a credential may be used: the sign-in generation, credentials invalidated by a confirmed 401,
|
|
11
|
+
* and the OAuth sessions this process has seen. Remembers only hashes, never a token.
|
|
12
|
+
*/
|
|
13
|
+
export class AuthState extends Context.Service<AuthState, AuthStateShape>()("pi-devin/AuthState") {}
|
|
14
|
+
|
|
15
|
+
export const AuthStateLive = Layer.sync(AuthState, () => {
|
|
16
|
+
const invalid = new Set<string>();
|
|
17
|
+
const oauthSessions = new Set<string>();
|
|
18
|
+
let generation = 0;
|
|
19
|
+
const rememberOAuth = (credential: Credential) => Effect.sync(() => {
|
|
20
|
+
const key = sessionKey(credential);
|
|
21
|
+
oauthSessions.delete(key);
|
|
22
|
+
evictOldest(oauthSessions, 64);
|
|
23
|
+
oauthSessions.add(key);
|
|
24
|
+
return credential;
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
generation: Effect.sync(() => generation),
|
|
28
|
+
assertUsable: (credential) => Effect.suspend(() => invalid.has(credentialKey(credential))
|
|
29
|
+
? Effect.fail(RPCError.make("unauthenticated", "Authentication failed for this session. Sign in again with /login.")) : Effect.void),
|
|
30
|
+
// Kept as a plain error so it does not invalidate authentication or cancel a running request.
|
|
31
|
+
assertOAuthSession: (credential) => Effect.suspend(() => oauthSessions.has(sessionKey(credential)) ? Effect.void : failWith(OAUTH_ONLY_MESSAGE)),
|
|
32
|
+
rememberOAuth,
|
|
33
|
+
fromHost: (credential) => Effect.flatMap(credentialFromOAuth(credential), rememberOAuth),
|
|
34
|
+
markInvalid: (credential, error, expectedGeneration) => Effect.sync(() => {
|
|
35
|
+
if (expectedGeneration !== generation || !isAuthenticationError(error)) return false;
|
|
36
|
+
const key = credentialKey(credential);
|
|
37
|
+
if (invalid.has(key)) return false;
|
|
38
|
+
evictOldest(invalid, 64);
|
|
39
|
+
invalid.add(key);
|
|
40
|
+
return true;
|
|
41
|
+
}),
|
|
42
|
+
acceptLogin: (credential) => Effect.gen(function* () {
|
|
43
|
+
invalid.delete(credentialKey(credential));
|
|
44
|
+
yield* rememberOAuth(credential);
|
|
45
|
+
return ++generation;
|
|
46
|
+
}),
|
|
47
|
+
} satisfies AuthStateShape;
|
|
48
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AuthInteraction, ModelAuth, OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { DevinError, DevinFailure, RPCError } from "../../../core/errors.ts";
|
|
4
|
+
import type { Credential } from "../../../core/types.ts";
|
|
5
|
+
|
|
6
|
+
export interface AuthStateShape {
|
|
7
|
+
/** Increases with every successful sign-in. Work captures it when it starts and acts on failures only within the same generation. */
|
|
8
|
+
readonly generation: Effect.Effect<number>;
|
|
9
|
+
/** Fails with an authentication error when this credential was invalidated. */
|
|
10
|
+
readonly assertUsable: (credential: Credential) => Effect.Effect<void, RPCError>;
|
|
11
|
+
/** Rejects sessions that did not come from an OAuth login or stored credential (API keys etc.) before any network request. */
|
|
12
|
+
readonly assertOAuthSession: (credential: Credential) => Effect.Effect<void, DevinError>;
|
|
13
|
+
/** Remembers the credential as this process's OAuth session. Pi resolves the session in use on every request, so it moves back to the front. */
|
|
14
|
+
readonly rememberOAuth: (credential: Credential) => Effect.Effect<Credential>;
|
|
15
|
+
/** Converts the OAuth credential stored by Pi into the shared credential and remembers it as an OAuth session. */
|
|
16
|
+
readonly fromHost: (credential: OAuthCredential) => Effect.Effect<Credential, DevinError>;
|
|
17
|
+
/** Marks a confirmed authentication failure of the given generation. True only when this call invalidated the credential. */
|
|
18
|
+
readonly markInvalid: (credential: Credential, error: unknown, generation: number) => Effect.Effect<boolean>;
|
|
19
|
+
/** A new sign-in: clears its invalid mark, remembers it and starts the next generation, which it returns. */
|
|
20
|
+
readonly acceptLogin: (credential: Credential) => Effect.Effect<number>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CredentialGuardShape {
|
|
24
|
+
/**
|
|
25
|
+
* Acts on a failure seen while using the credential. Only a confirmed authentication failure of the current generation
|
|
26
|
+
* invalidates it; then its account record is forgotten and the running run stops with the same error. The credential file is never deleted.
|
|
27
|
+
*/
|
|
28
|
+
readonly reportFailure: (credential: Credential, error: unknown, generation: number) => Effect.Effect<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pi's OAuth flows. Each call stops with the provider lifetime current when it started and with the caller's signal. */
|
|
32
|
+
export interface AccountLoginShape {
|
|
33
|
+
readonly login: (interaction: AuthInteraction) => Effect.Effect<OAuthCredential, DevinFailure>;
|
|
34
|
+
readonly refresh: (current: OAuthCredential, signal: AbortSignal) => Effect.Effect<OAuthCredential, DevinFailure>;
|
|
35
|
+
readonly toAuth: (current: OAuthCredential) => Effect.Effect<ModelAuth, DevinError | RPCError>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Makes room in an insertion-ordered set or map by forgetting its oldest entry once it holds `limit` entries. */
|
|
2
|
+
export function evictOldest<T>(collection: Set<T> | Map<T, unknown>, limit: number): void {
|
|
3
|
+
if (collection.size >= limit) collection.delete(collection.keys().next().value!);
|
|
4
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DevinAPI } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const DEVIN_API: DevinAPI = "devin-connect";
|
|
4
|
+
export const CATALOG_VERSION = 1;
|
|
5
|
+
/** Default sidekick when nothing is saved or the saved one is gone. The default lead is the group this UID belongs to. */
|
|
6
|
+
export const FUSION_SIDEKICK_UID = "swe-2-medium";
|
|
7
|
+
/** The single Fusion entry shown in `/model`. Lead and sidekick follow the global `/devin-fusion` choice. */
|
|
8
|
+
export const FUSION_MODEL_ID = "fusion";
|
|
9
|
+
|
|
10
|
+
export function isFusionModelID(id: string): boolean { return id === FUSION_MODEL_ID; }
|