Skip to content

Add relayfile mirror mount backend#242

Closed
kjgbot wants to merge 2 commits into
mainfrom
factory-sdk/w9-local-mirror
Closed

Add relayfile mirror mount backend#242
kjgbot wants to merge 2 commits into
mainfrom
factory-sdk/w9-local-mirror

Conversation

@kjgbot

@kjgbot kjgbot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add LocalMirrorMountClient for relayfile mirror filesystem reads, writes, listing, subscriptions, event fallback, and writeback ACK confirmation.
  • Add createMount plus config.mount.backend selection for relayfile-cloud and relayfile-mirror; createFactory can construct the mirror backend when the mount port is omitted.
  • Tighten MountLinearWriteback.verify so a local read-back match is not considered done unless the mount reports ACKED.

Tests

  • npx vitest run packages/factory-sdk
  • npx tsc --noEmit -p tsconfig.node.json

Notes

  • No src/main edits.
  • Mirror writes fail closed before touching the mirror if .relay/state.json / mount.pid are missing, stale/offline, misconfigured, or the mount PID is not alive.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a local mirror mount client (LocalMirrorMountClient) to support the relayfile-mirror backend, alongside configuration schema updates, factory orchestrator integration, and writeback verification enhancements. The review feedback highlights several key improvement opportunities, including optimizing directory subscription polling on platforms that support recursive watching, normalizing lookup keys to prevent mismatches, wrapping JSON parsing in try-catch blocks to avoid raw crashes on malformed metadata, and optimizing outbox matching performance. Additionally, it is recommended to reduce the hardcoded 90-second timeout in verifyAcked to prevent blocking during verification queries.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +175 to +194
try {
watcher = watch(this.mirrorDir, { recursive: true })
startPolling()
void (async () => {
try {
for await (const event of watcher) {
if (!active) break
if (event.eventType !== 'change' && event.eventType !== 'rename') continue
if (!event.filename) continue
const relativePath = event.filename.toString()
if (!relativePath || relativePath.split(/[\\/]/u).at(0) === '.relay') continue
emitPath(toRemotePath(this.mirrorDir, resolve(this.mirrorDir, relativePath)))
}
} catch {
if (active) startPolling()
}
})()
} catch {
startPolling()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

On macOS and Windows, fs.promises.watch fully supports recursive directory watching. Starting the heavy polling loop (startPolling()) concurrently with watch is redundant and leads to unnecessary CPU and disk I/O overhead (since polling recursively walks the entire directory tree and stats every file every second). Polling should only be used as a fallback on platforms where recursive watching is unsupported (like Linux) or if the watcher fails to initialize.

    try {
      watcher = watch(this.mirrorDir, { recursive: true })
      if (process.platform !== 'darwin' && process.platform !== 'win32') {
        startPolling()
      }
      void (async () => {
        try {
          for await (const event of watcher) {
            if (!active) break
            if (event.eventType !== 'change' && event.eventType !== 'rename') continue
            if (!event.filename) continue
            const relativePath = event.filename.toString()
            if (!relativePath || relativePath.split(/[\\/]/u).at(0) === '.relay') continue
            emitPath(toRemotePath(this.mirrorDir, resolve(this.mirrorDir, relativePath)))
          }
        } catch {
          if (active) startPolling()
        }
      })()
    } catch {
      startPolling()
    }

Comment on lines +89 to +95
this.#lastWriteByPath.set(path, {
path: normalizeRemotePath(path),
localPath,
contentHash: hashString(serialized),
startedAtMs: Date.now(),
ackedBaseline: outboxCount(metadata.state, 'acked'),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure consistency and avoid potential lookup mismatches due to leading slashes or formatting, the key used in this.#lastWriteByPath should be normalized using normalizeRemotePath(path) rather than using the raw path directly.

    const normalizedPath = normalizeRemotePath(path)
    this.#lastWriteByPath.set(normalizedPath, {
      path: normalizedPath,
      localPath,
      contentHash: hashString(serialized),
      startedAtMs: Date.now(),
      ackedBaseline: outboxCount(metadata.state, 'acked'),
    })

Comment on lines +242 to +244
const state = asRecord(JSON.parse(stateRaw)) ?? {}
const pidRecord = parsePidFile(pidRaw)
const pid = pidRecord.pid

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If state.json or mount.pid is concurrently modified, corrupted, or empty, JSON.parse will throw a raw SyntaxError which crashes the operation without a clear context. Wrapping the parsing in a try/catch block ensures we can provide a descriptive error message indicating that the writeback is not ready due to a malformed metadata file.

    let state: Record<string, unknown>
    try {
      state = asRecord(JSON.parse(stateRaw)) ?? {}
    } catch (error) {
      throw new Error(`Relayfile mirror writeback is not ready: failed to parse ${statePath}: ${errorMessage(error)}`)
    }
    const pidRecord = parsePidFile(pidRaw)

