Skip to content

Commit 4a475e2

Browse files
committed
fix(effect): align API and session-sync compliance boundaries
- add API and session-sync lint:effect gates and wire them into the root script - decode session-sync boundary payloads with schemas and run the CLI entrypoint through Effect NodeRuntime - move API agent snapshot persistence and terminal persistence queue toward Effect/platform primitives - keep command builder core pure by passing clonedOnHostname explicitly Proof of fix: - rtk bun run lint - rtk bun run typecheck - rtk bun run --cwd packages/api typecheck - rtk bun run --cwd packages/api lint - rtk bun run test - rtk bun run --cwd packages/api test - rtk bun run lint:effect
1 parent 74b74e5 commit 4a475e2

26 files changed

Lines changed: 754 additions & 242 deletions

bun.lock

Lines changed: 16 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"web:serve": "bun run --cwd packages/app serve:web",
4747
"lint": "bun run --filter @prover-coder-ai/docker-git-terminal lint && bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint",
4848
"lint:tests": "bun run --filter @prover-coder-ai/docker-git lint:tests",
49-
"lint:effect": "bun run --filter @prover-coder-ai/docker-git-terminal lint:effect && bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect",
49+
"lint:effect": "bun run --filter @prover-coder-ai/docker-git-session-sync lint:effect && bun run --filter @prover-coder-ai/docker-git-terminal lint:effect && bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @prover-coder-ai/docker-git-container lint:effect && bun run --filter @effect-template/lib lint:effect && bun run --filter @effect-template/api lint:effect",
5050
"test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git-terminal test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test",
5151
"typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git-terminal typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck",
5252
"start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// CHANGE: add API-local Effect-TS compliance lint profile.
2+
// WHY: expose Effect migration blockers in the API package, which previously only ran baseline ESLint.
3+
// QUOTE(TZ): "packages/api не защищён lint:effect"
4+
// REF: user-request-2026-06-17-effect-compliance-api
5+
// SOURCE: n/a
6+
// FORMAT THEOREM: lint(api) = hard unsafe bypass errors + visible Effect migration blockers
7+
// PURITY: SHELL
8+
// EFFECT: eslint config
9+
// INVARIANT: config does not alter runtime behavior.
10+
// COMPLEXITY: O(1)/O(1)
11+
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"
12+
import globals from "globals"
13+
import tseslint from "typescript-eslint"
14+
15+
const migrationBlockers = [
16+
{
17+
selector: "SwitchStatement",
18+
message: "Effect migration blocker: use Match.exhaustive instead of switch."
19+
},
20+
{
21+
selector: "TryStatement",
22+
message: "Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch."
23+
},
24+
{
25+
selector: "AwaitExpression",
26+
message: "Effect migration blocker: use Effect.gen / Effect.flatMap instead of await."
27+
},
28+
{
29+
selector: "FunctionDeclaration[async=true], FunctionExpression[async=true], ArrowFunctionExpression[async=true]",
30+
message: "Effect migration blocker: use Effect.gen / Effect.tryPromise instead of async functions."
31+
},
32+
{
33+
selector: "NewExpression[callee.name='Promise']",
34+
message: "Effect migration blocker: use Effect.async / Effect.tryPromise instead of new Promise."
35+
},
36+
{
37+
selector: "CallExpression[callee.object.name='Promise']",
38+
message: "Effect migration blocker: use Effect combinators instead of Promise.*."
39+
}
40+
]
41+
42+
export default tseslint.config({
43+
name: "api-effect-ts-compliance",
44+
files: ["src/**/*.ts", "tests/**/*.ts"],
45+
languageOptions: {
46+
parser: tseslint.parser,
47+
globals: { ...globals.node }
48+
},
49+
plugins: {
50+
"@typescript-eslint": tseslint.plugin,
51+
"eslint-comments": eslintComments
52+
},
53+
rules: {
54+
"@typescript-eslint/ban-ts-comment": ["error", {
55+
"ts-check": false,
56+
"ts-expect-error": true,
57+
"ts-ignore": true,
58+
"ts-nocheck": true
59+
}],
60+
"@typescript-eslint/no-explicit-any": "error",
61+
"@typescript-eslint/no-restricted-types": ["warn", {
62+
types: {
63+
Promise: {
64+
message: "Avoid Promise in public types. Use Effect.Effect<A, E, R>."
65+
},
66+
"Promise<*>": {
67+
message: "Avoid Promise<T>. Use Effect.Effect<T, E, R>."
68+
}
69+
}
70+
}],
71+
"eslint-comments/disable-enable-pair": "error",
72+
"eslint-comments/no-unlimited-disable": "error",
73+
"eslint-comments/no-unused-disable": "error",
74+
"eslint-comments/no-use": "error",
75+
"no-console": "error",
76+
"no-restricted-syntax": ["warn", ...migrationBlockers]
77+
}
78+
})

