Skip to content

feat(relayfile): fork API — SDK + server routes + core primitives#58

Merged
khaliqgant merged 5 commits intomainfrom
khaliq/relayfile-fork-api
Apr 21, 2026
Merged

feat(relayfile): fork API — SDK + server routes + core primitives#58
khaliqgant merged 5 commits intomainfrom
khaliq/relayfile-fork-api

Conversation

@khaliqgant
Copy link
Copy Markdown
Member

@khaliqgant khaliqgant commented Apr 21, 2026

Summary

  • Added fork lifecycle endpoints under /v1/workspaces/:workspaceId/forks:
    • POST /forks creates or returns an idempotent fork for (workspaceId, proposalId).
    • DELETE /forks/:forkId discards overlay state idempotently.
    • POST /forks/:forkId/commit promotes overlay writes/deletes back to the parent when the parent revision still matches.
  • Added forkId query scoping to existing fs routes for file read/write/delete, bulk write, tree, and query.
  • Implemented pointer-plus-overlay fork state in the actual Go server/store in this repo (internal/httpapi + internal/relayfile.Store). The Cloudflare Worker/Durable Object tree described in the issue is not present on main.
  • Added @relayfile/core fork primitives in packages/core/src/forks.ts and re-exported them.
  • Added TypeScript SDK methods:
    • createFork(input)
    • discardFork(input)
    • commitFork(input)
  • Added optional forkId SDK fields for writeFile, readFile, deleteFile, bulkWrite, listTree, and queryFiles.
  • Version bumps:
    • @relayfile/core: 0.3.0 -> 0.5.0
    • @relayfile/sdk: 0.3.0 -> 0.4.0

Storage Model

parent workspace ws_1
  revision: rev_42
  files:
    /docs/a.md
    /docs/b.md
        ^
        | parentRevision
        |
fork fork_123
  workspaceId: ws_1
  proposalId: prop_1
  parentRevision: rev_42
  expiresAt: now + ttl
  overlay:
    /docs/a.md -> write { revision: fork:fork_123:1, file: ... }
    /docs/b.md -> delete tombstone

Fork reads and listings merge parent files with overlay entries: overlay writes win, overlay deletes hide parent files. The current Go store does not have historical point-in-time file reads, so unmodified fork paths read from the current parent view. Commit still compare-and-sets against the stored parentRevision and returns parent_moved if the parent changed.

Test Coverage

SDK tests added in packages/sdk/typescript/src/client.test.ts:

  1. createFork posts { proposalId, ttlSeconds } and returns a fork handle.
  2. createFork omits ttlSeconds when unset.
  3. discardFork sends DELETE /forks/:forkId and resolves on 204.
  4. commitFork sends POST /forks/:forkId/commit and returns commit counts.
  5. writeFile with forkId appends forkId and keeps the JSON body unchanged.
  6. readFile with forkId appends forkId.
  7. bulkWrite with forkId appends forkId.
  8. listTree with forkId appends forkId.
  9. writeFile without forkId does not include forkId.
  10. Extra: queryFiles with forkId appends forkId.
  11. Extra: deleteFile with forkId appends forkId.

Server tests added in internal/httpapi/server_test.go:

  1. Create fork -> write file to fork -> read returns overlay content.
  2. Create fork -> read unwritten path returns parent content.
  3. Create fork -> delete path in fork -> fork read returns 404 while parent still has file.
  4. Create fork -> write -> commit -> parent has written content and commit revision.
  5. Create fork -> write -> parent moves -> commit returns 409 parent_moved.
  6. Create fork -> discard twice -> both return 204 and fork operations return 404.
  7. Create fork twice with the same proposalId -> same forkId.
  8. Create fork with short TTL -> expiry timer removes state and commit returns 410 fork_expired.
  9. Fork tree merges parent files, overlay writes, and overlay delete tombstones.

Core tests added in packages/core/src/forks.test.ts cover TTL normalization, expiry calculation, overlay revision generation, write overlay storage, and delete tombstones.

Migration Notes

No database migration is required for this repo's current in-process/JSON store.

New persisted JSON state fields:

  • persistedState.forks: active fork metadata and overlays.
  • workspaceState.revision: parent workspace head revision used for fork commit compare-and-set.

In-memory only:

  • fork proposal indexes
  • expired fork markers
  • expiry timers

Known Gaps / Out of Scope

  • Nested forks are not supported; POST /forks with forkId returns nested_forks_not_supported.
  • Python SDK support is intentionally not included.
  • No cross-workspace forks or fork-of-fork semantics.
  • No full workspace snapshots; forks remain pointer-plus-overlay only.
  • No historical parent point-in-time read support in the current Go store. The behavior is documented in docs/fork-architecture.md.
  • No Cloudflare Durable Object implementation was added because apps/relayfile is not present on main; this PR implements the server that exists in this repo.

