Skip to content

Commit 7ebeb23

Browse files
committed
fix(shell): tolerate broken codex symlinks
1 parent 6c67840 commit 7ebeb23

7 files changed

Lines changed: 6020 additions & 143 deletions

.knowledge/.codex/sessions/2026/01/19/rollout-2026-01-19T14-34-41-019bd5d2-343e-7b52-aca8-fd6a507dc9a7.jsonl

Lines changed: 5069 additions & 0 deletions
Large diffs are not rendered by default.

.knowledge/.codex/sessions/2026/01/21/rollout-2026-01-21T15-12-40-019be041-b300-7b02-b4e9-53d940349352.jsonl

Lines changed: 618 additions & 0 deletions
Large diffs are not rendered by default.

.knowledge/.codex/sessions/2026/01/21/rollout-2026-01-21T15-12-40-019be041-b387-73b2-80d6-38b5e3442e38.jsonl

Lines changed: 13 additions & 0 deletions
Large diffs are not rendered by default.

packages/app/src/shell/services/file-system.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { PlatformError as PlatformErrorType } from "@effect/platform/Error"
12
import * as FileSystem from "@effect/platform/FileSystem"
23
import * as Path from "@effect/platform/Path"
34
import { Context, Effect, Layer, pipe } from "effect"
@@ -58,6 +59,28 @@ const toDirectoryEntry = (
5859
kind: entryKindFromInfo(info)
5960
})
6061