Comment on lines +343 to +347
const parsePidFile = (raw: string): Record<string, unknown> & { pid: number } => {
const trimmed = raw.trim()
const parsed = trimmed.startsWith('{') ? asRecord(JSON.parse(trimmed)) : { pid: Number(trimmed) }
return { ...(parsed ?? {}), pid: Number(parsed?.pid) }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the mount.pid file contains malformed JSON, JSON.parse will throw a raw SyntaxError and crash the application. Wrapping the parsing logic in a try/catch block and returning a fallback object with pid: 0 allows the caller to handle it gracefully and throw a clear, descriptive error.

const parsePidFile = (raw: string): Record<string, unknown> & { pid: number } => {
  const trimmed = raw.trim()
  try {
    const parsed = trimmed.startsWith('{') ? asRecord(JSON.parse(trimmed)) : { pid: Number(trimmed) }
    return { ...(parsed ?? {}), pid: Number(parsed?.pid) }
  } catch {
    return { pid: 0 }
  }
}

Comment on lines +428 to +431
for (const filePath of files) {
const raw = await readFile(filePath, 'utf8').catch(() => '')
if (outboxRecordMatches(raw, path, marker)) return true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since confirmWrite polls outboxHasMatch repeatedly in a tight loop, reading and parsing every single file in the outbox can become a major I/O bottleneck as the outbox grows. Since we only care about writes initiated during the current operation, we can optimize this by checking the file's modification time (mtimeMs) and skipping any files that are older than marker.startedAtMs.

Suggested change
for (const filePath of files) {
const raw = await readFile(filePath, 'utf8').catch(() => '')
if (outboxRecordMatches(raw, path, marker)) return true
}
for (const filePath of files) {
if (marker) {
const fileStat = await stat(filePath).catch(() => null)
if (fileStat && fileStat.mtimeMs < marker.startedAtMs) {
continue
}
}
const raw = await readFile(filePath, 'utf8').catch(() => '')
if (outboxRecordMatches(raw, path, marker)) return true
}

Comment on lines +128 to +129
const verifyAcked = async (mount: MountClient, path: string): Promise<boolean> =>
await mount.confirmWrite(path, { timeoutMs: 90_000 }) === 'acked'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding a 90-second timeout in verifyAcked means any call to verify on an unconfirmed or failed write will block the entire execution flow for up to 90 seconds. Since confirmWriteback already waits for the write to be acked before calling verify, this second wait is completely redundant. We should allow verifyAcked to accept an optional timeout that defaults to 0 (immediate check) to prevent blocking during verification queries.

Suggested change
const verifyAcked = async (mount: MountClient, path: string): Promise<boolean> =>
await mount.confirmWrite(path, { timeoutMs: 90_000 }) === 'acked'
const verifyAcked = async (mount: MountClient, path: string, timeoutMs = 0): Promise<boolean> =>
await mount.confirmWrite(path, { timeoutMs }) === 'acked'

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@agent-relay-code[bot], we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 2 minutes and 35 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e8452933-0eb0-4034-9ff9-fabf1c884cf4

📥 Commits

Reviewing files that changed from the base of the PR and between 2b2b7a5 and 860f1dd.

📒 Files selected for processing (4)
  • packages/factory-sdk/src/cli/fleet.test.ts
  • packages/factory-sdk/src/cli/fleet.ts
  • packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts
  • packages/factory-sdk/src/mount/local-mirror-mount-client.ts
📝 Walkthrough

Walkthrough

This PR introduces a filesystem-backed local mirror mount client for the Factory SDK, allowing configuration-driven selection between a cloud-based and local mirror backend. It includes the new client implementation with read/write/subscribe operations, comprehensive tests, automatic mount construction in the orchestrator, and enhanced write verification using ack confirmation.

Changes

Local Mirror Mount Backend

Layer / File(s) Summary
Mount configuration schema
packages/factory-sdk/src/config/schema.ts, packages/factory-sdk/src/config/schema.test.ts
FactoryConfigSchema adds a mount object with backend enum ('relayfile-cloud' | 'relayfile-mirror'), optional workspaceId and mirrorDir, and defaults; tests verify both default cloud behavior and explicit mirror backend parsing.
Mount factory and type definitions
packages/factory-sdk/src/mount/create-mount.ts, packages/factory-sdk/src/index.ts
createMount factory uses a discriminated union to instantiate either LocalMirrorMountClient or RelayfileCloudMountClient based on config.backend; new exports surface MountBackend, CreateMountConfig, the factory, and client types.
LocalMirrorMountClient implementation
packages/factory-sdk/src/mount/local-mirror-mount-client.ts
Implements MountClient interface for filesystem-based mirroring: reads/writes JSON with mirror envelope serialization, lists directory trees, provides debounced file-change subscriptions with polling fallback, validates daemon readiness, tracks write lifecycle via outbox records, and includes path normalization, payload parsing, process detection, and record matching helpers.
LocalMirrorMountClient test suite
packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts
Comprehensive tests verify payload read/write handling, readiness enforcement, tree listing, debounced subscriptions, and write confirmation through outbox state transitions (acked, pending, timeout).
Factory orchestrator integration
packages/factory-sdk/src/types.ts, packages/factory-sdk/src/orchestrator/factory.ts
FactoryPorts.mount becomes optional; FactoryLoop auto-constructs a mount via createMount when not provided, so downstream writeback and github components use the constructed instance.
Write verification with ack confirmation
packages/factory-sdk/src/writeback/linear.ts, packages/factory-sdk/src/writeback/writeback.test.ts
MountLinearWriteback.verify checks write acknowledgment via confirmWrite before validating stored content; non-acked and queued writes are treated as unverified even if local read-back matches.

Sequence Diagram

sequenceDiagram
  participant FactoryConfig
  participant FactoryLoop
  participant createMount
  participant LocalMirrorMountClient
  participant Filesystem
  participant MountLinearWriteback
  FactoryConfig->>FactoryLoop: config with mount backend
  FactoryLoop->>createMount: config.mount (backend, mirrorDir)
  alt backend === 'relayfile-mirror'
    createMount->>LocalMirrorMountClient: new LocalMirrorMountClient(config)
    LocalMirrorMountClient-->>createMount: instance
  else default to cloud
    createMount-->>FactoryLoop: RelayfileCloudMountClient
  end
  createMount-->>FactoryLoop: mount instance
  FactoryLoop->>MountLinearWriteback: initialize with mount
  MountLinearWriteback->>LocalMirrorMountClient: writeFile(path, content)
  LocalMirrorMountClient->>Filesystem: persist mirror payload
  LocalMirrorMountClient-->>MountLinearWriteback: write recorded
  MountLinearWriteback->>LocalMirrorMountClient: confirmWrite(path, timeout)
  LocalMirrorMountClient->>Filesystem: poll outbox status
  alt outbox acked
    LocalMirrorMountClient-->>MountLinearWriteback: 'acked'
    MountLinearWriteback->>LocalMirrorMountClient: readFile(path)
    LocalMirrorMountClient-->>MountLinearWriteback: content + revision
    MountLinearWriteback-->>MountLinearWriteback: verify payload
  else pending or timeout
    LocalMirrorMountClient-->>MountLinearWriteback: 'pending' or 'timeout'
    MountLinearWriteback-->>MountLinearWriteback: verify fails
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A mirror to reflect the code,
Filesystem paths now bear the load,
Config selects the backend bright,
Acked writes confirm all's right,
Local echoes dance in the night!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: adding a new relayfile mirror mount backend. It directly reflects the primary objective of the changeset.
Description check ✅ Passed The description clearly relates to the changeset by explaining the three main components added: LocalMirrorMountClient, createMount factory with backend selection, and tightened writeback verification. It provides meaningful context about the changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch factory-sdk/w9-local-mirror

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@agent-relay-code

Copy link
Copy Markdown
Contributor

Fixed the PR issues I found:

Addressed comments

Advisory Notes

  • npm run verify:mcp-spawn could not run successfully here because it requires a packaged dist directory from npm run dist:mac; this Linux sandbox cannot reproduce the macOS packaging job.

Validation run locally:

  • npm ci
  • npm run verify:mcp-resources-drift
  • npm run lint
  • npm run typecheck:web
  • npm run typecheck:node
  • npm test
  • npx vitest run
  • npm run build
  • npm run build:web
  • npx playwright install --with-deps chromium
  • npx playwright test --config playwright.fidelity.config.ts
  • npx playwright test --config playwright.redraw.config.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/factory-sdk/src/config/schema.test.ts (1)

62-80: ⚡ Quick win

Add test coverage for invalid mirror backend config.

The test verifies parsing when mirrorDir is provided, but there's no test for the case where backend: 'relayfile-mirror' is specified without mirrorDir. If the schema is updated to enforce mirrorDir when the backend is 'relayfile-mirror' (as suggested in the schema.ts review), add a corresponding negative test case.

🧪 Proposed test for invalid mirror config
   expect(parsed.mount).toEqual({
     backend: 'relayfile-mirror',
     mirrorDir: '/tmp/factory-rf-mirror',
   })
 })
