Skip to content

Commit 24dac93

Browse files
committed
fix(effect): address PR review compliance findings
- Validate clonedOnHostname at API/config boundaries and keep command builders pure by passing host identity explicitly. - Serialize terminal session state mutations per project to prevent stale read/write races. - Harden session-sync GitHub decoders, readiness polling, PR comment logging, and regression/property tests. Proof of fix: - Cause: review found weak schema contracts, unsynchronized durable terminal writes, silent best-effort failures, and incomplete regression tests. - Solution: moved shared Effect lint rules into one module, strengthened decoders and hostname schemas, added project-scoped semaphores around state writers, and added focused unit/property tests. - Verification: api test/typecheck/lint, root typecheck/test/lint, session-sync typecheck/test, lint:effect, and git diff --check passed locally.
1 parent 4a475e2 commit 24dac93

24 files changed

Lines changed: 537 additions & 235 deletions

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

eslint.effect-ts-shared.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const effectMigrationWarnings = [
2+
{
3+
selector: "SwitchStatement",
4+
message: "Effect migration blocker: use Match.exhaustive instead of switch."
5+
},
6+
{
7+
selector: "TryStatement",
8+
message: "Effect migration blocker: use Effect.try / Effect.catch* instead of try/catch."
9+
},
10+
{
11+
selector: "AwaitExpression",
12+
message: "Effect migration blocker: use Effect.gen / Effect.flatMap instead of await."
13+
},
14+
{
15+
selector: "FunctionDeclaration[async=true], FunctionExpression[async=true], ArrowFunctionExpression[async=true]",
16+
message: "Effect migration blocker: use Effect.gen / Effect.tryPromise instead of async functions."
17+
},
18+
{
19+
selector: "NewExpression[callee.name='Promise']",
20+
message: "Effect migration blocker: use Effect.async / Effect.tryPromise instead of new Promise."
21+
},
22+
{
23+
selector: "CallExpression[callee.object.name='Promise']",
24+
message: "Effect migration blocker: use Effect combinators instead of Promise.*."
25+
}
26+
]
27+
28+
export const effectPromiseRestrictedTypes = {
29+
types: {
30+
Promise: {
31+
message: "Effect migration blocker: avoid Promise in public types. Use Effect.Effect<A, E, R>."
32+
},
33+
"Promise<*>": {
34+
message: "Effect migration blocker: avoid Promise<T>. Use Effect.Effect<T, E, R>."
35+
}
36+
}
37+
}

packages/api/eslint.effect-ts-check.config.mjs

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,7 @@ import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"
1212
import globals from "globals"
1313
import tseslint from "typescript-eslint"
1414

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-
]
15+
import { effectMigrationWarnings, effectPromiseRestrictedTypes } from "../../eslint.effect-ts-shared.mjs"
4116

4217
export default tseslint.config({
4318
name: "api-effect-ts-compliance",
@@ -58,21 +33,12 @@ export default tseslint.config({
5833
"ts-nocheck": true
5934
}],
6035
"@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-
}],
36+
"@typescript-eslint/no-restricted-types": ["warn", effectPromiseRestrictedTypes],
7137
"eslint-comments/disable-enable-pair": "error",
7238
"eslint-comments/no-unlimited-disable": "error",
7339
"eslint-comments/no-unused-disable": "error",
7440
"eslint-comments/no-use": "error",
7541
"no-console": "error",
76-
"no-restricted-syntax": ["warn", ...migrationBlockers]
42+
"no-restricted-syntax": ["warn", ...effectMigrationWarnings]
7743
}
7844
})

packages/api/src/api/contracts.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type ProjectSummary = {
2626
readonly sshSessions: number
2727
readonly startedAtIso: string | null
2828
readonly startedAtEpochMs: number | null
29-
readonly clonedOnHostname?: string | undefined
29+
readonly clonedOnHostname?: string
3030
}
3131