62+
const missingEntry = (
63+
path: Path.Path,
64+
entryPath: string
65+
): DirectoryEntry => ({
66+
name: path.basename(entryPath),
67+
path: entryPath,
68+
kind: "other"
69+
})
70+
71+
const isNotFoundError = (error: PlatformErrorType): boolean =>
72+
error._tag === "SystemError" && error.reason === "NotFound"
73+
74+
// CHANGE: tolerate missing directory entries while traversing sources
75+
// WHY: broken symlinks should not abort sync traversal
76+
// QUOTE(TZ): n/a
77+
// REF: user-2026-01-21-broken-symlink
78+
// SOURCE: n/a
79+
// FORMAT THEOREM: forall e: notFound(e) -> ignored(e)
80+
// PURITY: SHELL
81+
// EFFECT: Effect<DirectoryEntry, SyncError, FileSystem>
82+
// INVARIANT: NotFound entries are classified as kind="other"
83+
// COMPLEXITY: O(1)/O(1)
6184
const readEntry = (
6285
fs: FileSystem.FileSystem,
6386
path: Path.Path,
@@ -66,6 +89,7 @@ const readEntry = (
6689
pipe(
6790
fs.stat(entryPath),
6891
Effect.map((info) => toDirectoryEntry(path, entryPath, info)),
92+
Effect.catchIf(isNotFoundError, () => Effect.succeed(missingEntry(path, entryPath))),
6993
Effect.mapError(() => syncError(entryPath, "Cannot read directory entry"))
7094
)
7195

packages/app/tests/shell/cli-pack.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,15 @@ const packCli = Effect.gen(function*(_) {
4545
Command.make("npm", "pack", "--silent", "--pack-destination", packDir),
4646
Command.workingDirectory(appDir)
4747
)
48-
const tarballName = (yield* _(runCommandOutput(pack))).trim()
48+
const packOutput = yield* _(runCommandOutput(pack))
49+
const packLines = packOutput
50+
.split("\n")
51+
.map((line) => line.trim())
52+
.filter((line) => line.length > 0)
53+
const tarballName = packLines.at(-1)
54+
if (tarballName === undefined) {
55+
return yield* _(Effect.fail(new Error("Packed tarball name not found in npm output")))
56+
}
4957
const tarballPath = path.join(packDir, tarballName)
5058
const tarballSpec = `file:${tarballPath}`
5159

Lines changed: 21 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,12 @@
1-
import { NodeContext } from "@effect/platform-node"
2-
import type * as FileSystem from "@effect/platform/FileSystem"
3-
import * as Path from "@effect/platform/Path"
4-
import { describe, expect, it } from "@effect/vitest"
5-
import { Effect, Layer, Option, pipe } from "effect"
1+
import { describe, it } from "@effect/vitest"
2+
import { Effect } from "effect"
63
import fc from "fast-check"
74

8-
import { CryptoService, CryptoServiceLive } from "../../src/shell/services/crypto.js"
9-
import { FileSystemLive } from "../../src/shell/services/file-system.js"
10-
import { RuntimeEnv } from "../../src/shell/services/runtime-env.js"
11-
import { buildSyncProgram } from "../../src/shell/sync/index.js"
12-
import { buildTestPaths, makeTempDir, withFsPath, writeFile } from "../support/fs-helpers.js"
13-
14-
const forEach = Effect.forEach
15-
const some = Option.some
16-
const mapFileEntry = (
17-
sessionDir: string,
18-
fs: FileSystem.FileSystem,
19-
path: Path.Path
20-
) =>
21-
(entry: string) =>
22-
pipe(
23-
fs.stat(path.join(sessionDir, entry)),
24-
Effect.map((info) => info.type === "File" ? some(entry) : Option.none())
25-
)
26-
27-
const testPaths = buildTestPaths(
28-
new URL(import.meta.url),
29-
"context-doc-tests"
30-
)
31-
32-
const withTempDir = Effect.gen(function*(_) {
33-
const { tempBase } = yield* _(testPaths)
34-
return yield* _(makeTempDir(tempBase, "context-doc-"))
35-
})
36-
37-
const makeRuntimeEnvLayer = (cwd: string): Layer.Layer<RuntimeEnv> =>
38-
Layer.succeed(RuntimeEnv, {
39-
argv: Effect.succeed(["node", "main"]),
40-
cwd: Effect.succeed(cwd),
41-
homedir: Effect.succeed(cwd),
42-
envVar: () => Effect.succeed(Option.none())
43-
})
44-
45-
const assertSyncOutput = (
46-
destDir: string,
47-
qwenHash: string,
48-
cwd: string,
49-
expectedMessage: string,
50-
skippedMessage: string
51-
) =>
52-
withFsPath((fs, path) =>
53-
Effect.gen(function*(_) {
54-
const sessionDir = path.join(destDir, "sessions/2025/11")
55-
const entries = yield* _(fs.readDirectory(sessionDir))
56-
const files = yield* _(
57-
forEach(entries, mapFileEntry(sessionDir, fs, path))
58-
)
59-
const copiedFiles = files.flatMap((entry) => Option.isSome(entry) ? [entry.value] : [])
60-
61-
expect(copiedFiles).toEqual(["match.jsonl"])
62-
63-
const content = yield* _(
64-
fs.readFileString(path.join(sessionDir, "match.jsonl"))
65-
)
66-
67-
expect(content).toContain(`"message":"${expectedMessage}"`)
68-
expect(content).not.toContain(`"message":"${skippedMessage}"`)
69-
70-
const qwenCopied = path.join(
71-
cwd,
72-
".knowledge",
73-
".qwen",
74-
qwenHash,
75-
"chats",
76-
"session-1.json"
77-
)
78-
79-
const exists = yield* _(fs.exists(qwenCopied))
80-
expect(exists).toBe(true)
81-
})
82-
)
83-
84-
const runSyncScenario = (
85-
matchMessage: string,
86-
skippedMessage: string
87-
) =>
88-
Effect.scoped(
89-
Effect.gen(function*(_) {
90-
const path = yield* _(Path.Path)
91-
const crypto = yield* _(CryptoService)
92-
const cwd = yield* _(withTempDir)
93-
const codexDir = path.join(cwd, ".codex")
94-
const destDir = path.join(cwd, ".knowledge", ".codex")
95-
const qwenSource = path.join(cwd, ".qwen", "tmp")
96-
const qwenHash = yield* _(crypto.sha256(cwd))
97-
yield* _(
98-
writeFile(
99-
path.join(codexDir, "sessions/2025/11/match.jsonl"),
100-
[
101-
JSON.stringify({ cwd, message: matchMessage }),
102-
JSON.stringify({
103-
payload: { cwd: path.join(cwd, "sub") }
104-
})
105-
].join("\n")
106-
)
107-
)
108-
109-
yield* _(
110-
writeFile(
111-
path.join(codexDir, "sessions/2025/11/ignore.jsonl"),
112-
[
113-
JSON.stringify({
114-
cwd: "/home/user/other",
115-
message: skippedMessage
116-
})
117-
].join("\n")
118-
)
119-
)
120-
121-
yield* _(
122-
writeFile(
123-
path.join(qwenSource, qwenHash, "chats", "session-1.json"),
124-
JSON.stringify({ sessionId: "s1", projectHash: qwenHash })
125-
)
126-
)
127-
128-
yield* _(
129-
Effect.provide(
130-
buildSyncProgram({
131-
cwd,
132-
sourceDir: codexDir,
133-
destinationDir: destDir,
134-
qwenSourceDir: qwenSource
135-
}),
136-
Layer.mergeAll(FileSystemLive, makeRuntimeEnvLayer(cwd))
137-
)
138-
)
139-
140-
yield* _(assertSyncOutput(destDir, qwenHash, cwd, matchMessage, skippedMessage))
141-
})
142-
).pipe(
143-
Effect.provide(Layer.mergeAll(NodeContext.layer, CryptoServiceLive))
144-
)
5+
import {
6+
runSyncScenario,
7+
runSyncScenarioFromHomeCodex,
8+
runSyncScenarioWithBrokenSymlink
9+
} from "../support/sync-knowledge-helpers.js"
14510

14611
describe("sync-knowledge end-to-end", () => {
14712
const safeChar = fc.constantFrom(
@@ -200,4 +65,18 @@ describe("sync-knowledge end-to-end", () => {
20065
),
20166
catch: (error) => error instanceof Error ? error : new Error(String(error))
20267
}))
68+
69+
it.effect("ignores broken Codex symlink entries", () =>
70+
Effect.gen(function*(_) {
71+
const matchMessage = "match"
72+
const skippedMessage = "skip"
73+
yield* _(runSyncScenarioWithBrokenSymlink(matchMessage, skippedMessage))
74+
}))
75+
76+
it.effect("finds Codex entries in homedir when local source is missing", () =>
77+
Effect.gen(function*(_) {
78+
const matchMessage = "home-match"
79+
const skippedMessage = "home-skip"
80+
yield* _(runSyncScenarioFromHomeCodex(matchMessage, skippedMessage))
81+
}))
20382
})

0 commit comments

Comments
 (0)