+
+it('rejects relayfile-mirror backend without mirrorDir', () => {
+  expect(() => FactoryConfigSchema.parse({
+    workspaceId: 'rw_123',
+    mount: {
+      backend: 'relayfile-mirror',
+    },
+    repos: {
+      byLabel: {
+        pear: 'AgentWorkforce/pear',
+      },
+    },
+  })).toThrow(/mirrorDir/)
+})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/config/schema.test.ts` around lines 62 - 80, Add a
negative test that ensures FactoryConfigSchema.parse throws when mount.backend
is 'relayfile-mirror' but mirrorDir is missing: create a test (e.g., it('rejects
relayfile-mirror without mirrorDir', ...)) that calls FactoryConfigSchema.parse
with workspaceId, mount: { backend: 'relayfile-mirror' } and repos, and assert
the parse call throws (use expect(() =>
FactoryConfigSchema.parse(...)).toThrow()). This mirrors the positive test and
verifies the schema validation added to FactoryConfigSchema enforces mirrorDir
for the 'relayfile-mirror' backend.
packages/factory-sdk/src/mount/local-mirror-mount-client.ts (1)

277-287: 💤 Low value

Heuristic-based ack detection has edge cases with concurrent writes.

The fallback at lines 280-282 assumes that if the global acked count increased and there are no pending writes, this specific write was acked. With concurrent writes from multiple processes, this could incorrectly report 'acked' for a write that's still in flight.

This is likely acceptable given the file-based coordination model, but a brief comment explaining the heuristic would help maintainability.

📝 Suggested comment
     const state = await readRelayState(this.mirrorDir)
     if (state) {
       if (outboxCount(state, 'failed') > 0) return 'failed'
+      // Heuristic: if global acked count increased since our write started and
+      // there are no pending writes, assume our write was included in the batch.
+      // This may report 'acked' prematurely with concurrent multi-process writes.
       if (marker && outboxCount(state, 'acked') > marker.ackedBaseline && !hasPendingWriteback(state)) {
         return 'acked'
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/mount/local-mirror-mount-client.ts` around lines 277
- 287, Add a brief explanatory comment above the heuristic fallback in the
method that reads relay state (around the call to
readRelayState(this.mirrorDir)) describing that the ack-detection is
best-effort: it treats an increased global acked count (outboxCount(state,
'acked') > marker.ackedBaseline) and no pending writebacks
(hasPendingWriteback(state) === false) as evidence this specific write was
acked, but with concurrent writers this can be a false positive; note that the
fallback return (return marker ? 'pending' : 'acked') is conservative for
missing state and this heuristic is intentional due to file-based coordination.
Reference readRelayState, outboxCount, hasPendingWriteback and marker in the
comment so future maintainers can find and reason about the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/factory-sdk/src/mount/local-mirror-mount-client.ts`:
- Around line 142-148: filesystemEventToChangeEvent is returning a ChangeEvent
type that doesn't match the ChangeEvent expected by MountClient.subscribe (which
is an alias of RelayFileChangeEvent), so the code uses a double cast `as unknown
as ChangeEvent`; fix this by aligning types: either change
filesystemEventToChangeEvent's return type to the ports' ChangeEvent
(RelayFileChangeEvent) or create/export a single shared ChangeEvent type alias
(sourced from `@relayfile/sdk`) and use it in filesystemEventToChangeEvent and
MountClient.subscribe, then remove the `as unknown as ChangeEvent` cast;
alternatively implement a small adapter that constructs an object explicitly
typed as the ports' ChangeEvent (used where filesystemEventToChangeEvent is
called) to ensure type compatibility without casts.

In `@packages/factory-sdk/src/orchestrator/factory.ts`:
- Line 81: The MountGithubRead invocation in FactoryLoop is a no-op—remove the
dead expression `void (ports.github ?? MountGithubRead(mount))` or instead
assign/save the result so it can be used later; either set `ports.github =
ports.github ?? MountGithubRead(mount)` or store the returned `GithubRead` (the
object with `getPr`) on the FactoryLoop instance so later code can call `getPr`;
update references so the factory actually uses the saved `GithubRead` (or remove
any unused `MountGithubRead` import) to eliminate the dead initialization.

---

Nitpick comments:
In `@packages/factory-sdk/src/config/schema.test.ts`:
- Around line 62-80: Add a negative test that ensures FactoryConfigSchema.parse
throws when mount.backend is 'relayfile-mirror' but mirrorDir is missing: create
a test (e.g., it('rejects relayfile-mirror without mirrorDir', ...)) that calls
FactoryConfigSchema.parse with workspaceId, mount: { backend: 'relayfile-mirror'
} and repos, and assert the parse call throws (use expect(() =>
FactoryConfigSchema.parse(...)).toThrow()). This mirrors the positive test and
verifies the schema validation added to FactoryConfigSchema enforces mirrorDir
for the 'relayfile-mirror' backend.

In `@packages/factory-sdk/src/mount/local-mirror-mount-client.ts`:
- Around line 277-287: Add a brief explanatory comment above the heuristic
fallback in the method that reads relay state (around the call to
readRelayState(this.mirrorDir)) describing that the ack-detection is
best-effort: it treats an increased global acked count (outboxCount(state,
'acked') > marker.ackedBaseline) and no pending writebacks
(hasPendingWriteback(state) === false) as evidence this specific write was
acked, but with concurrent writers this can be a false positive; note that the
fallback return (return marker ? 'pending' : 'acked') is conservative for
missing state and this heuristic is intentional due to file-based coordination.
Reference readRelayState, outboxCount, hasPendingWriteback and marker in the
comment so future maintainers can find and reason about the logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ad9dc7ac-766a-4cd6-a9c4-07667261bc1e

📥 Commits

Reviewing files that changed from the base of the PR and between 94fdcc0 and 2b2b7a5.

📒 Files selected for processing (10)
  • packages/factory-sdk/src/config/schema.test.ts
  • packages/factory-sdk/src/config/schema.ts
  • packages/factory-sdk/src/index.ts
  • packages/factory-sdk/src/mount/create-mount.ts
  • packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts
  • packages/factory-sdk/src/mount/local-mirror-mount-client.ts
  • packages/factory-sdk/src/orchestrator/factory.ts
  • packages/factory-sdk/src/types.ts
  • packages/factory-sdk/src/writeback/linear.ts
  • packages/factory-sdk/src/writeback/writeback.test.ts

Comment on lines +142 to +148
onChange(filesystemEventToChangeEvent(null, this.workspaceId, {
type: 'file.updated',
path: changedPath,
eventId: `mirror:${this.workspaceId}:${changedPath}:${Date.now()}`,
revision: String(Date.now()),
timestamp: new Date().toISOString(),
}) as unknown as ChangeEvent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the return type of filesystemEventToChangeEvent and ChangeEvent interface
echo "=== filesystemEventToChangeEvent function ==="
ast-grep --pattern 'function filesystemEventToChangeEvent($$$) { $$$ }'
ast-grep --pattern 'const filesystemEventToChangeEvent = ($$$) => $$$'
ast-grep --pattern 'export const filesystemEventToChangeEvent = ($$$) => $$$'

echo "=== ChangeEvent type definition ==="
rg -n --type=ts 'type ChangeEvent|interface ChangeEvent' -A 10

Repository: AgentWorkforce/pear

Length of output: 5789


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Locate filesystemEventToChangeEvent ==="
rg -n "filesystemEventToChangeEvent" packages/factory-sdk/src || true

echo "=== Inspect local-mirror-mount-client subscribe region (around 142-148) ==="
sed -n '110,180p' packages/factory-sdk/src/mount/local-mirror-mount-client.ts

echo "=== Inspect ChangeEvent import/type usage in local-mirror-mount-client ==="
rg -n "import type \\{[^}]*ChangeEvent|from '\\.\\./ports'|from '\\./'|ChangeEvent" packages/factory-sdk/src/mount/local-mirror-mount-client.ts

echo "=== Inspect ports ChangeEvent type (context for runtime/typing) ==="
sed -n '1,120p' packages/factory-sdk/src/ports/mount.ts

echo "=== If filesystemEventToChangeEvent is defined elsewhere, print nearby definition ==="
# Best-effort: show candidate definition locations
for f in $(rg -l "filesystemEventToChangeEvent" packages/factory-sdk/src || true); do
  echo "--- $f (near match) ---"
  rg -n "filesystemEventToChangeEvent" "$f" -n
done

Repository: AgentWorkforce/pear

Length of output: 6705


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Inspect filesystemEventToChangeEvent signature/body ==="
sed -n '120,280p' packages/factory-sdk/src/subscriptions/event-client.ts

echo "=== Inspect exported filesystemEventToChangeEvent types (if any) ==="
rg -n "export function filesystemEventToChangeEvent" -n packages/factory-sdk/src/subscriptions/event-client.ts

echo "=== Inspect Subscription/Event-client emit call site types context ==="
sed -n '200,260p' packages/factory-sdk/src/subscriptions/event-client.ts

Repository: AgentWorkforce/pear

Length of output: 8541


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Inspect ChangeEvent type in subscriptions/event-client.ts (top) ==="
sed -n '1,120p' packages/factory-sdk/src/subscriptions/event-client.ts

echo "=== Inspect subscriptions/index.ts exports for filesystemEventToChangeEvent ==="
sed -n '1,120p' packages/factory-sdk/src/subscriptions/index.ts

echo "=== Inspect ports/mount.ts ChangeEvent alias again (context) ==="
sed -n '1,60p' packages/factory-sdk/src/ports/mount.ts

Repository: AgentWorkforce/pear

Length of output: 6113


Avoid as unknown as ChangeEvent by aligning the ChangeEvent types used by filesystemEventToChangeEvent and MountClient.subscribe.

filesystemEventToChangeEvent() is typed to return ChangeEvent from packages/factory-sdk/src/subscriptions/event-client.ts, while subscribe() expects ChangeEvent from packages/factory-sdk/src/ports/mount.ts (an alias of RelayFileChangeEvent from @relayfile/sdk). The as unknown as ChangeEvent cast hides this mismatch.

Consider either:

  1. Updating filesystemEventToChangeEvent’s return type to match the ports ChangeEvent (so the cast can be removed), or
  2. Exporting a single shared ChangeEvent type alias (same source as @relayfile/sdk) and using it in both places, or otherwise constructing an explicit adapter typed as the ports ChangeEvent without the double cast.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/mount/local-mirror-mount-client.ts` around lines 142
