Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions apps/server/src/open.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { assertSuccess } from "@effect/vitest/utils";
import { FileSystem, Path, Effect } from "effect";
import { FileSystem, Path, Effect, Sink, Stream } from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";

import {
isCommandAvailable,
Expand Down Expand Up @@ -475,20 +476,53 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
});

it.layer(NodeServices.layer)("launchDetached", (it) => {
it.effect("resolves when command can be spawned", () =>
it.effect("spawns through the Effect child process service", () =>
Effect.gen(function* () {
let capturedCommand: unknown;
let unrefCalled = false;
const spawner = ChildProcessSpawner.make((command) =>
Effect.sync(() => {
capturedCommand = command;
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(123),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.sync(() => {
unrefCalled = true;
return Effect.void;
}),
stdin: Sink.drain,
stdout: Stream.empty,
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
});
}),
);

const result = yield* launchDetached({
command: process.execPath,
args: ["-e", "process.exit(0)"],
}).pipe(Effect.result);
}).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.result,
);

assertSuccess(result, undefined);
assert.isTrue(unrefCalled);
assert.deepInclude(capturedCommand as Record<string, unknown>, {
command: process.execPath,
args: ["-e", "process.exit(0)"],
});
}),
);

it.effect("rejects when command does not exist", () =>
Effect.gen(function* () {
const result = yield* launchDetached({
command: `t3code-no-such-command-${Date.now()}`,
command: "t3code-no-such-command-effect-child-process",
args: [],
}).pipe(Effect.result);
assert.equal(result._tag, "Failure");
Expand Down
74 changes: 43 additions & 31 deletions apps/server/src/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
*
* @module Open
*/
import { spawn } from "node:child_process";

import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
import { Context, Effect, Layer } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

// ==============================
// Definitions
Expand Down Expand Up @@ -182,44 +181,54 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
return { command: fileManagerCommandForPlatform(platform), args: [input.cwd] };
});

export const launchDetached = (launch: EditorLaunch) =>
const launchDetachedWithSpawner = (
launch: EditorLaunch,
spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
) =>
Effect.gen(function* () {
if (!isCommandAvailable(launch.command)) {
return yield* new OpenError({ message: `Editor command not found: ${launch.command}` });
}

yield* Effect.callback<void, OpenError>((resume) => {
let child;
try {
yield* Effect.scoped(
Effect.gen(function* () {
const isWin32 = process.platform === "win32";
child = spawn(
launch.command,
isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args],
{
detached: true,
stdio: "ignore",
shell: isWin32,
},
);
} catch (error) {
return resume(
Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
const child = yield* spawner
.spawn(
ChildProcess.make(
launch.command,
isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args],
{
detached: true,
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
shell: isWin32,
},
),
)
.pipe(
Effect.mapError(
(cause) => new OpenError({ message: "failed to spawn detached process", cause }),
),
);
const _reref = yield* child.unref.pipe(
Effect.mapError(
(cause) => new OpenError({ message: "failed to unref detached process", cause }),
),
);
}

const handleSpawn = () => {
child.unref();
resume(Effect.void);
};
}),
);
});

child.once("spawn", handleSpawn);
child.once("error", (cause) =>
resume(Effect.fail(new OpenError({ message: "failed to spawn detached process", cause }))),
);
});
export const launchDetached = (launch: EditorLaunch) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
return yield* launchDetachedWithSpawner(launch, spawner);
});

const make = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const open = yield* Effect.tryPromise({
try: () => import("open"),
catch: (cause) => new OpenError({ message: "failed to load browser opener", cause }),
Expand All @@ -230,8 +239,11 @@ const make = Effect.gen(function* () {
Effect.tryPromise({
try: () => open.default(target),
catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }),
}),
openInEditor: (input) => Effect.flatMap(resolveEditorLaunch(input), launchDetached),
}).pipe(Effect.asVoid),
openInEditor: (input) =>
Effect.flatMap(resolveEditorLaunch(input), (launch) =>
launchDetachedWithSpawner(launch, spawner),
),
} satisfies OpenShape;
});

Expand Down
70 changes: 70 additions & 0 deletions apps/server/src/orchestration/decider.clock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { CommandId, EventId, ProjectId } from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import { DateTime, Duration, Effect, Random } from "effect";
import { TestClock } from "effect/testing";

import { decideOrchestrationCommand } from "./decider.ts";
import { createEmptyReadModel, projectEvent } from "./projector.ts";

const projectId = ProjectId.make("project-clock");
const createdAt = "2026-01-01T00:00:00.000Z";
const zeroRandomService = {
nextIntUnsafe: () => 0,
nextDoubleUnsafe: () => 0,
};

it.effect("uses the Effect clock for generated project update timestamps", () =>
Effect.gen(function* () {
const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
sequence: 1,
eventId: EventId.make("evt-project-clock"),
aggregateKind: "project",
aggregateId: projectId,
type: "project.created",
occurredAt: createdAt,
commandId: CommandId.make("cmd-project-clock-create"),
causationEventId: null,
correlationId: CommandId.make("cmd-project-clock-create"),
metadata: {},
payload: {
projectId,
title: "Clock",
workspaceRoot: "/tmp/clock",
defaultModelSelection: null,
scripts: [],
createdAt,
updatedAt: createdAt,
},
});

yield* TestClock.adjust(Duration.seconds(5));
const expectedNow = DateTime.formatIso(yield* DateTime.now);
const result = yield* decideOrchestrationCommand({
command: {
type: "project.meta.update",
commandId: CommandId.make("cmd-project-clock-update"),
projectId,
title: "Clock Updated",
},
readModel,
});
const events = Array.isArray(result) ? [...result] : [result];
assert.lengthOf(events, 1);
const event = events[0];
if (!event) {
assert.fail("expected a project meta-updated event");
return;
}
if (event.type !== "project.meta-updated") {
assert.fail(`expected project.meta-updated, received ${event.type}`);
return;
}

assert.equal(event.occurredAt, expectedNow);
assert.equal(event.eventId, EventId.make("00000000-0000-4000-8000-000000000000"));
assert.equal(event.payload.updatedAt, expectedNow);
}).pipe(
Effect.provide(TestClock.layer()),
Effect.provideService(Random.Random, zeroRandomService),
),
);
Loading
Loading