packages/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"pretypecheck": "bun run --cwd ../terminal build && bun run --cwd ../container build && bun run --cwd ../lib build",
1616
"typecheck": "tsc --noEmit -p tsconfig.json",
1717
"lint": "eslint .",
18+
"lint:effect": "eslint --config eslint.effect-ts-check.config.mjs .",
1819
"pretest": "bun run --cwd ../terminal build && bun run --cwd ../container build && bun run --cwd ../lib build",
1920
"test": "vitest run"
2021
},
@@ -41,6 +42,7 @@
4142
"homepage": "https://github.com/ProverCoderAI/docker-git#readme",
4243
"devDependencies": {
4344
"@effect/vitest": "^0.29.0",
45+
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
4446
"@eslint/js": "10.0.1",
4547
"@types/node": "^25.9.3",
4648
"@types/ws": "^8.18.1",
@@ -50,6 +52,7 @@
5052
"fast-check": "4.8.0",
5153
"globals": "^17.6.0",
5254
"typescript": "^6.0.3",
55+
"typescript-eslint": "^8.61.1",
5356
"vitest": "^4.1.9"
5457
}
5558
}

packages/api/src/api/contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ export type CreateProjectRequest = {
480480
readonly forceEnv?: boolean | undefined
481481
readonly waitForClone?: boolean | undefined
482482
readonly async?: boolean | undefined
483+
readonly clonedOnHostname?: string | undefined
483484
}
484485