- 148, filesystemEventToChangeEvent is returning a ChangeEvent type that doesn't
match the ChangeEvent expected by MountClient.subscribe (which is an alias of
RelayFileChangeEvent), so the code uses a double cast `as unknown as
ChangeEvent`; fix this by aligning types: either change
filesystemEventToChangeEvent's return type to the ports' ChangeEvent
(RelayFileChangeEvent) or create/export a single shared ChangeEvent type alias
(sourced from `@relayfile/sdk`) and use it in filesystemEventToChangeEvent and
MountClient.subscribe, then remove the `as unknown as ChangeEvent` cast;
alternatively implement a small adapter that constructs an object explicitly
typed as the ports' ChangeEvent (used where filesystemEventToChangeEvent is
called) to ensure type compatibility without casts.

this.#slack = ports.slack ?? (config.slack ? MountSlackWriteback(ports.mount, config.slack) : undefined)
void (ports.github ?? MountGithubRead(ports.mount))
this.#slack = ports.slack ?? (config.slack ? MountSlackWriteback(mount, config.slack) : undefined)
void (ports.github ?? MountGithubRead(mount))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if MountGithubRead has constructor side effects or if the result should be stored

# Search for MountGithubRead usage patterns
echo "=== MountGithubRead usage patterns ==="
rg -n "MountGithubRead" --type=ts -C2