Verification

  • npm install
  • npm run build --workspace=packages/core && npm run test --workspace=packages/core
  • npm run typecheck --workspace=packages/sdk/typescript && npm run test --workspace=packages/sdk/typescript && npm run build --workspace=packages/sdk/typescript
  • go test ./...
  • npm run typecheck --if-present
  • npm run build --if-present
  • npm test --if-present

Open in Devin Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f1ed3e4650

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/httpapi/server.go Outdated
Comment on lines +1357 to +1359
func (s *Server) handleCommitFork(w http.ResponseWriter, _ *http.Request, workspaceID, forkID, correlationID string) {
resp, err := s.store.CommitFork(workspaceID, forkID, correlationID)
if err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce ACL and path scopes before committing forks

handleCommitFork immediately calls s.store.CommitFork(...) without re-validating per-file ACL rules or path-scoped authorization for the overlay entries being promoted. In a shared workspace/proposal flow, a caller with broad fs:write scope on the workspace (but without access to specific files/paths) can still commit another actor’s staged overlay changes to protected files, which is an authorization bypass.

Useful? React with 👍 / 👎.

Comment on lines +1362 to +1365
writeJSON(w, http.StatusConflict, map[string]any{
"error": "parent_moved",
"currentRevision": parentMoved.CurrentRevision,
"correlationId": correlationID,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return commit conflicts in standard error schema

The parent_moved branch returns a custom payload with error/currentRevision instead of the normal code/message envelope used elsewhere. The TypeScript SDK error parser keys off code/message, so commitFork conflicts become generic API errors and lose currentRevision, which prevents clients from reliably detecting and handling rebase/retry paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1401 to +1405
case relayfile.ErrForkExpired:
writeJSON(w, http.StatusGone, map[string]any{
"error": "fork_expired",
"correlationId": correlationID,
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Fork expired error response uses "error" key instead of standard "code" key, breaking SDK error detection

In handleCommitFork, when the fork has expired, the response uses writeJSON with a non-standard JSON shape {"error": "fork_expired", "correlationId": "..."} instead of the standard writeError format {"code": "...", "message": "...", "correlationId": "..."} used by every other endpoint (see writeError at internal/httpapi/server.go:2365-2371). The SDK's throwForError at packages/sdk/typescript/src/client.ts:945-1015 reads data.code from the ErrorResponse interface (packages/sdk/typescript/src/types.ts:264-269), so the resulting RelayFileApiError will have code: "api_error" and message: "HTTP 410" instead of code: "fork_expired". SDK consumers cannot programmatically identify fork expiry errors.

Suggested change
case relayfile.ErrForkExpired:
writeJSON(w, http.StatusGone, map[string]any{
"error": "fork_expired",
"correlationId": correlationID,
})
case relayfile.ErrForkExpired:
writeError(w, http.StatusGone, "fork_expired", err.Error(), correlationID)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1340 to +1344
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "nested_forks_not_supported",
"correlationId": correlationID,
})
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Nested forks error response uses "error" key instead of standard "code" key

Same inconsistency as the fork_expired response: handleCreateFork uses writeJSON with {"error": "nested_forks_not_supported"} instead of the standard writeError format with "code" and "message" keys. The SDK will see this as code: "api_error" and message: "HTTP 400" instead of the intended error code.

Suggested change
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "nested_forks_not_supported",
"correlationId": correlationID,
})
return
writeError(w, http.StatusBadRequest, "nested_forks_not_supported", "nested forks are not supported", correlationID)
return
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread internal/relayfile/store.go Outdated
_, task := s.recordWriteLocked(ws, path, revision, "file.deleted", existing.Provider, correlationID)
tasks = append(tasks, task)
}
deletedCount++
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot Apr 21, 2026

Choose a reason for hiding this comment

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

🟡 Redundant nested if existed blocks in fork commit delete path

In CommitForkWithValidator, the delete case (lines 1640-1644) contains two redundant if existed wrappers around deletedCount++. The first if existed block (lines 1633-1639) already handles the actual file deletion. The second pair of nested if existed blocks is clearly a merge or copy-paste artifact. While the Go compiler accepts this and the runtime behavior is correct (the condition is checked redundantly), this is malformed code that could mislead future editors into thinking there's complex conditional logic here.

Malformed code block
if existed {
    if existed {
    deletedCount++
}
}

Should be simply:

if existed {
    deletedCount++
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@miyaontherelay
Copy link
Copy Markdown
Contributor

Follow-up on Devin review feedback:

  • Review comment #3116443765 is resolved.
  • CommitForkWithValidator now increments deletedCount only when the deleted file actually existed in the parent workspace.
  • Fixed in 5a31d4f.

I also fixed the related failing CI wiring in the same push by building packages/core before SDK validation in the affected workflows.

@khaliqgant khaliqgant merged commit a935e6d into main Apr 21, 2026
6 checks passed
@khaliqgant khaliqgant deleted the khaliq/relayfile-fork-api branch April 21, 2026 10:22
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.

2 participants