485486
export type AgentEnvVar = {

packages/api/src/api/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export const CreateProjectRequestSchema = Schema.Struct({
5151
force: OptionalBoolean,
5252
forceEnv: OptionalBoolean,
5353
waitForClone: OptionalBoolean,
54-
async: OptionalBoolean
54+
async: OptionalBoolean,
55+
clonedOnHostname: OptionalString
5556
})
5657

5758
export const GithubAuthLoginRequestSchema = Schema.Struct({

packages/api/src/services/agents.ts

Lines changed: 96 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@ import { recordProjectRuntimeActivity } from "@effect-template/lib"
22
import { runCommandWithExitCodes } from "@effect-template/lib/shell/command-runner"
33
import { CommandFailedError } from "@effect-template/lib/shell/errors"
44
import { defaultProjectsRoot } from "@effect-template/lib/usecases/path-helpers"
5+
import type { PlatformError } from "@effect/platform/Error"
6+
import * as FileSystem from "@effect/platform/FileSystem"
57
import { NodeContext } from "@effect/platform-node"
6-
import { Effect } from "effect"
8+
import { Effect, Either } from "effect"
9+
import * as ParseResult from "effect/ParseResult"
10+
import * as Schema from "effect/Schema"
711
import { spawn, type ChildProcess } from "node:child_process"
812
import { randomUUID } from "node:crypto"
9-
import { promises as fs } from "node:fs"
10-
import { join } from "node:path"
13+
import { dirname, join } from "node:path"
1114

1215
import type {
1316
AgentLogLine,
1417
AgentSession,
1518
CreateAgentRequest,
1619
ProjectDetails
1720
} from "../api/contracts.js"
21+
import { AgentSessionSchema } from "../api/schema.js"
1822
import { ApiBadRequestError, ApiConflictError, ApiNotFoundError } from "../api/errors.js"
1923
import { emitProjectEvent } from "./events.js"
2024

@@ -31,11 +35,28 @@ type SnapshotFile = {
3135
readonly sessions: ReadonlyArray<AgentSession>
3236
}
3337

38+
const SnapshotFileSchema = Schema.Struct({
39+
sessions: Schema.Array(AgentSessionSchema)
40+
})
41+
42+
const SnapshotFileJsonSchema = Schema.parseJson(SnapshotFileSchema)
43+
3444
const records: Map<string, AgentRecord> = new Map()
3545
const projectIndex: Map<string, Set<string>> = new Map()
3646
const maxLogLines = 5000
3747
let initialized = false
3848

49+
export const clearAgentRuntimeForTest = (): void => {
50+
for (const record of records.values()) {
51+
if (record.process !== null && !record.process.killed) {
52+
record.process.kill("SIGTERM")
53+
}
54+
}
55+
records.clear()
56+
projectIndex.clear()
57+
initialized = false
58+
}
59+
3960
const nowIso = (): string => new Date().toISOString()
4061

4162
const stateFilePath = (): string =>
@@ -184,19 +205,32 @@ export const buildAgentDockerExecArgs = (
184205
const trimLogs = (logs: Array<AgentLogLine>): Array<AgentLogLine> =>
185206
logs.length <= maxLogLines ? logs : logs.slice(logs.length - maxLogLines)
186207

187-
const persistSnapshot = async (): Promise<void> => {
188-
const filePath = stateFilePath()
189-
await fs.mkdir(join(filePath, ".."), { recursive: true })
190-
const payload: SnapshotFile = {
191-
sessions: [...records.values()].map((record) => record.session)
192-
}
193-
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8")
194-
}
208+
const emptySnapshotFile = (): SnapshotFile => ({
209+
sessions: []
210+
})
195211

196-
const persistSnapshotBestEffort = (): void => {
197-
void persistSnapshot().catch(() => {
198-
// best effort snapshot persistence
212+
const decodeSnapshotFile = (input: string): SnapshotFile | null =>
213+
Either.match(ParseResult.decodeUnknownEither(SnapshotFileJsonSchema)(input), {
214+
onLeft: () => null,
215+
onRight: (value) => value
199216
})
217+
218+
const persistSnapshot = (): Effect.Effect<void, PlatformError, FileSystem.FileSystem> =>
219+
Effect.gen(function*(_) {
220+
const fs = yield* _(FileSystem.FileSystem)
221+
const filePath = stateFilePath()
222+
const payload: SnapshotFile = {
223+
sessions: [...records.values()].map((record) => record.session)
224+
}
225+
yield* _(fs.makeDirectory(dirname(filePath), { recursive: true }))
226+
yield* _(fs.writeFileString(filePath, `${JSON.stringify(payload, null, 2)}\n`))
227+
})
228+
229+
const persistSnapshotBestEffort = (): void => {
230+
Effect.runFork(persistSnapshot().pipe(
231+
Effect.provide(NodeContext.layer),
232+
Effect.catchAll(() => Effect.void)
233+
))
200234
}
201235

202236
const recordAgentActivityBestEffort = (projectId: string): void => {
@@ -321,50 +355,61 @@ const killAgentScript = (sessionId: string): string => {
321355
].join("\n")
322356
}
323357

324-
const hydrateFromSnapshot = async (): Promise<void> => {
325-
const filePath = stateFilePath()
326-
const exists = await fs.stat(filePath).then(() => true).catch(() => false)
327-
if (!exists) {
328-
return
329-
}
330-
331-
const raw = await fs.readFile(filePath, "utf8")
332-
const parsed = JSON.parse(raw) as SnapshotFile
333-
for (const session of parsed.sessions ?? []) {
334-
const restored: AgentSession = {
335-
...session,
336-
status: endedStatuses.has(session.status) ? session.status : "exited",
337-
hostPid: null,
338-
stoppedAt: session.stoppedAt ?? nowIso(),
339-
updatedAt: nowIso()
358+
const readSnapshotFile = (): Effect.Effect<SnapshotFile, never, FileSystem.FileSystem> =>
359+
Effect.gen(function*(_) {
360+
const fs = yield* _(FileSystem.FileSystem)
361+
const filePath = stateFilePath()
362+
const exists = yield* _(Effect.either(fs.exists(filePath)))
363+
const fileExists = Either.match(exists, {
364+
onLeft: () => false,
365+
onRight: (value) => value
366+
})
367+
if (!fileExists) {
368+
return emptySnapshotFile()
340369
}
370+
const contents = yield* _(Effect.either(fs.readFileString(filePath)))
371+
return Either.match(contents, {
372+
onLeft: () => emptySnapshotFile(),
373+
onRight: (value) => decodeSnapshotFile(value) ?? emptySnapshotFile()
374+
})
375+
}).pipe(Effect.catchAll(() => Effect.succeed(emptySnapshotFile())))
341376

342-
const record: AgentRecord = {
343-
session: restored,
344-
projectDir: "",
345-
logs: [],
346-
process: null,
347-
stdoutRemainder: "",
348-
stderrRemainder: ""
349-
}
377+
const hydrateFromSnapshot = (): Effect.Effect<void, never, FileSystem.FileSystem> =>
378+
Effect.gen(function*(_) {
379+
const parsed = yield* _(readSnapshotFile())
380+
for (const session of parsed.sessions) {
381+
const restored: AgentSession = {
382+
...session,
383+
status: endedStatuses.has(session.status) ? session.status : "exited",
384+
hostPid: null,
385+
stoppedAt: session.stoppedAt ?? nowIso(),
386+
updatedAt: nowIso()
387+
}
350388

351-
records.set(restored.id, record)
352-
upsertProjectIndex(restored.projectId, restored.id)
353-
}
354-
}
389+
const record: AgentRecord = {
390+
session: restored,
391+
projectDir: "",
392+
logs: [],
393+
process: null,
394+
stdoutRemainder: "",
395+
stderrRemainder: ""
396+
}
397+
398+
records.set(restored.id, record)
399+
upsertProjectIndex(restored.projectId, restored.id)
400+
}
401+
})
355402

356403
export const initializeAgentState = () =>
357-
Effect.tryPromise({
358-
try: async () => {
359-
if (initialized) {
360-
return
361-
}
362-
await hydrateFromSnapshot()
363-
initialized = true
364-
},
365-
catch: (error) => new Error(String(error))
404+
Effect.gen(function*(_) {
405+
if (initialized) {
406+
return
407+
}
408+
yield* _(hydrateFromSnapshot())
409+
initialized = true
366410
}).pipe(
367411
Effect.catchAll(() => Effect.void),
412+
Effect.provide(NodeContext.layer),
368413
Effect.asVoid
369414
)
370415

0 commit comments

Comments
 (0)