Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0fc9542
refactor(code-link): SyncKernel, scheduler, SyncBase, PluginBase, Eff…
huntercaron Apr 17, 2026
ae16447
Clearer mode messages
huntercaron Apr 17, 2026
9bc4bb2
fix(code-link): harden reconnect prompt state
huntercaron Apr 17, 2026
e0e4a75
feat(code-link): sync-phase protocol, internalPhase, pluginMode
huntercaron Apr 17, 2026
524e5f9
refactor(code-link): SyncKernel, EffectResult pipeline, UiState, prom…
huntercaron Apr 17, 2026
d515aca
refactor(code-link): declarative describeEffect pipeline; rename kern…
huntercaron Apr 17, 2026
22c1487
Remove tracked Cursor plans and ignore future ones
huntercaron Apr 24, 2026
5bae2a9
Flatten effect pipeline and prompt handling
huntercaron Apr 24, 2026
a7f8bcf
Fix ESLint and Biome issues
huntercaron Apr 24, 2026
27d51ce
Update Vitest to fix Code Link tests
huntercaron Apr 24, 2026
6600353
Inline prompt session creation and clean certs test mocks
huntercaron Apr 24, 2026
ecc0316
Simplify code-link section comments
huntercaron Apr 24, 2026
46ccd95
Flatten sync event handling and remove dead helpers
huntercaron Apr 24, 2026
b9add6e
Acknowledge missing delete paths in Framer
huntercaron Apr 24, 2026
615147a
Fix delete prompt test mocks
huntercaron Apr 24, 2026
f76fefd
Rename delete tombstones to expected delete echoes
huntercaron Apr 24, 2026
e9b67f8
Use exact content for plugin echo checks
huntercaron Apr 24, 2026
e7c6167
Refine code link sync memory and conflict handling
huntercaron Apr 24, 2026
ad1c59a
Defer sync completion until delete prompts resolve
huntercaron Apr 24, 2026
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
.vscode/*.log
*.local
dist
*.tsbuildinfo
node_modules
npm-debug.log*
plugin.zip
.env
.cursor/plans/

# Yarn v2 with Zero Installs (https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored)
.yarn/*
Expand All @@ -22,4 +24,4 @@ plugin.zip
# Code Link CLI — ignore folders (Untitled, etc.) for testing
packages/code-link-cli/*/
!packages/code-link-cli/src/
!packages/code-link-cli/skills/
!packages/code-link-cli/skills/
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed .yarn/cache/sirv-npm-3.0.1-6bef01ff05-b110ebe28e.zip
Binary file not shown.
Binary file removed .yarn/cache/sirv-npm-3.0.2-6cf658c733-259617f4ab.zip
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@
"typescript": "^5.9.2",
"valibot": "^1.2.0",
"vite": "^8.0.1",
"vitest": "^4.1.0"
"vitest": "^4.1.5"
}
}
2 changes: 1 addition & 1 deletion packages/code-link-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@
"@types/ws": "^8.18.1",
"tsdown": "^0.20.1",
"tsx": "^4.21.0",
"vitest": "^4.0.15"
"vitest": "^4.1.5"
}
}
243 changes: 243 additions & 0 deletions packages/code-link-cli/src/controller.apply.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import type { PromptSession } from "@code-link/shared"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { describe, expect, it } from "vitest"
import type { WebSocket } from "ws"
import { type ApplyCtx, applyEffect } from "./controller.ts"
import { SyncRuntime } from "./runtime.ts"
import type { SyncState } from "./sync-events.ts"
import type { Config } from "./types.ts"

function config(overrides: Partial<Config> = {}): Config {
return {
port: 0,
projectHash: "project",
projectDir: null,
filesDir: null,
dangerouslyAutoDelete: false,
allowUnsupportedNpm: false,
...overrides,
}
}