# Search for github port references in FactoryLoop
echo "=== References to github port in factory.ts ==="
rg -n "this\.#github|ports\.github" packages/factory-sdk/src/orchestrator/factory.ts -C2

# Check if there's a github field declaration
echo "=== Github field declarations ==="
ast-grep --pattern 'class FactoryLoop {
  $$$
  `#github`$_
  $$$
}'

Repository: AgentWorkforce/pear

Length of output: 4100


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect MountGithubRead implementation
echo "=== packages/factory-sdk/src/writeback/github.ts (relevant excerpt) ==="
sed -n '1,140p' packages/factory-sdk/src/writeback/github.ts

# Inspect factory.ts around the constructor/class fields and the line in question
echo "=== packages/factory-sdk/src/orchestrator/factory.ts (around FactoryLoop init) ==="
sed -n '1,220p' packages/factory-sdk/src/orchestrator/factory.ts | nl -ba | sed -n '50,130p'

# Search for any usage of a stored github port / field in factory loop
echo "=== Search for '`#github`' in factory.ts ==="
rg -n "`#github`" packages/factory-sdk/src/orchestrator/factory.ts -S || true

# Search the repo for ports.github and 'MountGithubRead(mount' usage
echo "=== Search repo for ports.github ==="
rg -n "ports\.github" --type=ts packages/factory-sdk/src || true

