alchemy 2.0.0-beta.3 → 2.0.0-beta.4
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/bin/{alchemy-effect.js → alchemy.js} +170 -170
- package/bin/alchemy.js.map +1 -0
- package/bin/{alchemy-effect.sh → alchemy.sh} +2 -2
- package/lib/cli/index.d.ts.map +1 -1
- package/lib/cli/index.js +167 -167
- package/lib/cli/index.js.map +1 -1
- package/package.json +38 -38
- package/src/AWS/AGENTS.md +10 -10
- package/src/AWS/Assets.ts +1 -1
- package/src/AWS/AuthProvider.ts +392 -0
- package/src/AWS/Credentials.ts +0 -1
- package/src/AWS/DynamoDB/Table.ts +63 -10
- package/src/AWS/EC2/VpcEndpoint.ts +4 -4
- package/src/AWS/EC2/hosted.ts +1 -1
- package/src/AWS/ECS/Task.ts +1 -1
- package/src/AWS/Kinesis/Stream.ts +44 -8
- package/src/AWS/Lambda/Function.ts +218 -10
- package/src/AWS/S3/Bucket.ts +26 -19
- package/src/AWS/S3/BucketNotifications.ts +1 -1
- package/src/AWS/SNS/Topic.ts +47 -10
- package/src/AWS/SQS/Queue.ts +66 -0
- package/src/Auth/AuthProvider.ts +33 -0
- package/src/Auth/Credentials.ts +56 -0
- package/src/Auth/Env.ts +45 -0
- package/src/Auth/Profile.ts +72 -0
- package/src/Auth/index.ts +2 -0
- package/src/Cloudflare/Auth/AuthProvider.ts +670 -0
- package/src/Cloudflare/Auth/OAuthClient.ts +315 -0
- package/src/Cloudflare/Container/ContainerApplication.ts +3 -3
- package/src/Cloudflare/Container/ContainerBinding.ts +2 -4
- package/src/Cloudflare/Container/StartContainer.ts +1 -1
- package/src/Cloudflare/Providers.ts +1 -6
- package/src/Cloudflare/R2/R2Bucket.ts +2 -2
- package/src/Cloudflare/Website/StaticSite.ts +44 -15
- package/src/Cloudflare/Website/Vite.ts +34 -12
- package/src/Cloudflare/Workers/Assets.ts +170 -207
- package/src/Cloudflare/Workers/DurableObjectNamespace.ts +230 -370
- package/src/Cloudflare/Workers/DurableObjectState.ts +63 -0
- package/src/Cloudflare/Workers/DurableObjectStorage.ts +256 -0
- package/src/Cloudflare/Workers/InferEnv.ts +11 -7
- package/src/Cloudflare/Workers/Rpc.ts +13 -2
- package/src/Cloudflare/Workers/ScheduledEvents.ts +185 -0
- package/src/Cloudflare/Workers/WebSocket.ts +1 -1
- package/src/Cloudflare/Workers/Worker.ts +261 -61
- package/src/Cloudflare/Workers/index.ts +3 -0
- package/src/Construct.ts +2 -2
- package/src/GitHub/Secret.ts +52 -7
- package/src/GitHub/Variable.ts +45 -2
- package/src/Kubernetes/client.ts +2 -2
- package/src/Output.ts +1 -1
- package/src/Platform.ts +10 -5
- package/src/Resource.ts +4 -4
- package/src/Test/Vitest.ts +4 -3
- package/src/Util/Clank.ts +77 -0
- package/src/Util/PlatformServices.ts +21 -0
- package/src/Util/dedent.ts +4 -1
- package/bin/alchemy-effect.js.map +0 -1
- package/src/Daemon/Client.ts +0 -116
- package/src/Daemon/Config.ts +0 -28
- package/src/Daemon/Errors.ts +0 -48
- package/src/Daemon/Lock.ts +0 -162
- package/src/Daemon/ProcessRegistry.ts +0 -284
- package/src/Daemon/RpcSchema.ts +0 -43
- package/src/Daemon/RpcServer.ts +0 -231
- package/src/Daemon/index.ts +0 -27
- package/src/Spawn.ts +0 -23
- /package/bin/{alchemy-effect.ts → alchemy.ts} +0 -0
package/src/Daemon/Client.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import * as NodeSocket from "@effect/platform-node/NodeSocket";
|
|
2
|
-
import * as Context from "effect/Context";
|
|
3
|
-
import * as Effect from "effect/Effect";
|
|
4
|
-
import * as FileSystem from "effect/FileSystem";
|
|
5
|
-
import * as Layer from "effect/Layer";
|
|
6
|
-
import * as Path from "effect/Path";
|
|
7
|
-
import * as Schedule from "effect/Schedule";
|
|
8
|
-
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
9
|
-
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
|
|
10
|
-
import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
|
|
11
|
-
import * as Socket from "effect/unstable/socket/Socket";
|
|
12
|
-
import { resolveSocketPath } from "./Config.ts";
|
|
13
|
-
import { DaemonConnectFailed, DaemonSocketNotReady } from "./Errors.ts";
|
|
14
|
-
import { DaemonRpcs } from "./RpcSchema.ts";
|
|
15
|
-
|
|
16
|
-
export type DaemonClient = Effect.Success<ReturnType<typeof makeClient>>;
|
|
17
|
-
|
|
18
|
-
export class Daemon extends Context.Service<Daemon, DaemonClient>()(
|
|
19
|
-
"alchemy/Cli/Daemon",
|
|
20
|
-
) {}
|
|
21
|
-
|
|
22
|
-
export const makeClient = (socketPath: string) =>
|
|
23
|
-
Effect.gen(function* () {
|
|
24
|
-
const socket = yield* NodeSocket.makeNet({ path: socketPath });
|
|
25
|
-
const protocol = yield* RpcClient.makeProtocolSocket().pipe(
|
|
26
|
-
Effect.provideService(Socket.Socket, socket),
|
|
27
|
-
Effect.provide(RpcSerialization.layerJson),
|
|
28
|
-
);
|
|
29
|
-
return yield* RpcClient.make(DaemonRpcs).pipe(
|
|
30
|
-
Effect.provideService(RpcClient.Protocol, protocol),
|
|
31
|
-
);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const DAEMON_BIN = "process-manager.ts";
|
|
35
|
-
const HEARTBEAT_INTERVAL = "1 seconds" as const;
|
|
36
|
-
|
|
37
|
-
const resolveDaemonBin = Effect.gen(function* () {
|
|
38
|
-
const path = yield* Path.Path;
|
|
39
|
-
return path.join(path.dirname(import.meta.dirname), "bin", DAEMON_BIN);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Layer that provides a connected `Daemon` client.
|
|
44
|
-
*
|
|
45
|
-
* On build:
|
|
46
|
-
* 1. Try to connect to the existing daemon socket
|
|
47
|
-
* 2. If that fails, spawn the daemon process and wait for the socket
|
|
48
|
-
* 3. Connect to the socket
|
|
49
|
-
* 4. Fork a heartbeat fiber that pings every second to keep the daemon alive
|
|
50
|
-
* 5. When the scope closes, the heartbeat stops (daemon may idle-shutdown)
|
|
51
|
-
*/
|
|
52
|
-
export const DaemonLive: Layer.Layer<
|
|
53
|
-
Daemon,
|
|
54
|
-
never,
|
|
55
|
-
FileSystem.FileSystem | Path.Path | ChildProcessSpawner
|
|
56
|
-
> = Layer.effect(
|
|
57
|
-
Daemon,
|
|
58
|
-
Effect.gen(function* () {
|
|
59
|
-
const socketPath = yield* resolveSocketPath;
|
|
60
|
-
|
|
61
|
-
const client = yield* tryConnect(socketPath).pipe(
|
|
62
|
-
Effect.catchTag("DaemonConnectFailed", () =>
|
|
63
|
-
Effect.gen(function* () {
|
|
64
|
-
yield* Effect.logInfo("Starting daemon…");
|
|
65
|
-
yield* startDaemonProcess.pipe(Effect.forkChild);
|
|
66
|
-
yield* waitForSocket(socketPath);
|
|
67
|
-
yield* Effect.sleep("200 millis");
|
|
68
|
-
return yield* tryConnect(socketPath);
|
|
69
|
-
}),
|
|
70
|
-
),
|
|
71
|
-
Effect.catchTag("PlatformError", (e) => Effect.die(e)),
|
|
72
|
-
Effect.catchTag("DaemonConnectFailed", () =>
|
|
73
|
-
Effect.die(new Error("Failed to connect to daemon")),
|
|
74
|
-
),
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
yield* client
|
|
78
|
-
.heartbeat(void 0 as any)
|
|
79
|
-
.pipe(
|
|
80
|
-
Effect.ignore,
|
|
81
|
-
Effect.repeat(Schedule.spaced(HEARTBEAT_INTERVAL)),
|
|
82
|
-
Effect.forkScoped,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
yield* Effect.logInfo("Connected to daemon");
|
|
86
|
-
|
|
87
|
-
return client;
|
|
88
|
-
}),
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
const waitForSocket = (socketPath: string) =>
|
|
92
|
-
Effect.gen(function* () {
|
|
93
|
-
const fs = yield* FileSystem.FileSystem;
|
|
94
|
-
yield* fs.exists(socketPath).pipe(
|
|
95
|
-
Effect.flatMap((exists) =>
|
|
96
|
-
exists ? Effect.void : Effect.fail(new DaemonSocketNotReady()),
|
|
97
|
-
),
|
|
98
|
-
Effect.retry(
|
|
99
|
-
Schedule.spaced("100 millis").pipe(Schedule.both(Schedule.recurs(50))),
|
|
100
|
-
),
|
|
101
|
-
Effect.catchTag("DaemonSocketNotReady", () =>
|
|
102
|
-
Effect.fail(new DaemonConnectFailed()),
|
|
103
|
-
),
|
|
104
|
-
);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
const tryConnect = (socketPath: string) =>
|
|
108
|
-
makeClient(socketPath).pipe(
|
|
109
|
-
Effect.catch(() => Effect.fail(new DaemonConnectFailed())),
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
const startDaemonProcess = Effect.gen(function* () {
|
|
113
|
-
const binPath = yield* resolveDaemonBin;
|
|
114
|
-
const cmd = ChildProcess.make("bun", ["run", binPath]);
|
|
115
|
-
yield* cmd;
|
|
116
|
-
});
|
package/src/Daemon/Config.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import * as Effect from "effect/Effect";
|
|
2
|
-
import * as FileSystem from "effect/FileSystem";
|
|
3
|
-
import * as Path from "effect/Path";
|
|
4
|
-
|
|
5
|
-
export const ALCHEMY_DIR = ".alchemy";
|
|
6
|
-
export const SOCKET_FILE = "daemon.sock";
|
|
7
|
-
export const LOCK_DIR_NAME = "daemon.lock";
|
|
8
|
-
export const PID_FILE_NAME = "pid";
|
|
9
|
-
export const DB_FILE = "daemon.db";
|
|
10
|
-
export const PROCESSES_DIR = "processes";
|
|
11
|
-
|
|
12
|
-
export const resolveAlchemyDir = Effect.gen(function* () {
|
|
13
|
-
const path = yield* Path.Path;
|
|
14
|
-
return path.resolve(ALCHEMY_DIR);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
export const ensureAlchemyDir = Effect.gen(function* () {
|
|
18
|
-
const fs = yield* FileSystem.FileSystem;
|
|
19
|
-
const dir = yield* resolveAlchemyDir;
|
|
20
|
-
yield* fs.makeDirectory(dir, { recursive: true });
|
|
21
|
-
return dir;
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export const resolveSocketPath = Effect.gen(function* () {
|
|
25
|
-
const path = yield* Path.Path;
|
|
26
|
-
const dir = yield* resolveAlchemyDir;
|
|
27
|
-
return path.join(dir, SOCKET_FILE);
|
|
28
|
-
});
|
package/src/Daemon/Errors.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import * as Data from "effect/Data";
|
|
2
|
-
import * as Schema from "effect/Schema";
|
|
3
|
-
|
|
4
|
-
// Schema-based errors used in the RPC contract (shared between client and server)
|
|
5
|
-
|
|
6
|
-
export class ProcessAlreadyExists extends Schema.TaggedClass<ProcessAlreadyExists>()(
|
|
7
|
-
"ProcessAlreadyExists",
|
|
8
|
-
{ id: Schema.String },
|
|
9
|
-
) {}
|
|
10
|
-
|
|
11
|
-
export class ProcessNotFound extends Schema.TaggedClass<ProcessNotFound>()(
|
|
12
|
-
"ProcessNotFound",
|
|
13
|
-
{ id: Schema.String },
|
|
14
|
-
) {}
|
|
15
|
-
|
|
16
|
-
// Data-based errors used internally by the daemon runtime
|
|
17
|
-
|
|
18
|
-
export class DaemonAlreadyRunning extends Data.TaggedError(
|
|
19
|
-
"DaemonAlreadyRunning",
|
|
20
|
-
)<{
|
|
21
|
-
readonly pid: number | undefined;
|
|
22
|
-
}> {
|
|
23
|
-
get message() {
|
|
24
|
-
return this.pid !== undefined
|
|
25
|
-
? `Daemon already running (pid ${this.pid})`
|
|
26
|
-
: "Daemon already running";
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class LockCompromised extends Data.TaggedError("LockCompromised")<{
|
|
31
|
-
readonly lockDir: string;
|
|
32
|
-
}> {}
|
|
33
|
-
|
|
34
|
-
export class DaemonSocketNotReady extends Data.TaggedError(
|
|
35
|
-
"DaemonSocketNotReady",
|
|
36
|
-
) {
|
|
37
|
-
get message() {
|
|
38
|
-
return "Daemon socket is not ready yet";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export class DaemonConnectFailed extends Data.TaggedError(
|
|
43
|
-
"DaemonConnectFailed",
|
|
44
|
-
) {
|
|
45
|
-
get message() {
|
|
46
|
-
return "Failed to connect to daemon";
|
|
47
|
-
}
|
|
48
|
-
}
|
package/src/Daemon/Lock.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import * as Deferred from "effect/Deferred";
|
|
2
|
-
import * as Duration from "effect/Duration";
|
|
3
|
-
import * as Effect from "effect/Effect";
|
|
4
|
-
import * as Fiber from "effect/Fiber";
|
|
5
|
-
import * as FileSystem from "effect/FileSystem";
|
|
6
|
-
import * as Option from "effect/Option";
|
|
7
|
-
import * as Path from "effect/Path";
|
|
8
|
-
import * as Ref from "effect/Ref";
|
|
9
|
-
import * as Schedule from "effect/Schedule";
|
|
10
|
-
import { ensureAlchemyDir, LOCK_DIR_NAME, PID_FILE_NAME } from "./Config.ts";
|
|
11
|
-
import { DaemonAlreadyRunning } from "./Errors.ts";
|
|
12
|
-
|
|
13
|
-
const STALE_THRESHOLD = Duration.seconds(10);
|
|
14
|
-
const LOCK_UPDATE_INTERVAL = Duration.seconds(4);
|
|
15
|
-
|
|
16
|
-
export const acquireLock = (shutdownSignal: Deferred.Deferred<void>) =>
|
|
17
|
-
Effect.gen(function* () {
|
|
18
|
-
const fs = yield* FileSystem.FileSystem;
|
|
19
|
-
const path = yield* Path.Path;
|
|
20
|
-
const dir = yield* ensureAlchemyDir;
|
|
21
|
-
const lockDir = path.join(dir, LOCK_DIR_NAME);
|
|
22
|
-
const pidFile = path.join(lockDir, PID_FILE_NAME);
|
|
23
|
-
|
|
24
|
-
const tryMkdir = Effect.gen(function* () {
|
|
25
|
-
yield* fs.makeDirectory(lockDir);
|
|
26
|
-
yield* fs.writeFileString(pidFile, String(process.pid));
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
yield* tryMkdir.pipe(
|
|
30
|
-
Effect.catchTag("PlatformError", (err) => {
|
|
31
|
-
if (err.reason._tag !== "AlreadyExists") return Effect.fail(err);
|
|
32
|
-
|
|
33
|
-
return Effect.gen(function* () {
|
|
34
|
-
const stale = yield* isLockStale(pidFile);
|
|
35
|
-
if (!stale) {
|
|
36
|
-
const pid = yield* readLockPid(lockDir);
|
|
37
|
-
return yield* new DaemonAlreadyRunning({ pid });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const pid = yield* readLockPid(lockDir);
|
|
41
|
-
if (pid !== undefined) {
|
|
42
|
-
const alive = yield* isProcessAlive(pid);
|
|
43
|
-
if (alive) {
|
|
44
|
-
return yield* new DaemonAlreadyRunning({ pid });
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
yield* Effect.logWarning(
|
|
49
|
-
`Removing stale lock (previous pid: ${pid ?? "unknown"})`,
|
|
50
|
-
);
|
|
51
|
-
yield* fs.remove(lockDir, { recursive: true, force: true });
|
|
52
|
-
|
|
53
|
-
yield* tryMkdir.pipe(
|
|
54
|
-
Effect.catchTag("PlatformError", (retryErr) =>
|
|
55
|
-
retryErr.reason._tag !== "AlreadyExists"
|
|
56
|
-
? Effect.fail<DaemonAlreadyRunning | typeof retryErr>(retryErr)
|
|
57
|
-
: Effect.gen(function* () {
|
|
58
|
-
const winnerPid = yield* readLockPid(lockDir);
|
|
59
|
-
return yield* new DaemonAlreadyRunning({ pid: winnerPid });
|
|
60
|
-
}),
|
|
61
|
-
),
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
}),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
|
-
const updaterFiber = yield* startLockUpdater(lockDir, shutdownSignal);
|
|
68
|
-
|
|
69
|
-
yield* Effect.addFinalizer(() =>
|
|
70
|
-
Effect.gen(function* () {
|
|
71
|
-
yield* Fiber.interrupt(updaterFiber).pipe(Effect.ignore);
|
|
72
|
-
yield* fs
|
|
73
|
-
.remove(lockDir, { recursive: true, force: true })
|
|
74
|
-
.pipe(Effect.ignore);
|
|
75
|
-
}),
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
export const isProcessAlive = (pid: number): Effect.Effect<boolean> =>
|
|
80
|
-
Effect.sync(() => {
|
|
81
|
-
try {
|
|
82
|
-
process.kill(pid, 0);
|
|
83
|
-
return true;
|
|
84
|
-
} catch {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const readLockPid = (lockDir: string) =>
|
|
90
|
-
Effect.gen(function* () {
|
|
91
|
-
const fs = yield* FileSystem.FileSystem;
|
|
92
|
-
const path = yield* Path.Path;
|
|
93
|
-
const pidFile = path.join(lockDir, PID_FILE_NAME);
|
|
94
|
-
const contents = yield* fs
|
|
95
|
-
.readFileString(pidFile)
|
|
96
|
-
.pipe(Effect.catchTag("PlatformError", () => Effect.succeed("")));
|
|
97
|
-
const pid = parseInt(contents.trim(), 10);
|
|
98
|
-
return isNaN(pid) ? undefined : pid;
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const isLockStale = (pidFile: string) =>
|
|
102
|
-
Effect.gen(function* () {
|
|
103
|
-
const fs = yield* FileSystem.FileSystem;
|
|
104
|
-
const info = yield* fs
|
|
105
|
-
.stat(pidFile)
|
|
106
|
-
.pipe(Effect.catchTag("PlatformError", () => Effect.succeed(undefined)));
|
|
107
|
-
if (info === undefined) return true;
|
|
108
|
-
const mtime = Option.getOrElse(info.mtime, () => new Date(0));
|
|
109
|
-
const age = Date.now() - mtime.getTime();
|
|
110
|
-
return age > Duration.toMillis(STALE_THRESHOLD);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const startLockUpdater = (
|
|
114
|
-
lockDir: string,
|
|
115
|
-
shutdownSignal: Deferred.Deferred<void>,
|
|
116
|
-
) =>
|
|
117
|
-
Effect.gen(function* () {
|
|
118
|
-
const fs = yield* FileSystem.FileSystem;
|
|
119
|
-
const path = yield* Path.Path;
|
|
120
|
-
const pidFile = path.join(lockDir, PID_FILE_NAME);
|
|
121
|
-
const lastMtime = yield* Ref.make<number>(Date.now());
|
|
122
|
-
|
|
123
|
-
const touch = Effect.gen(function* () {
|
|
124
|
-
const info = yield* fs
|
|
125
|
-
.stat(pidFile)
|
|
126
|
-
.pipe(
|
|
127
|
-
Effect.catchTag("PlatformError", () => Effect.succeed(undefined)),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
if (info === undefined) {
|
|
131
|
-
yield* Effect.logError("Lock compromised: pid file disappeared");
|
|
132
|
-
yield* Deferred.succeed(shutdownSignal, void 0);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const currentMtime = Option.getOrElse(info.mtime, () => new Date(0));
|
|
137
|
-
const expected = yield* Ref.get(lastMtime);
|
|
138
|
-
|
|
139
|
-
if (Math.abs(currentMtime.getTime() - expected) > 1000) {
|
|
140
|
-
yield* Effect.logError(
|
|
141
|
-
`Lock compromised: mtime changed externally (expected ${expected}, got ${currentMtime.getTime()})`,
|
|
142
|
-
);
|
|
143
|
-
yield* Deferred.succeed(shutdownSignal, void 0);
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const now = new Date();
|
|
148
|
-
yield* fs.utimes(pidFile, now, now).pipe(
|
|
149
|
-
Effect.catchTag("PlatformError", (err) => {
|
|
150
|
-
Effect.logWarning(`Failed to update lock mtime: ${err.message}`);
|
|
151
|
-
return Effect.void;
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
154
|
-
yield* Ref.set(lastMtime, now.getTime());
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
return yield* touch.pipe(
|
|
158
|
-
Effect.repeat(Schedule.spaced(LOCK_UPDATE_INTERVAL)),
|
|
159
|
-
Effect.asVoid,
|
|
160
|
-
Effect.forkChild,
|
|
161
|
-
);
|
|
162
|
-
});
|
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
import * as Effect from "effect/Effect";
|
|
2
|
-
import * as FileSystem from "effect/FileSystem";
|
|
3
|
-
import * as HashMap from "effect/HashMap";
|
|
4
|
-
import * as Option from "effect/Option";
|
|
5
|
-
import * as Path from "effect/Path";
|
|
6
|
-
import * as PubSub from "effect/PubSub";
|
|
7
|
-
import * as Ref from "effect/Ref";
|
|
8
|
-
import * as Scope from "effect/Scope";
|
|
9
|
-
import * as Stream from "effect/Stream";
|
|
10
|
-
import * as ChildProcess from "effect/unstable/process/ChildProcess";
|
|
11
|
-
import {
|
|
12
|
-
ChildProcessSpawner,
|
|
13
|
-
type ChildProcessHandle,
|
|
14
|
-
} from "effect/unstable/process/ChildProcessSpawner";
|
|
15
|
-
import { type SQLiteConnection } from "../SQLite/index.ts";
|
|
16
|
-
import { ensureAlchemyDir, PROCESSES_DIR } from "./Config.ts";
|
|
17
|
-
import { ProcessAlreadyExists, ProcessNotFound } from "./Errors.ts";
|
|
18
|
-
|
|
19
|
-
interface OutputMessage {
|
|
20
|
-
readonly fd: "stdout" | "stderr";
|
|
21
|
-
readonly text: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface ManagedProcess {
|
|
25
|
-
readonly id: string;
|
|
26
|
-
readonly handle: ChildProcessHandle;
|
|
27
|
-
readonly pubsub: PubSub.PubSub<OutputMessage>;
|
|
28
|
-
readonly stdoutLogPath: string;
|
|
29
|
-
readonly stderrLogPath: string;
|
|
30
|
-
readonly cursors: Ref.Ref<
|
|
31
|
-
HashMap.HashMap<string, { stdoutOffset: number; stderrOffset: number }>
|
|
32
|
-
>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
type Registry = Ref.Ref<HashMap.HashMap<string, ManagedProcess>>;
|
|
36
|
-
|
|
37
|
-
export const makeProcessRegistry = (db: SQLiteConnection) =>
|
|
38
|
-
Effect.gen(function* () {
|
|
39
|
-
const registry: Registry = yield* Ref.make(
|
|
40
|
-
HashMap.empty<string, ManagedProcess>(),
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
const fs = yield* FileSystem.FileSystem;
|
|
44
|
-
const pathSvc = yield* Path.Path;
|
|
45
|
-
const spawner = yield* ChildProcessSpawner;
|
|
46
|
-
const alchemyDir = yield* ensureAlchemyDir;
|
|
47
|
-
const processesDir = pathSvc.join(alchemyDir, PROCESSES_DIR);
|
|
48
|
-
yield* fs.makeDirectory(processesDir, { recursive: true });
|
|
49
|
-
|
|
50
|
-
const spawnProcess = (req: {
|
|
51
|
-
id: string;
|
|
52
|
-
command: string;
|
|
53
|
-
args: ReadonlyArray<string>;
|
|
54
|
-
cwd?: string;
|
|
55
|
-
env?: Record<string, string>;
|
|
56
|
-
}) =>
|
|
57
|
-
Effect.gen(function* () {
|
|
58
|
-
const current = yield* Ref.get(registry);
|
|
59
|
-
const existing = HashMap.get(current, req.id);
|
|
60
|
-
|
|
61
|
-
if (Option.isSome(existing)) {
|
|
62
|
-
const alive = yield* existing.value.handle.isRunning;
|
|
63
|
-
if (alive) {
|
|
64
|
-
return yield* Effect.fail(new ProcessAlreadyExists({ id: req.id }));
|
|
65
|
-
}
|
|
66
|
-
yield* Ref.update(registry, HashMap.remove(req.id));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const logDir = pathSvc.join(processesDir, req.id);
|
|
70
|
-
yield* fs.makeDirectory(logDir, { recursive: true });
|
|
71
|
-
const stdoutLogPath = pathSvc.join(logDir, "stdout.log");
|
|
72
|
-
const stderrLogPath = pathSvc.join(logDir, "stderr.log");
|
|
73
|
-
|
|
74
|
-
yield* fs
|
|
75
|
-
.writeFile(stdoutLogPath, new Uint8Array(0))
|
|
76
|
-
.pipe(Effect.ignore);
|
|
77
|
-
yield* fs
|
|
78
|
-
.writeFile(stderrLogPath, new Uint8Array(0))
|
|
79
|
-
.pipe(Effect.ignore);
|
|
80
|
-
|
|
81
|
-
const cmd = ChildProcess.make(req.command, req.args as string[], {
|
|
82
|
-
cwd: req.cwd,
|
|
83
|
-
env: req.env,
|
|
84
|
-
});
|
|
85
|
-
const childScope = yield* Scope.make();
|
|
86
|
-
const handle = yield* Scope.provide(spawner.spawn(cmd), childScope);
|
|
87
|
-
|
|
88
|
-
const insertStmt = yield* db.prepare(
|
|
89
|
-
"INSERT OR REPLACE INTO processes (id, pid, command, args, cwd) VALUES (?, ?, ?, ?, ?)",
|
|
90
|
-
);
|
|
91
|
-
yield* insertStmt.run(
|
|
92
|
-
req.id,
|
|
93
|
-
handle.pid,
|
|
94
|
-
req.command,
|
|
95
|
-
JSON.stringify(req.args),
|
|
96
|
-
req.cwd ?? null,
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
const pubsub = yield* PubSub.unbounded<OutputMessage>();
|
|
100
|
-
const cursors = yield* Ref.make(
|
|
101
|
-
HashMap.empty<
|
|
102
|
-
string,
|
|
103
|
-
{ stdoutOffset: number; stderrOffset: number }
|
|
104
|
-
>(),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const collectStream = (
|
|
108
|
-
stream: Stream.Stream<Uint8Array, any>,
|
|
109
|
-
fd: "stdout" | "stderr",
|
|
110
|
-
logPath: string,
|
|
111
|
-
) =>
|
|
112
|
-
Effect.scoped(
|
|
113
|
-
Effect.gen(function* () {
|
|
114
|
-
const logFile = yield* fs.open(logPath, { flag: "a" });
|
|
115
|
-
|
|
116
|
-
yield* stream.pipe(
|
|
117
|
-
Stream.runForEach((chunk) =>
|
|
118
|
-
Effect.gen(function* () {
|
|
119
|
-
yield* logFile.writeAll(chunk);
|
|
120
|
-
const text = new TextDecoder().decode(chunk);
|
|
121
|
-
yield* PubSub.publish(pubsub, { fd, text });
|
|
122
|
-
}),
|
|
123
|
-
),
|
|
124
|
-
Effect.ignore,
|
|
125
|
-
);
|
|
126
|
-
}),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
yield* collectStream(handle.stdout, "stdout", stdoutLogPath).pipe(
|
|
130
|
-
Effect.forkDetach,
|
|
131
|
-
);
|
|
132
|
-
yield* collectStream(handle.stderr, "stderr", stderrLogPath).pipe(
|
|
133
|
-
Effect.forkDetach,
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const managed: ManagedProcess = {
|
|
137
|
-
id: req.id,
|
|
138
|
-
handle,
|
|
139
|
-
pubsub,
|
|
140
|
-
stdoutLogPath,
|
|
141
|
-
stderrLogPath,
|
|
142
|
-
cursors,
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
yield* Ref.update(registry, HashMap.set(req.id, managed));
|
|
146
|
-
|
|
147
|
-
return { pid: handle.pid };
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
const killProcess = (req: { id: string; killSignal?: string }) =>
|
|
151
|
-
Effect.gen(function* () {
|
|
152
|
-
const current = yield* Ref.get(registry);
|
|
153
|
-
const existing = HashMap.get(current, req.id);
|
|
154
|
-
|
|
155
|
-
if (Option.isNone(existing)) {
|
|
156
|
-
return yield* Effect.fail(new ProcessNotFound({ id: req.id }));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const proc = existing.value;
|
|
160
|
-
yield* proc.handle
|
|
161
|
-
.kill(
|
|
162
|
-
req.killSignal
|
|
163
|
-
? { killSignal: req.killSignal as ChildProcess.Signal }
|
|
164
|
-
: undefined,
|
|
165
|
-
)
|
|
166
|
-
.pipe(Effect.ignore);
|
|
167
|
-
yield* proc.handle.exitCode.pipe(Effect.ignore);
|
|
168
|
-
|
|
169
|
-
const delStmt = yield* db.prepare("DELETE FROM processes WHERE id = ?");
|
|
170
|
-
yield* delStmt.run(req.id);
|
|
171
|
-
|
|
172
|
-
yield* Ref.update(registry, HashMap.remove(req.id));
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
const watchProcess = (req: { id: string; clientId: string }) =>
|
|
176
|
-
Stream.unwrap(
|
|
177
|
-
Effect.gen(function* () {
|
|
178
|
-
const current = yield* Ref.get(registry);
|
|
179
|
-
const existing = HashMap.get(current, req.id);
|
|
180
|
-
|
|
181
|
-
if (Option.isNone(existing)) {
|
|
182
|
-
return yield* Effect.fail(new ProcessNotFound({ id: req.id }));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const proc = existing.value;
|
|
186
|
-
|
|
187
|
-
const allCursors = yield* Ref.get(proc.cursors);
|
|
188
|
-
const cursor = Option.getOrElse(
|
|
189
|
-
HashMap.get(allCursors, req.clientId),
|
|
190
|
-
() => ({ stdoutOffset: 0, stderrOffset: 0 }),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
const replayFile = (
|
|
194
|
-
logPath: string,
|
|
195
|
-
fd: "stdout" | "stderr",
|
|
196
|
-
offset: number,
|
|
197
|
-
) =>
|
|
198
|
-
Effect.gen(function* () {
|
|
199
|
-
const content = yield* fs
|
|
200
|
-
.readFile(logPath)
|
|
201
|
-
.pipe(
|
|
202
|
-
Effect.catchTag("PlatformError", () =>
|
|
203
|
-
Effect.succeed(new Uint8Array(0)),
|
|
204
|
-
),
|
|
205
|
-
);
|
|
206
|
-
if (offset >= content.byteLength)
|
|
207
|
-
return { messages: [] as OutputMessage[], newOffset: offset };
|
|
208
|
-
const chunk = content.slice(offset);
|
|
209
|
-
const text = new TextDecoder().decode(chunk);
|
|
210
|
-
return {
|
|
211
|
-
messages: [{ fd, text } as OutputMessage],
|
|
212
|
-
newOffset: content.byteLength,
|
|
213
|
-
};
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
const stdoutReplay = yield* replayFile(
|
|
217
|
-
proc.stdoutLogPath,
|
|
218
|
-
"stdout",
|
|
219
|
-
cursor.stdoutOffset,
|
|
220
|
-
);
|
|
221
|
-
const stderrReplay = yield* replayFile(
|
|
222
|
-
proc.stderrLogPath,
|
|
223
|
-
"stderr",
|
|
224
|
-
cursor.stderrOffset,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const replayMessages = [
|
|
228
|
-
...stdoutReplay.messages,
|
|
229
|
-
...stderrReplay.messages,
|
|
230
|
-
].filter((m) => m.text.length > 0);
|
|
231
|
-
|
|
232
|
-
const cursorUpdate = Stream.fromEffect(
|
|
233
|
-
Effect.as(
|
|
234
|
-
Ref.update(
|
|
235
|
-
proc.cursors,
|
|
236
|
-
HashMap.set(req.clientId, {
|
|
237
|
-
stdoutOffset: stdoutReplay.newOffset,
|
|
238
|
-
stderrOffset: stderrReplay.newOffset,
|
|
239
|
-
}),
|
|
240
|
-
),
|
|
241
|
-
undefined as never,
|
|
242
|
-
),
|
|
243
|
-
).pipe(Stream.filter((_): _ is never => false));
|
|
244
|
-
|
|
245
|
-
const replayStream = Stream.concat(
|
|
246
|
-
Stream.fromIterable(replayMessages),
|
|
247
|
-
cursorUpdate,
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
const isRunning = yield* proc.handle.isRunning.pipe(
|
|
251
|
-
Effect.catchTag("PlatformError", () => Effect.succeed(false)),
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
if (!isRunning) {
|
|
255
|
-
return replayStream;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const liveStream = Stream.fromPubSub(proc.pubsub).pipe(
|
|
259
|
-
Stream.tap((msg) =>
|
|
260
|
-
Ref.update(proc.cursors, (cursorsMap) => {
|
|
261
|
-
const cur = Option.getOrElse(
|
|
262
|
-
HashMap.get(cursorsMap, req.clientId),
|
|
263
|
-
() => ({
|
|
264
|
-
stdoutOffset: cursor.stdoutOffset,
|
|
265
|
-
stderrOffset: cursor.stderrOffset,
|
|
266
|
-
}),
|
|
267
|
-
);
|
|
268
|
-
const byteLen = new TextEncoder().encode(msg.text).byteLength;
|
|
269
|
-
return HashMap.set(cursorsMap, req.clientId, {
|
|
270
|
-
stdoutOffset:
|
|
271
|
-
cur.stdoutOffset + (msg.fd === "stdout" ? byteLen : 0),
|
|
272
|
-
stderrOffset:
|
|
273
|
-
cur.stderrOffset + (msg.fd === "stderr" ? byteLen : 0),
|
|
274
|
-
});
|
|
275
|
-
}),
|
|
276
|
-
),
|
|
277
|
-
);
|
|
278
|
-
|
|
279
|
-
return Stream.concat(replayStream, liveStream);
|
|
280
|
-
}),
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
return { registry, spawnProcess, killProcess, watchProcess };
|
|
284
|
-
});
|
package/src/Daemon/RpcSchema.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import * as Schema from "effect/Schema";
|
|
2
|
-
import { Rpc, RpcGroup } from "effect/unstable/rpc";
|
|
3
|
-
import { ProcessAlreadyExists, ProcessNotFound } from "./Errors.ts";
|
|
4
|
-
|
|
5
|
-
const heartbeat = Rpc.make("heartbeat", {
|
|
6
|
-
success: Schema.Void,
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
const spawn = Rpc.make("spawn", {
|
|
10
|
-
payload: {
|
|
11
|
-
id: Schema.String,
|
|
12
|
-
command: Schema.String,
|
|
13
|
-
args: Schema.Array(Schema.String),
|
|
14
|
-
cwd: Schema.optionalKey(Schema.String),
|
|
15
|
-
env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
16
|
-
},
|
|
17
|
-
error: ProcessAlreadyExists,
|
|
18
|
-
success: Schema.Struct({ pid: Schema.Number }),
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
const kill = Rpc.make("kill", {
|
|
22
|
-
payload: {
|
|
23
|
-
id: Schema.String,
|
|
24
|
-
killSignal: Schema.optionalKey(Schema.String),
|
|
25
|
-
},
|
|
26
|
-
error: ProcessNotFound,
|
|
27
|
-
success: Schema.Void,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const watch = Rpc.make("watch", {
|
|
31
|
-
payload: {
|
|
32
|
-
id: Schema.String,
|
|
33
|
-
clientId: Schema.String,
|
|
34
|
-
},
|
|
35
|
-
success: Schema.Struct({
|
|
36
|
-
fd: Schema.Literals(["stdout", "stderr"]),
|
|
37
|
-
text: Schema.String,
|
|
38
|
-
}),
|
|
39
|
-
error: ProcessNotFound,
|
|
40
|
-
stream: true,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
export class DaemonRpcs extends RpcGroup.make(heartbeat, spawn, kill, watch) {}
|