function socket({ open = true }: { open?: boolean } = {}) {
const sent: unknown[] = []
return {
sent,
ws: {
readyState: open ? 1 : 3,
send(payload: string, cb?: (err?: Error | null) => void) {
sent.push(JSON.parse(payload) as unknown)
cb?.(null)
},
} as WebSocket,
}
}

function applyCtx(runtime: SyncRuntime, ws: WebSocket | null, overrides: Partial<Config> = {}): ApplyCtx {
const syncState: SyncState =
ws === null ? { internalPhase: "disconnected", socket: null } : { internalPhase: "watching", socket: ws }
return {
config: config(overrides),
runtime,
syncState,
shutdown: async () => {},
}
}

describe("applyEffect transaction boundaries", () => {
it("does not record a local send when the socket send fails", async () => {
const runtime = new SyncRuntime()
const closed = socket({ open: false })

await applyEffect({ type: "SEND_LOCAL_CHANGE", fileName: "A.tsx", content: "x" }, applyCtx(runtime, closed.ws))

expect(runtime.memory.matchesContentEcho("A.tsx", "x")).toBe(false)
})

it("does not register a pending rename when the rename send fails", async () => {
const runtime = new SyncRuntime()
const closed = socket({ open: false })

await applyEffect(
{
type: "SEND_FILE_RENAME",
oldFileName: "Old.tsx",
newFileName: "New.tsx",
content: "x",
},
applyCtx(runtime, closed.ws)
)

expect(runtime.getPendingRename("New.tsx")).toBeUndefined()
})

it("rolls back write echo and skips metadata when a remote disk write fails", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-write-fail-"))
try {
const filesDir = path.join(tmpDir, "files")
await fs.mkdir(filesDir, { recursive: true })
await fs.writeFile(path.join(filesDir, "Blocked.tsx"), "not a directory", "utf-8")

const runtime = new SyncRuntime()
runtime.configureWorkspace(tmpDir, false)

await applyEffect(
{
type: "WRITE_FILES",
files: [{ name: "Blocked.tsx/Nested.tsx", content: "x", modifiedAt: 1 }],
echoPolicy: "authoritative",
},
applyCtx(runtime, null)
)

expect(runtime.metadata.get("Blocked.tsx/Nested.tsx")).toBeUndefined()
expect(runtime.memory.matchesContentEcho("Blocked.tsx/Nested.tsx", "x")).toBe(false)
} finally {
await fs.rm(tmpDir, { recursive: true, force: true })
}
})

it("rolls back expected delete echoes and keeps metadata when a local delete fails", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-delete-fail-"))
try {
const filesDir = path.join(tmpDir, "files")
await fs.mkdir(path.join(filesDir, "Folder.tsx"), { recursive: true })

const runtime = new SyncRuntime()
runtime.configureWorkspace(tmpDir, false)
runtime.memory.recordSyncedContent("Folder.tsx", "x", 1)

await applyEffect({ type: "DELETE_LOCAL_FILES", names: ["Folder.tsx"] }, applyCtx(runtime, null))

expect(runtime.metadata.get("Folder.tsx")).toBeDefined()
expect(runtime.memory.matchesExpectedDeleteEcho("Folder.tsx")).toBe(false)
} finally {
await fs.rm(tmpDir, { recursive: true, force: true })
}
})

it("starts delete prompts without awaiting a user decision", async () => {
const runtime = new SyncRuntime()
runtime.mintConnectionId()
const open = socket()

await applyEffect({ type: "LOCAL_INITIATED_FILE_DELETE", fileNames: ["A.tsx"] }, applyCtx(runtime, open.ws))

expect(open.sent).toEqual([
expect.objectContaining({
type: "file-delete",
fileNames: ["A.tsx"],
requireConfirmation: true,
session: expect.objectContaining({ connectionId: 1 }),
}),
])
})