echo "=== Search repo for MountGithubRead(mount) ==="
rg -n "MountGithubRead\(mount\)" --type=ts packages/factory-sdk/src || true

echo "=== Search repo for '.github' on returned objects named ports ==="
rg -n "github.*Mount|MountGithubRead|getPr\(" --type=ts packages/factory-sdk/src/orchestrator packages/factory-sdk/src | head -n 50

Repository: AgentWorkforce/pear

Length of output: 2571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect MountGithubRead implementation
echo "=== packages/factory-sdk/src/writeback/github.ts (relevant excerpt) ==="
sed -n '1,160p' packages/factory-sdk/src/writeback/github.ts

# Inspect factory.ts around the constructor/class fields and the line in question
echo "=== packages/factory-sdk/src/orchestrator/factory.ts (around FactoryLoop init) ==="
sed -n '1,220p' packages/factory-sdk/src/orchestrator/factory.ts | nl -ba | sed -n '60,120p'

# Search for any usage of a stored github port / field in factory loop
echo "=== Search for '`#github`' in factory.ts ==="
rg -n "`#github`" packages/factory-sdk/src/orchestrator/factory.ts -S || true

# Search the repo for ports.github and other MountGithubRead call patterns
echo "=== Search repo for ports.github ==="
rg -n "ports\.github" --type=ts packages/factory-sdk/src || true

echo "=== Search repo for 'MountGithubRead(mount' ==="
rg -n "MountGithubRead\(mount" --type=ts packages/factory-sdk/src || true

Repository: AgentWorkforce/pear

Length of output: 2571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect MountGithubRead implementation
echo "=== packages/factory-sdk/src/writeback/github.ts (relevant excerpt) ==="
sed -n '1,160p' packages/factory-sdk/src/writeback/github.ts