3232
export type ProjectDetails = ProjectSummary & {
@@ -480,7 +480,20 @@ export type CreateProjectRequest = {
480480
readonly forceEnv?: boolean | undefined
481481
readonly waitForClone?: boolean | undefined
482482
readonly async?: boolean | undefined
483-
readonly clonedOnHostname?: string | undefined
483+
/**
484+
* Hostname of the machine where the project was cloned.
485+
*
486+
* CHANGE: add explicit clone-origin hostname to the create-project contract.
487+
* WHY: keeps command builders pure by passing host identity as immutable input instead of reading OS state.
488+
* QUOTE(ТЗ): "CORE: Исключительно чистые функции, неизменяемые данные, математические операции"
489+
* REF: pr-420-coderabbit-review-4518791377
490+
* SOURCE: n/a
491+
* FORMAT THEOREM: ∀h ∈ Hostname: request(h) -> build(request).config.clonedOnHostname = h
492+
* PURITY: CORE - immutable request data.
493+
* INVARIANT: if present, value satisfies API HostnameSchema.
494+
* COMPLEXITY: O(1)/O(1)
495+
*/
496+
readonly clonedOnHostname?: string
484497
}
485498

486499
export type AgentEnvVar = {

packages/api/src/api/schema.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export {
1212
const OptionalString = Schema.optional(Schema.String)
1313
const OptionalBoolean = Schema.optional(Schema.Boolean)
1414
const OptionalNullableString = Schema.optional(Schema.NullOr(Schema.String))
15+
const HostnameSchema = Schema.String.pipe(
16+
Schema.minLength(1),
17+
Schema.maxLength(253),
18+
Schema.pattern(/^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/u)
19+
)
1520

1621
export const CreateProjectRequestSchema = Schema.Struct({
1722
repoUrl: OptionalString,
@@ -52,7 +57,7 @@ export const CreateProjectRequestSchema = Schema.Struct({
5257
forceEnv: OptionalBoolean,
5358
waitForClone: OptionalBoolean,
5459
async: OptionalBoolean,
55-
clonedOnHostname: OptionalString
60+
clonedOnHostname: Schema.optional(HostnameSchema)
5661
})
5762

5863
export const GithubAuthLoginRequestSchema = Schema.Struct({

packages/api/src/http.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1426,7 +1426,11 @@ export const makeRouter = () => {
14261426
"/projects",
14271427
Effect.gen(function*(_) {
14281428
const request = yield* _(readCreateProjectRequest())
1429-
const result = yield* _(createProjectFromRequest(request))
1429+
const { clonedOnHostname, ...requestWithoutCloneHost } = request
1430+
const result = yield* _(createProjectFromRequest({
1431+
...requestWithoutCloneHost,
1432+
...(clonedOnHostname === undefined ? {} : { clonedOnHostname })
1433+
}))
14301434
return yield* _(
14311435
"accepted" in result && result.accepted === true
14321436
? jsonResponse(result, 202)

packages/api/src/services/agents.ts

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type SnapshotFile = {
3535
readonly sessions: ReadonlyArray<AgentSession>
3636
}
3737

38-
const SnapshotFileSchema = Schema.Struct({
38+
const SnapshotFileSchema: Schema.Schema<SnapshotFile> = Schema.Struct({
3939
sessions: Schema.Array(AgentSessionSchema)
4040
})
4141

@@ -46,16 +46,43 @@ const projectIndex: Map<string, Set<string>> = new Map()
4646
const maxLogLines = 5000
4747
let initialized = false
4848

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")
49+
const waitForProcessExit = (child: ChildProcess): Effect.Effect<void> =>
50+
Effect.async((resume) => {
51+
const finish = (): void => {
52+
clearTimeout(timeout)
53+
child.off("exit", finish)
54+
child.off("close", finish)
55+
resume(Effect.void)
5356
}
54-
}
55-
records.clear()
56-
projectIndex.clear()
57-
initialized = false
58-
}
57+
const timeout = setTimeout(finish, 1_000)
58+
if (child.exitCode !== null || child.signalCode !== null) {
59+
finish()
60+
return
61+
}
62+
child.once("exit", finish)
63+
child.once("close", finish)
64+
})
65+
66+
export const clearAgentRuntimeForTest = (): Effect.Effect<void> =>
67+
Effect.gen(function*(_) {
68+
const children = [...records.values()]
69+
.map((record) => record.process)
70+
.filter((process): process is ChildProcess => process !== null)
71+
72+
for (const child of children) {
73+
if (!child.killed) {
74+
child.kill("SIGTERM")
75+
}
76+
}
77+
78+
yield* _(Effect.forEach(children, waitForProcessExit, {
79+
concurrency: "unbounded",
80+
discard: true
81+
}))
82+
records.clear()
83+
projectIndex.clear()
84+
initialized = false
85+
})
5986

6087
const nowIso = (): string => new Date().toISOString()
6188

@@ -229,7 +256,9 @@ const persistSnapshot = (): Effect.Effect<void, PlatformError, FileSystem.FileSy
229256
const persistSnapshotBestEffort = (): void => {
230257
Effect.runFork(persistSnapshot().pipe(
231258
Effect.provide(NodeContext.layer),
232-
Effect.catchAll(() => Effect.void)
259+
Effect.catchAll((error) =>
260+
Effect.logWarning(`[agents] Failed to persist snapshot: ${String(error)}`)
261+
)
233262
))
234263
}
235264

@@ -359,19 +388,16 @@ const readSnapshotFile = (): Effect.Effect<SnapshotFile, never, FileSystem.FileS
359388
Effect.gen(function*(_) {
360389
const fs = yield* _(FileSystem.FileSystem)
361390
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-
})
391+
const fileExists = yield* _(fs.exists(filePath).pipe(
392+
Effect.orElse(() => Effect.succeed(false))
393+
))
367394
if (!fileExists) {
368395
return emptySnapshotFile()
369396
}
370-
const contents = yield* _(Effect.either(fs.readFileString(filePath)))
371-
return Either.match(contents, {
372-
onLeft: () => emptySnapshotFile(),
373-
onRight: (value) => decodeSnapshotFile(value) ?? emptySnapshotFile()
374-
})
397+
const contents = yield* _(fs.readFileString(filePath).pipe(
398+
Effect.orElse(() => Effect.succeed(""))
399+
))
400+
return decodeSnapshotFile(contents) ?? emptySnapshotFile()
375401
}).pipe(Effect.catchAll(() => Effect.succeed(emptySnapshotFile())))
376402

377403
const hydrateFromSnapshot = (): Effect.Effect<void, never, FileSystem.FileSystem> =>
@@ -386,6 +412,8 @@ const hydrateFromSnapshot = (): Effect.Effect<void, never, FileSystem.FileSystem
386412
updatedAt: nowIso()
387413
}
388414

415+
// Hydrated sessions are restart metadata only; projectDir is available
416+
// only when new operations receive a live ProjectDetails argument.
389417
const record: AgentRecord = {
390418
session: restored,
391419
projectDir: "",

packages/api/src/services/projects.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ const withProjectRuntime = (
160160
sshSessions: runtime.sshSessions,
161161
startedAtIso: runtime.startedAtIso,
162162
startedAtEpochMs: runtime.startedAtEpochMs,
163-
clonedOnHostname: project.clonedOnHostname
163+
...(project.clonedOnHostname === undefined ? {} : { clonedOnHostname: project.clonedOnHostname })
164164
}))
165165
)
166166

@@ -191,7 +191,7 @@ const dbProjectSummary = (
191191
sshSessions: 0,
192192
startedAtIso: project.lastStartedAtIso,
193193
startedAtEpochMs: project.lastStartedAtEpochMs,
194-
clonedOnHostname: project.clonedOnHostname
194+
...(project.clonedOnHostname === undefined ? {} : { clonedOnHostname: project.clonedOnHostname })
195195
})
196196

197197
const toProjectDetails = (

0 commit comments

Comments
 (0)