it("auto-delete sends exactly one remote delete and records only after send success", async () => {
const runtime = new SyncRuntime()
runtime.memory.recordSyncedContent("A.tsx", "old", 1)
const open = socket()

await applyEffect(
{ type: "LOCAL_INITIATED_FILE_DELETE", fileNames: ["A.tsx"] },
applyCtx(runtime, open.ws, { dangerouslyAutoDelete: true })
)

expect(open.sent).toEqual([{ type: "file-delete", fileNames: ["A.tsx"] }])
expect(runtime.metadata.get("A.tsx")).toBeUndefined()
})

it("refreshes an active conflict prompt when local conflict content changes", async () => {
const runtime = new SyncRuntime()
runtime.mintConnectionId()
const open = socket()
const prompt = runtime.startOrUpdateConflictPrompt([
{ fileName: "A.tsx", localContent: "old", remoteContent: "remote" },
])
if (!prompt) throw new Error("Expected conflict prompt")

await applyEffect(
{ type: "UPDATE_ACTIVE_CONFLICT_LOCAL", fileName: "A.tsx", content: "new" },
applyCtx(runtime, open.ws)
)

expect(open.sent).toEqual([
{
type: "conflicts-detected",
session: prompt.session,
conflicts: [{ fileName: "A.tsx", localContent: "new", remoteContent: "remote" }],
},
])
})

it("clears an active conflict prompt and records metadata when the final conflict converges", async () => {
const runtime = new SyncRuntime()
runtime.mintConnectionId()
const open = socket()
const prompt = runtime.startOrUpdateConflictPrompt([
{ fileName: "A.tsx", localContent: "local", remoteContent: "remote" },
])
if (!prompt) throw new Error("Expected conflict prompt")

await applyEffect(
{ type: "UPDATE_ACTIVE_CONFLICT_REMOTE", fileName: "A.tsx", content: "local", modifiedAt: 123 },
applyCtx(runtime, open.ws)
)

expect(open.sent).toEqual([{ type: "conflicts-cleared", session: prompt.session }])
expect(runtime.getActiveConflictPrompt()).toBeNull()
expect(runtime.metadata.get("A.tsx")?.lastRemoteTimestamp).toBe(123)
})

it("sends delete prompt path invalidations without clearing unrelated pending deletes", async () => {
const runtime = new SyncRuntime()
runtime.mintConnectionId()
const open = socket()
const prompt = runtime.startDeletePrompt(["A.tsx", "B.tsx"])
if (!prompt) throw new Error("Expected delete prompt")

await applyEffect({ type: "INVALIDATE_DELETE_PROMPT_PATH", fileName: "A.tsx" }, applyCtx(runtime, open.ws))

expect(open.sent).toEqual([{ type: "delete-prompt-cleared", session: prompt.session, fileNames: ["A.tsx"] }])
expect(runtime.getDeletePromptFileNames(prompt.session, ["A.tsx", "B.tsx"])).toEqual(["B.tsx"])
})
})

describe("prompt response apply boundaries", () => {
it("ignores stale delete responses without sending deletes", async () => {
const runtime = new SyncRuntime()
const stale: PromptSession = { connectionId: 99, promptId: "stale" }
const open = socket()

await applyEffect(
{ type: "RESOLVE_DELETE_PROMPT", session: stale, confirmedFileNames: ["A.tsx"], cancelledFiles: [] },
applyCtx(runtime, open.ws)
)

expect(open.sent).toEqual([])
})

it("ignores delete prompt responses for paths that were invalidated", async () => {
const runtime = new SyncRuntime()
runtime.mintConnectionId()
const prompt = runtime.startDeletePrompt(["A.tsx", "B.tsx"])
if (!prompt) throw new Error("Expected prompt")
runtime.invalidateDeletePromptPath("A.tsx")
const open = socket()

await applyEffect(
{
type: "RESOLVE_DELETE_PROMPT",
session: prompt.session,
confirmedFileNames: ["A.tsx"],
cancelledFiles: [],
},
applyCtx(runtime, open.ws)
)

expect(open.sent).toEqual([])
expect(runtime.getDeletePromptFileNames(prompt.session, ["A.tsx", "B.tsx"])).toEqual(["B.tsx"])
})
})
Loading
Loading