# Inspect factory.ts around the constructor/class fields and the line in question
echo "=== packages/factory-sdk/src/orchestrator/factory.ts (around FactoryLoop init) ==="
sed -n '1,220p' packages/factory-sdk/src/orchestrator/factory.ts | nl -ba | sed -n '60,120p'

# Search for any usage of a stored github port / field in factory loop
echo "=== Search for '`#github`' in factory.ts ==="
rg -n "`#github`" packages/factory-sdk/src/orchestrator/factory.ts -S || true

# Search the repo for ports.github and other MountGithubRead call patterns
echo "=== Search repo for ports.github ==="
rg -n "ports\.github" --type=ts packages/factory-sdk/src || true

echo "=== Search repo for 'MountGithubRead(mount' ==="
rg -n "MountGithubRead\(mount" --type=ts packages/factory-sdk/src || true

Repository: AgentWorkforce/pear

Length of output: 2571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== factory.ts around the init line (lines ~60-110) ==="
sed -n '60,110p' packages/factory-sdk/src/orchestrator/factory.ts

echo "=== Search for '`#github`' in factory.ts ==="
rg -n "`#github`" packages/factory-sdk/src/orchestrator/factory.ts -S || true

echo "=== Search repo for 'ports.github' ==="
rg -n "ports\.github" --type=ts packages/factory-sdk/src || true

echo "=== Search repo for 'MountGithubRead(mount' ==="
rg -n "MountGithubRead\(mount" --type=ts packages/factory-sdk/src || true

echo "=== Search repo for 'MountGithubRead' call sites ==="
rg -n "MountGithubRead" --type=ts packages/factory-sdk/src || true

Repository: AgentWorkforce/pear

Length of output: 3069


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for getPr usage in orchestrator ==="
rg -n "\.getPr\(" packages/factory-sdk/src/orchestrator --type=ts || true

echo "=== Search for github usage keywords in orchestrator ==="
rg -n "github" packages/factory-sdk/src/orchestrator --type=ts || true

echo "=== Locate FactoryPorts type/interface ==="
rg -n "type FactoryPorts|interface FactoryPorts|FactoryPorts" packages/factory-sdk/src/orchestrator/factory.ts packages/factory-sdk/src/orchestrator -S --type=ts || true

echo "=== Show FactoryPorts definition (if in types) ==="
fd -t f "types.ts" packages/factory-sdk/src/orchestrator packages/factory-sdk/src --full-path | head -n 20
rg -n "FactoryPorts" packages/factory-sdk/src --type=ts || true

Repository: AgentWorkforce/pear

Length of output: 2434


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== packages/factory-sdk/src/types.ts (FactoryPorts) ==="
sed -n '1,220p' packages/factory-sdk/src/types.ts

echo "=== Search entire repo for 'ports.github' property usage ==="
rg -n "ports\.github" --type=ts || true

echo "=== Search entire repo for '`#github`' private fields ==="
rg -n "`#github`" --type=ts packages/factory-sdk/src || true

echo "=== Search entire repo for '.github' access on FactoryLoop/factory instances ==="
rg -n "\.github\b" --type=ts packages/factory-sdk/src/orchestrator || true

Repository: AgentWorkforce/pear

Length of output: 3875


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== factory.ts: search for GithubRead/getPr/GithubRead-like usage ==="
rg -n "GithubRead|getPr|MountGithubRead|github\." packages/factory-sdk/src/orchestrator/factory.ts --type=ts -S || true

echo "=== factory.ts: inspect around githubPullRoot usage (~620-710) ==="
sed -n '600,720p' packages/factory-sdk/src/orchestrator/factory.ts

Repository: AgentWorkforce/pear

Length of output: 4669


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Locate GithubRead type in packages/factory-sdk/src/ports ==="
fd -t f "ports.ts" packages/factory-sdk/src | head -n 20
fd -t f "ports*.ts" packages/factory-sdk/src | head -n 50

# Prefer exact file if present
if [ -f packages/factory-sdk/src/ports.ts ]; then
  sed -n '1,220p' packages/factory-sdk/src/ports.ts
fi

# Otherwise search for GithubRead definition
rg -n "export (type|interface) GithubRead" packages/factory-sdk/src --type=ts || true
rg -n "GithubRead" packages/factory-sdk/src/ports --type=ts || true

Repository: AgentWorkforce/pear

Length of output: 339


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== packages/factory-sdk/src/ports/writeback.ts (GithubRead) ==="
sed -n '1,200p' packages/factory-sdk/src/ports/writeback.ts

echo "=== packages/factory-sdk/src/ports/index.ts (exports) ==="
sed -n '1,120p' packages/factory-sdk/src/ports/index.ts

echo "=== Search for GithubRead usage across src ==="
rg -n "GithubRead" packages/factory-sdk/src --type=ts || true

echo "=== Search for getPr() call sites across src ==="
rg -n "\.getPr\(" packages/factory-sdk/src --type=ts || true

Repository: AgentWorkforce/pear

Length of output: 2723


Remove the dead MountGithubRead initialization in FactoryLoop

packages/factory-sdk/src/orchestrator/factory.ts constructor contains void (ports.github ?? MountGithubRead(mount)) and then never stores or uses a GithubRead port. MountGithubRead just returns { getPr } without side effects during construction, so this line is effectively a no-op; remove it or store ports.github/the result in a field if the factory is meant to call getPr later.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/factory-sdk/src/orchestrator/factory.ts` at line 81, The
MountGithubRead invocation in FactoryLoop is a no-op—remove the dead expression
`void (ports.github ?? MountGithubRead(mount))` or instead assign/save the
result so it can be used later; either set `ports.github = ports.github ??
MountGithubRead(mount)` or store the returned `GithubRead` (the object with
`getPr`) on the FactoryLoop instance so later code can call `getPr`; update
references so the factory actually uses the saved `GithubRead` (or remove any
unused `MountGithubRead` import) to eliminate the dead initialization.

@kjgbot

kjgbot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Closing: superseded by the empirical writeback finding. The LocalMirrorMountClient (raw-fs write to the local mirror) is not the headless writeback mechanism — the relayfile mirror local-fs is read-only (ACL workspace-API-managed), and more importantly the real path is simpler: a cloud-API write (W8 RelayfileCloudMountClient client.writeFile, workspace creds) is pushed to the provider by the relayfile CLOUD BACKEND's own WritebackConsumer (proven: created AR-133 end-to-end, Pear down, no mount-write, no own-consumer). So no local-mirror client, no own-consumer, no re-mount needed. The diligent fail-closed/acked-not-queued/guards-above-mount review work here directly informed the final design (per-opId providerResult verify + the chokepoint backstop). Follow-up = W10 (writeback payload hygiene: strip read-only fields + verify-repoint + is-real-issue dispatch guard + structural invariant audit + cloud-client chokepoint).

@kjgbot kjgbot closed this Jun 12, 2026
agent-relay-code Bot added a commit that referenced this pull request Jun 12, 2026
@agent-relay-code

Copy link
Copy Markdown
Contributor

Implemented fixes for the PR and reran the repo’s CI-equivalent local checks.

Changed:

  • Preserved wrapped Linear issue payloads when local mirror state updates are written.
  • Normalized local mirror write markers.
  • Added safer mirror metadata parsing.
  • Avoided redundant polling on native recursive-watch platforms.
  • Made writeback verification use an immediate ack check.
  • Aligned subscription event typing and removed the local double cast.
  • Removed the dead MountGithubRead initialization.

Addressed comments

  • gemini-code-assist[bot]: redundant polling alongside native recursive watch; fixed in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:177.
  • gemini-code-assist[bot]: raw #lastWriteByPath key should be normalized; fixed in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:87, covered by packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts:211.
  • gemini-code-assist[bot]: malformed state.json should not throw raw parse errors; fixed in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:246 and :313, covered by packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts:94.
  • gemini-code-assist[bot]: malformed mount.pid should be handled gracefully; fixed in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:381, covered by packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts:104.
  • gemini-code-assist[bot]: outbox polling should skip old marker files; fixed in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:470.
  • gemini-code-assist[bot]: verifyAcked should not wait another 90 seconds; fixed in packages/factory-sdk/src/writeback/linear.ts:128.
  • coderabbitai[bot]: remove as unknown as ChangeEvent; fixed by aligning the shared event type in packages/factory-sdk/src/subscriptions/event-client.ts:19 and using it directly in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:144.
  • coderabbitai[bot]: remove dead MountGithubRead initialization; fixed in packages/factory-sdk/src/orchestrator/factory.ts:23 and :80.
  • coderabbitai[bot]: add comment for aggregate ack heuristic; fixed in packages/factory-sdk/src/mount/local-mirror-mount-client.ts:291.
  • coderabbitai[bot]: require mirrorDir for relayfile-mirror; no change. Invalid against current checkout because mirrorDir is intentionally optional in schema at packages/factory-sdk/src/config/schema.ts:10 and defaults in LocalMirrorMountClient at packages/factory-sdk/src/mount/local-mirror-mount-client.ts:60.
  • coderabbitai[bot] top-level summary: all actionable and nitpick items are accounted above.

Advisory Notes

None.

Verification

Passed locally:

  • npx vitest run packages/factory-sdk/src/mount/local-mirror-mount-client.test.ts packages/factory-sdk/src/writeback/writeback.test.ts packages/factory-sdk/src/config/schema.test.ts packages/factory-sdk/src/cli/fleet.test.ts
  • npm run verify:mcp-resources-drift && npm run lint && npm run typecheck:web && npm run typecheck:node && npm test && npx vitest run && npm run build && npm run verify:mcp-resources-drift

npm run lint still reports existing warnings, but no errors. I did not verify GitHub-side mergeability or remote check status from here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant