Skip to content

Bootstrap first publish directly to source repos - #194

Merged
kentcdodds merged 4 commits into
mainfrom
cursor/first-publish-bootstrap-35df
Apr 18, 2026
Merged

kentcdodds merged 4 commits into
mainfrom
cursor/first-publish-bootstrap-35df

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Apr 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • split first publish from later repo-session edits by routing unpublished sources through a direct source-repo bootstrap path
  • keep repo-session fork/export workflows only for sources that already have a published commit
  • harden repo session workspace cleanup so stale bootstrap .git state cannot be silently reused by later session clones
  • validate repo manifests before bootstrap push so bootstrap failures cannot leave a pushed commit with no recorded published_commit
  • add focused node-unit coverage for unpublished-source bootstrap vs published-source session publish

Validation

  • npx vitest run --project node-unit "packages/worker/src/repo/source-sync.node.test.ts" "packages/worker/src/repo/source-service.node.test.ts" "packages/worker/src/repo/source-backfill.node.test.ts"
  • npx vitest run --project node-unit "packages/worker/src/repo/source-sync.node.test.ts" "packages/worker/src/repo/source-service.node.test.ts" "packages/worker/src/repo/source-backfill.node.test.ts" "packages/worker/src/repo/app-source.node.test.ts" "packages/worker/src/jobs/service.node.test.ts"
  • npx tsc -b --noEmit

Notes

  • left the pre-existing package-lock.json working-tree change untouched
  • kept the Artifacts REST transport only; no worker-binding fallback was reintroduced
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Added a source-bootstrapping API to initialize unpublished repositories, apply edits, commit/push changes, and return publish results.
  • Improvements

    • Improved workspace lifecycle management, session cleanup, and shared handling of workspace edits with validation and rollback support.
  • Tests

    • Added tests covering unpublished and published source sync flows to validate bootstrapping and session-based apply/publish behaviors.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Apr 18, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a bootstrap path to initialize unpublished source repositories, refactors workspace cleanup into resetWorkspace(), introduces applyWorkspaceEdits() shared helper, exposes bootstrapSource() on the session RPC, and updates syncArtifactSourceSnapshot to route between bootstrap and existing session publish flows.

Changes

Cohort / File(s) Summary
Type Definitions
packages/worker/src/repo/types.ts
Added exported RepoSourceBootstrapResult { sessionId, publishedCommit, message }.
Session Implementation
packages/worker/src/repo/repo-session-do.ts
Added resetWorkspace() to remove workspace and clear initializedSessionId. Added applyWorkspaceEdits(...) to validate and convert structured edits into planEdits + applyEditPlan. Replaced inline edit-plan logic with the helper. Added bootstrapSource(...) to create/init workspace & git repo, apply edits with rollback, commit/push, parse manifest, and persist publishedCommit/manifest data. Updated discardSession and getSessionInfo to use resetWorkspace().
RPC Interface
packages/worker/src/repo/repo-session-rpc.ts
Extended RepoSessionRpc with bootstrapSource(payload: { sessionId, sourceId, userId, edits }) => Promise<RepoSourceBootstrapResult>.
Source Sync Logic
packages/worker/src/repo/source-sync.ts
Precomputes a single edits array. If source.published_commit is null, calls session.bootstrapSource(...) and returns its publishedCommit; otherwise uses existing open/apply/publish session flow with the precomputed edits.
Tests
packages/worker/src/repo/source-sync.node.test.ts
New Vitest tests for syncArtifactSourceSnapshot verifying: unpublished sources call bootstrapSource and avoid session flow methods; published sources follow open/apply/publish flow. Mocks getEntitySourceById and repoSessionRpc.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant RepoSession
    participant Workspace
    participant Git
    participant Manifest
    participant Database

    Client->>RepoSession: bootstrapSource(sessionId, sourceId, userId, edits)
    RepoSession->>Workspace: resetWorkspace()
    RepoSession->>Workspace: create/initialize workspace
    RepoSession->>Git: init repo & configure remote
    RepoSession->>Workspace: applyWorkspaceEdits(edits)
    Workspace-->>RepoSession: edits applied / results
    RepoSession->>Git: commit & push branch
    Git-->>RepoSession: publishedCommit
    RepoSession->>Manifest: read & parse manifest file
    Manifest-->>RepoSession: parsed manifest data
    RepoSession->>Database: persist publishedCommit + manifest/sourceRoot
    Database-->>RepoSession: persisted
    RepoSession-->>Client: return RepoSourceBootstrapResult(sessionId, publishedCommit, message)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰
A clean workspace, soft and bright,
I nibble edits through the night,
Commit and push, a hopping cheer,
Manifests whisper, new repo near,
Hooray — a source begins to light!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: introducing a new bootstrap path for initial publishing directly to source repos, bypassing the standard session workflow for unpublished sources.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/first-publish-bootstrap-35df

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.

@kentcdodds
kentcdodds marked this pull request as ready for review April 18, 2026 02:26
@github-actions

github-actions Bot commented Apr 18, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-194.kentcdodds.workers.dev

Worker: kody-pr-194
D1: kody-pr-194-db
KV: kody-pr-194-oauth-kv

Mocks:

@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/worker/src/repo/source-sync.node.test.ts (1)

19-193: Solid branch coverage for the new routing.

Both tests pin down the positive and negative RPC calls per branch, and the edits shape check guards the precomputed array. Consider a follow-up test for the publishResult.status !== 'ok' rethrow path in source-sync.ts — currently uncovered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/repo/source-sync.node.test.ts` around lines 19 - 193, Add
a new unit test that covers the error path when publishResult.status !== 'ok' in
syncArtifactSourceSnapshot: mock getEntitySourceById and repoSessionRpc to
return a sessionClient where publishSession resolves with status !== 'ok' (e.g.,
status: 'error') and ensure syncArtifactSourceSnapshot throws/rethrows that
error, and verify discardSession is still called with the expected sessionId and
userId; locate behavior around the syncArtifactSourceSnapshot function and the
sessionClient.publishSession / publishResult.status handling in source-sync.ts
to implement this negative-path test.
packages/worker/src/repo/repo-session-do.ts (1)

535-555: DRY: duplicate manifest-read + source update block with publishSession.

Lines 535-555 here and lines 956-979 in publishSession are near-identical (read manifest → parseRepoManifest → updateEntitySource with the same sourceRoot normalization startsWith('/') ? ... : '/' + ... : source.source_root). A small helper (e.g. writeSourcePublishedState({ source, publishedCommit })) on the class would remove the drift risk and centralize the sourceRoot normalization rule.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/repo/repo-session-do.ts` around lines 535 - 555, The
manifest-read + updateEntitySource logic is duplicated between the block in this
function and publishSession; extract a private helper on the RepoSessionDo class
(e.g. writeSourcePublishedState({ source, publishedCommit })) that performs
workspace.readFile(resolveRepoWorkspacePath(...)), parses with
parseRepoManifest, computes sourceRoot using the existing normalization
(manifest.sourceRoot?.startsWith('/') ? manifest.sourceRoot :
manifest.sourceRoot ? `/${manifest.sourceRoot}` : source.source_root), and calls
updateEntitySource(this.env.APP_DB, {...}) with the same fields (id, userId,
publishedCommit, manifestPath, sourceRoot); replace the two duplicated blocks
(the current block and the block in publishSession) to call this new helper so
the normalization and update logic are centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/src/repo/repo-session-do.ts`:
- Around line 521-555: The manifest is being read and parsed after git.push,
which can leave a commit on the remote if parsing fails; move the manifest
read/validation to before the push so parsing errors prevent any remote push.
Specifically, after publishCommit is created by commitIfDirty (publishedCommit)
and before calling this.git.push, call
this.workspace.readFile(resolveRepoWorkspacePath(...)) and run
parseRepoManifest(...) (same args currently used), validate manifest.sourceRoot
normalization, and only then call this.git.push and updateEntitySource; keep
updateEntitySource, commitIfDirty, this.git.push, this.workspace.readFile and
parseRepoManifest as the referenced symbols to locate the change.
- Around line 389-393: The new throw in openSession tightens the invariant but
breaks callers that pass unpublished sources (e.g., repo_open_session,
run-saved-skill.ts, jobs/service.ts, app-source.ts) because ensureEntitySource
can create sources without published_commit and bootstrapSource isn't exported;
fix by changing openSession in repo-session-do.ts to detect missing
source.published_commit and either (A) call bootstrapSource internally to create
the published commit before proceeding (ensure bootstrapSource is accessible in
this module and handle errors/logging), or (B) if you prefer explicit flow,
export bootstrapSource and add a public MCP capability so callers (including
repo_open_session) can bootstrap prior to calling openSession; reference
symbols: openSession, bootstrapSource, published_commit, repo_open_session,
ensureEntitySource.

---

Nitpick comments:
In `@packages/worker/src/repo/repo-session-do.ts`:
- Around line 535-555: The manifest-read + updateEntitySource logic is
duplicated between the block in this function and publishSession; extract a
private helper on the RepoSessionDo class (e.g. writeSourcePublishedState({
source, publishedCommit })) that performs
workspace.readFile(resolveRepoWorkspacePath(...)), parses with
parseRepoManifest, computes sourceRoot using the existing normalization
(manifest.sourceRoot?.startsWith('/') ? manifest.sourceRoot :
manifest.sourceRoot ? `/${manifest.sourceRoot}` : source.source_root), and calls
updateEntitySource(this.env.APP_DB, {...}) with the same fields (id, userId,
publishedCommit, manifestPath, sourceRoot); replace the two duplicated blocks
(the current block and the block in publishSession) to call this new helper so
the normalization and update logic are centralized.

In `@packages/worker/src/repo/source-sync.node.test.ts`:
- Around line 19-193: Add a new unit test that covers the error path when
publishResult.status !== 'ok' in syncArtifactSourceSnapshot: mock
getEntitySourceById and repoSessionRpc to return a sessionClient where
publishSession resolves with status !== 'ok' (e.g., status: 'error') and ensure
syncArtifactSourceSnapshot throws/rethrows that error, and verify discardSession
is still called with the expected sessionId and userId; locate behavior around
the syncArtifactSourceSnapshot function and the sessionClient.publishSession /
publishResult.status handling in source-sync.ts to implement this negative-path
test.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: eb67fc48-514c-4d50-b7f2-578a360ae21c

📥 Commits

Reviewing files that changed from the base of the PR and between b6275d1 and 9feeb84.

📒 Files selected for processing (5)
  • packages/worker/src/repo/repo-session-do.ts
  • packages/worker/src/repo/repo-session-rpc.ts
  • packages/worker/src/repo/source-sync.node.test.ts
  • packages/worker/src/repo/source-sync.ts
  • packages/worker/src/repo/types.ts

Comment on lines +389 to +393
if (!baseCommit) {
throw new Error(
`Source "${source.id}" has no published commit yet. Bootstrap the source repo before opening a repo session.`,
)
}

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all call sites of openSession to confirm none rely on the previous lenient behavior.
rg -nP --type=ts -C3 '\bopenSession\s*\(' -g '!**/*.test.ts'

Repository: kentcdodds/kody

Length of output: 3850


🏁 Script executed:

#!/bin/bash
# Check the context of openSession calls to see if sources are prepared/bootstrapped first
# Start with run-saved-skill.ts
echo "=== run-saved-skill.ts context (around line 108) ==="
sed -n '95,120p' packages/worker/src/mcp/skills/run-saved-skill.ts

echo -e "\n=== repo-open-session.ts context (around line 60) ==="
sed -n '40,75p' packages/worker/src/mcp/capabilities/repo/repo-open-session.ts

echo -e "\n=== app-source.ts context (around line 80) ==="
sed -n '60,95p' packages/worker/src/repo/app-source.ts

echo -e "\n=== jobs/service.ts context (around lines 464, 478) ==="
sed -n '450,490p' packages/worker/src/jobs/service.ts

Repository: kentcdodds/kody

Length of output: 4389


🏁 Script executed:

#!/bin/bash
# Check source-sync.ts to understand bootstrap pattern
echo "=== source-sync.ts full context ==="
sed -n '1,80p' packages/worker/src/repo/source-sync.ts

echo -e "\n=== Check if bootstrapSource is exported/available ==="
rg -n "export.*bootstrapSource" packages/worker/src/repo/

echo -e "\n=== Check source types and validation ==="
echo "Checking if sources are validated as published before these calls..."
rg -n "published_commit|isPublished" packages/worker/src/repo/ -t ts | head -20

Repository: kentcdodds/kody

Length of output: 4335


🏁 Script executed:

#!/bin/bash
# Check if sources are validated as published in each context
echo "=== repo-open-session.ts: Check source validation before openSession ==="
sed -n '40,70p' packages/worker/src/mcp/capabilities/repo/repo-open-session.ts

echo -e "\n=== run-saved-skill.ts: Check source validation context ==="
sed -n '85,110p' packages/worker/src/mcp/skills/run-saved-skill.ts

echo -e "\n=== jobs/service.ts: Check job/source setup ==="
sed -n '445,475p' packages/worker/src/jobs/service.ts

echo -e "\n=== app-source.ts: Check artifact source constraints ==="
sed -n '65,95p' packages/worker/src/repo/app-source.ts

echo -e "\n=== Check if these sources are created via published flows ==="
echo "Checking source creation context..."
rg -n "sourceId|source_id" packages/worker/src/mcp/skills/run-saved-skill.ts | head -5

Repository: kentcdodds/kody

Length of output: 4159


🏁 Script executed:

#!/bin/bash
# Check repoSessionNeedsRefresh to understand the refresh logic
echo "=== Check repoSessionNeedsRefresh ==="
rg -n "repoSessionNeedsRefresh" packages/worker/src/ -A 5 -B 2

# Check if skills and jobs require published sources
echo -e "\n=== Check skill source constraints ==="
rg -n "getMcpSkillByNameInput|skill.*source" packages/worker/src/mcp/skills/run-saved-skill.ts | head -10

# Check artifact source constraints
echo -e "\n=== Check canResolveRepoBackedSource ==="
rg -n "canResolveRepoBackedSource" packages/worker/src/repo/app-source.ts -B 10 -A 10

Repository: kentcdodds/kody

Length of output: 3362


🏁 Script executed:

#!/bin/bash
# Check the actual error that would be thrown and when it happens
echo "=== Check openSession implementation (lines 364-393) ==="
sed -n '364,395p' packages/worker/src/repo/repo-session-do.ts

# Check if job sources are guaranteed to be published
echo -e "\n=== Check JobRecord and job source setup ==="
rg -n "JobRecord|job.*source" packages/worker/src/jobs/ -t ts | grep -E "interface|type|sourceId" | head -15

# Check if skill sources are guaranteed to be published
echo -e "\n=== Check getMcpSkillByNameInput result ==="
rg -n "getMcpSkillByNameInput" packages/worker/src/mcp/skills/ -A 20 | head -40

Repository: kentcdodds/kody

Length of output: 5718


🏁 Script executed:

#!/bin/bash
# Critical: Check if unpublished sources can be stored in jobs/skills/artifacts
echo "=== Check job creation - can sourceId point to unpublished source? ==="
rg -n "createJob|insertJob" packages/worker/src/jobs/ -A 10 -B 2 | head -40

echo -e "\n=== Check skill creation - can source_id point to unpublished source? ==="
rg -n "createSkill|meta_save_skill" packages/worker/src/mcp/skills/ -A 10 -B 2 | head -50

echo -e "\n=== Check artifact creation - can sourceId point to unpublished source? ==="
rg -n "createArtifact|insertArtifact" packages/worker/src/ -A 10 -B 2 | head -50

echo -e "\n=== Most critical: Check if repo-open-session allows unpublished sources ==="
echo "Can user request an unpublished source directly in repo-open-session?"
sed -n '40,70p' packages/worker/src/mcp/capabilities/repo/repo-open-session.ts

Repository: kentcdodds/kody

Length of output: 8830


🏁 Script executed:

#!/bin/bash
# Check if bootstrapSource is exported and accessible
echo "=== Check bootstrapSource export ==="
rg -n "bootstrapSource" packages/worker/src/repo/repo-session-do.ts | head -20

echo -e "\n=== Check what is exported from repo-session-do ==="
rg -n "export" packages/worker/src/repo/repo-session-do.ts | head -20

echo -e "\n=== Critical: Check if repo-open-session can handle unpublished sources ==="
echo "Does repo-open-session MCP capability have any bootstrap logic?"
rg -n "bootstrap" packages/worker/src/mcp/capabilities/repo/ -t ts

Repository: kentcdodds/kody

Length of output: 465


🏁 Script executed:

#!/bin/bash
# Check if bootstrapSource is accessible via RPC and if there's a bootstrap MCP capability
echo "=== Check bootstrapSource in RepoSession class ==="
sed -n '458,480p' packages/worker/src/repo/repo-session-do.ts

echo -e "\n=== Check if bootstrapSource is accessible via repoSessionRpc ==="
rg -n "bootstrapSource" packages/worker/src/ -t ts | grep -v test

echo -e "\n=== Check all MCP capabilities for repo ==="
rg -n "defineCapability" packages/worker/src/mcp/capabilities/repo/ -A 3 -B 1

echo -e "\n=== Check if there's a separate bootstrap capability ==="
find packages/worker/src/mcp/capabilities -name "*bootstrap*" -o -name "*source*"

Repository: kentcdodds/kody

Length of output: 1305


🏁 Script executed:

#!/bin/bash
# Check if there's a way to bootstrap sources before repo-open-session
echo "=== Check if there's a bootstrap MCP capability ==="
rg -n "defineCapability.*bootstrap" packages/worker/src/mcp/capabilities/

echo -e "\n=== Check repo-open-session full implementation ==="
sed -n '1,100p' packages/worker/src/mcp/capabilities/repo/repo-open-session.ts

echo -e "\n=== Check if source is guaranteed published by how it's looked up ==="
rg -n "getEntitySourceById" packages/worker/src/mcp/capabilities/repo/repo-open-session.ts -B 5 -A 5

Repository: kentcdodds/kody

Length of output: 3628


🏁 Script executed:

#!/bin/bash
# Verify if bootstrapSource is truly accessible via RPC
echo "=== Check repo-session-rpc.ts to see if bootstrapSource is callable ==="
sed -n '70,80p' packages/worker/src/repo/repo-session-rpc.ts

echo -e "\n=== Check the full RPC export ==="
rg -n "export.*repoSessionRpc" packages/worker/src/repo/repo-session-rpc.ts -A 30

echo -e "\n=== Verify: Is there any way to bootstrap before repo-open-session? ==="
rg -n "bootstrap" packages/worker/src/mcp/capabilities/ -t ts

Repository: kentcdodds/kody

Length of output: 4358


🏁 Script executed:

#!/bin/bash
# Final check: Can sources be unpublished when reached through these callers?
echo "=== Check if there's a validation that ensures sources are published ==="
rg -n "published" packages/worker/src/repo/entity-sources.ts -B 3 -A 3

echo -e "\n=== Check how sources are created/persisted ==="
rg -n "ensureEntitySource" packages/worker/src/ -t ts | head -10

echo -e "\n=== Most critical: Could an unpublished source reach repo-open-session? ==="
echo "What if a user calls repo_open_session with a just-created source?"
sed -n '1,20p' packages/worker/src/repo/source-service.ts

Repository: kentcdodds/kody

Length of output: 3397


🏁 Script executed:

#!/bin/bash
# Final confirmation: check if ensureEntitySource can create unpublished sources
echo "=== Check ensureEntitySource in jobs service ==="
sed -n '150,180p' packages/worker/src/jobs/service.ts

echo -e "\n=== Verify: Can repo-open-session receive an unpublished source? ==="
echo "Scenario: User creates a job with a source, then tries to open repo session"
rg -n "ensureEntitySource" packages/worker/src/jobs/service.ts -B 2 -A 8

Repository: kentcdodds/kody

Length of output: 2386


🏁 Script executed:

#!/bin/bash
# Final verification: Can an unpublished source actually be created and reach openSession?
echo "=== Check if ensureEntitySource can leave sources unpublished ==="
sed -n '47,95p' packages/worker/src/repo/source-service.ts

echo -e "\n=== Conclusion: Is bootstrapSource exported/accessible? ==="
echo "Checking all exports from repo-session-do.ts..."
grep "^export" packages/worker/src/repo/repo-session-do.ts

Repository: kentcdodds/kody

Length of output: 1727


🏁 Script executed:

#!/bin/bash
# One final check: Can sources reach openSession calls while unpublished?
echo "=== Check buildEntitySourceRow - does it set published_commit? ==="
sed -n '68,100p' packages/worker/src/repo/source-service.ts

echo -e "\n=== Check: Does any of these callers validate published_commit? ==="
echo "Checking each caller for published_commit validation..."
for file in \
  "packages/worker/src/mcp/skills/run-saved-skill.ts" \
  "packages/worker/src/jobs/service.ts" \
  "packages/worker/src/repo/app-source.ts" \
  "packages/worker/src/mcp/capabilities/repo/repo-open-session.ts"
do
  echo "=== $file ==="
  rg -n "published" "$file"
done

Repository: kentcdodds/kody

Length of output: 2630


Tightens the invariant, but creates a breaking change for unpublished sources without a bootstrap path.

While the invariant is sound, the new error in openSession will break callers that pass unpublished sources—specifically the public repo_open_session MCP capability and other callers like run-saved-skill.ts, jobs/service.ts, and app-source.ts. These sources can be created unpublished via ensureEntitySource (which doesn't set published_commit), but there is no public way to bootstrap them:

  • bootstrapSource is not exported from repo-session-do.ts
  • No public MCP bootstrap capability exists
  • Only source-sync.ts has the pattern to check if (!source.published_commit) before deciding to bootstrap

The suggestion to "route unpublished sources through bootstrapSource first" is not feasible for the public repo_open_session MCP API or other existing callers. Either provide a public bootstrap capability or ensure all source creation paths guarantee published_commit is set before this change takes effect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/repo/repo-session-do.ts` around lines 389 - 393, The new
throw in openSession tightens the invariant but breaks callers that pass
unpublished sources (e.g., repo_open_session, run-saved-skill.ts,
jobs/service.ts, app-source.ts) because ensureEntitySource can create sources
without published_commit and bootstrapSource isn't exported; fix by changing
openSession in repo-session-do.ts to detect missing source.published_commit and
either (A) call bootstrapSource internally to create the published commit before
proceeding (ensure bootstrapSource is accessible in this module and handle
errors/logging), or (B) if you prefer explicit flow, export bootstrapSource and
add a public MCP capability so callers (including repo_open_session) can
bootstrap prior to calling openSession; reference symbols: openSession,
bootstrapSource, published_commit, repo_open_session, ensureEntitySource.

Comment thread packages/worker/src/repo/repo-session-do.ts

@cursor cursor Bot left a comment •

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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Guard in openSession breaks fallback for unpublished sources
    • Removed the early error so openSession again tolerates missing published commits and allows the inline fallback path to proceed.
Preview (4e2e7cf39b)
diff --git a/packages/worker/src/repo/repo-session-do.ts b/packages/worker/src/repo/repo-session-do.ts
--- a/packages/worker/src/repo/repo-session-do.ts
+++ b/packages/worker/src/repo/repo-session-do.ts
@@ -33,6 +33,7 @@
 	type RepoApplyPatchResult,
 	type RepoSearchMode,
 	type RepoSearchOutputMode,
+	type RepoSourceBootstrapResult,
 	type RepoSessionApplyEditsResult,
 	type RepoSessionCheckRun,
 	type RepoSessionCheckStatus,
@@ -169,6 +170,18 @@
 		})
 	}
 
+	private async resetWorkspace() {
+		await this.workspace
+			.rm(repoSessionWorkspacePrefix, {
+				force: true,
+				recursive: true,
+			})
+			.catch(() => {
+				// Best effort only; the session row is the source of truth.
+			})
+		this.initializedSessionId = null
+	}
+
 	private async getCurrentBranch(defaultBranch = defaultSessionBranch) {
 		const branchResult = await this.git.branch({
 			dir: repoSessionWorkspacePrefix,
@@ -271,6 +284,83 @@
 		this.initializedSessionId = input.sessionId
 	}
 
+	private async applyWorkspaceEdits(input: {
+		edits: Array<{
+			kind: 'write' | 'replace' | 'writeJson'
+			path: string
+			content?: string
+			search?: string
+			replacement?: string
+			value?: unknown
+			options?: {
+				caseSensitive?: boolean
+				regex?: boolean
+				wholeWord?: boolean
+				contextBefore?: number
+				contextAfter?: number
+				maxMatches?: number
+				spaces?: number
+			}
+		}>
+		dryRun?: boolean
+		rollbackOnError?: boolean
+	}): Promise<RepoSessionApplyEditsResult> {
+		const plan = await this.state.planEdits(
+			input.edits.map((edit) => {
+				const path = resolveRepoWorkspacePath(
+					edit.path,
+					repoSessionWorkspacePrefix,
+				)
+				switch (edit.kind) {
+					case 'write':
+						if (typeof edit.content !== 'string') {
+							throw new Error('repo_apply_patch write edits require content.')
+						}
+						return {
+							kind: 'write' as const,
+							path,
+							content: edit.content,
+						}
+					case 'replace':
+						if (typeof edit.search !== 'string') {
+							throw new Error('repo_apply_patch replace edits require search.')
+						}
+						return {
+							kind: 'replace' as const,
+							path,
+							search: edit.search,
+							replacement: edit.replacement ?? '',
+							options: edit.options,
+						}
+					case 'writeJson':
+						return {
+							kind: 'writeJson' as const,
+							path,
+							value: edit.value,
+							options:
+								typeof edit.options?.spaces === 'number'
+									? { spaces: edit.options.spaces }
+									: undefined,
+						}
+				}
+			}),
+		)
+		const result = await this.state.applyEditPlan(plan, {
+			dryRun: input.dryRun,
+			rollbackOnError: input.rollbackOnError,
+		})
+		return {
+			dryRun: result.dryRun,
+			totalChanged: result.totalChanged,
+			edits: result.edits.map((edit) => ({
+				path: toExternalRepoPath(edit.path, repoSessionWorkspacePrefix),
+				changed: edit.changed,
+				content: edit.content,
+				diff: edit.diff,
+			})),
+		}
+	}
+
 	async openSession(input: {
 		sessionId: string
 		sourceId: string
@@ -360,6 +450,111 @@
 		return toRepoSessionInfoResult(sessionRow, source)
 	}
 
+	async bootstrapSource(input: {
+		sessionId: string
+		sourceId: string
+		userId: string
+		edits: Array<{
+			kind: 'write' | 'replace' | 'writeJson'
+			path: string
+			content?: string
+			search?: string
+			replacement?: string
+			value?: unknown
+			options?: {
+				caseSensitive?: boolean
+				regex?: boolean
+				wholeWord?: boolean
+				contextBefore?: number
+				contextAfter?: number
+				maxMatches?: number
+				spaces?: number
+			}
+		}>
+	}): Promise<RepoSourceBootstrapResult> {
+		const source = await getEntitySourceById(this.env.APP_DB, input.sourceId)
+		if (!source) {
+			throw new Error(`Source "${input.sourceId}" was not found.`)
+		}
+		if (source.user_id !== input.userId) {
+			throw new Error(
+				`Source "${input.sourceId}" was not found for this user.`,
+			)
+		}
+		if (source.published_commit) {
+			throw new Error(
+				`Source "${source.id}" already has a published commit. Use repo sessions for later edits.`,
+			)
+		}
+		const sourceRepo = await resolveArtifactSourceRepo(this.env, source.repo_id)
+		const sourceInfo = await sourceRepo.info()
+		const sourceAccess = await ensureArtifactRepoRemote({
+			repo: sourceRepo,
+			scope: 'write',
+		})
+		const targetBranch = sourceInfo?.defaultBranch ?? defaultSessionBranch
+		await this.resetWorkspace()
+		await this.workspace.mkdir(repoSessionWorkspacePrefix, {
+			recursive: true,
+		})
+		await this.git.init({
+			dir: repoSessionWorkspacePrefix,
+			defaultBranch: targetBranch,
+		})
+		await this.ensureRemote({
+			name: 'source',
+			url: buildAuthenticatedArtifactsRemote({
+				remote: sourceAccess.remote,
+				token: sourceAccess.token,
+			}),
+		})
+		await this.applyWorkspaceEdits({
+			edits: input.edits,
+			dryRun: false,
+			rollbackOnError: true,
+		})
+		const publishedCommit = await this.commitIfDirty(
+			`Bootstrap source repo ${source.id}`,
+		)
+		if (!publishedCommit) {
+			throw new Error(`Source "${source.id}" bootstrap produced no commit.`)
+		}
+		await this.git.push({
+			dir: repoSessionWorkspacePrefix,
+			remote: 'source',
+			ref: targetBranch,
+			token: sourceAccess.token,
+			username: 'x',
+			password: sourceAccess.token.split('?expires=')[0] ?? sourceAccess.token,
+		})
+		const manifestContent = await this.workspace.readFile(
+			resolveRepoWorkspacePath(source.manifest_path, repoSessionWorkspacePrefix),
+		)
+		if (manifestContent == null) {
+			throw new Error(`Manifest "${source.manifest_path}" was not found.`)
+		}
+		const manifest = parseRepoManifest({
+			content: manifestContent,
+			manifestPath: source.manifest_path,
+		})
+		await updateEntitySource(this.env.APP_DB, {
+			id: source.id,
+			userId: source.user_id,
+			publishedCommit,
+			manifestPath: source.manifest_path,
+			sourceRoot: manifest.sourceRoot?.startsWith('/')
+				? manifest.sourceRoot
+				: manifest.sourceRoot
+					? `/${manifest.sourceRoot}`
+					: source.source_root,
+		})
+		return {
+			sessionId: input.sessionId,
+			publishedCommit,
+			message: `Bootstrapped source ${source.id} in ${source.repo_id}.`,
+		}
+	}
+
 	async getSessionInfo(input: { sessionId: string; userId: string }) {
 		const { sessionRow, source } = await this.getSessionState(
 			input.sessionId,
@@ -377,6 +572,7 @@
 			input.sessionId,
 		)
 		if (!sessionRow) {
+			await this.resetWorkspace()
 			return {
 				ok: true,
 				sessionId: input.sessionId,
@@ -389,14 +585,7 @@
 			)
 		}
 		await deleteRepoSession(this.env.APP_DB, input.sessionId)
-		try {
-			await this.workspace.rm(repoSessionWorkspacePrefix, {
-				force: true,
-				recursive: true,
-			})
-		} catch {
-			// Best effort only; the session row is the source of truth.
-		}
+		await this.resetWorkspace()
 		return {
 			ok: true,
 			sessionId: input.sessionId,
@@ -569,65 +758,13 @@
 			input.sessionId,
 			input.userId,
 		)
-		const plan = await this.state.planEdits(
-			input.edits.map((edit) => {
-				const path = resolveRepoWorkspacePath(
-					edit.path,
-					repoSessionWorkspacePrefix,
-				)
-				switch (edit.kind) {
-					case 'write':
-						if (typeof edit.content !== 'string') {
-							throw new Error('repo_apply_patch write edits require content.')
-						}
-						return {
-							kind: 'write' as const,
-							path,
-							content: edit.content,
-						}
-					case 'replace':
-						if (typeof edit.search !== 'string') {
-							throw new Error('repo_apply_patch replace edits require search.')
-						}
-						return {
-							kind: 'replace' as const,
-							path,
-							search: edit.search,
-							replacement: edit.replacement ?? '',
-							options: edit.options,
-						}
-					case 'writeJson':
-						return {
-							kind: 'writeJson' as const,
-							path,
-							value: edit.value,
-							options:
-								typeof edit.options?.spaces === 'number'
-									? { spaces: edit.options.spaces }
-									: undefined,
-						}
-				}
-			}),
-		)
-		const result = await this.state.applyEditPlan(plan, {
-			dryRun: input.dryRun,
-			rollbackOnError: input.rollbackOnError,
-		})
+		const result = await this.applyWorkspaceEdits(input)
 		await updateRepoSession(this.env.APP_DB, {
 			id: input.sessionId,
 			userId: sessionRow.user_id,
 			lastCheckpointAt: nowIso(),
 		})
-		return {
-			dryRun: result.dryRun,
-			totalChanged: result.totalChanged,
-			edits: result.edits.map((edit) => ({
-				path: toExternalRepoPath(edit.path, repoSessionWorkspacePrefix),
-				changed: edit.changed,
-				content: edit.content,
-				diff: edit.diff,
-			})),
-		}
+		return result
 	}
 
 	async runChecks(input: {

diff --git a/packages/worker/src/repo/repo-session-rpc.ts b/packages/worker/src/repo/repo-session-rpc.ts
--- a/packages/worker/src/repo/repo-session-rpc.ts
+++ b/packages/worker/src/repo/repo-session-rpc.ts
@@ -1,4 +1,5 @@
 import {
+	type RepoSourceBootstrapResult,
 	type RepoSearchMode,
 	type RepoSearchOutputMode,
 	type RepoSessionApplyEditsResult,
@@ -68,6 +69,12 @@
 		dryRun?: boolean
 		rollbackOnError?: boolean
 	}) => Promise<RepoSessionApplyEditsResult>
+	bootstrapSource: (payload: {
+		sessionId: string
+		sourceId: string
+		userId: string
+		edits: Array<RepoSessionEdit>
+	}) => Promise<RepoSourceBootstrapResult>
 	runChecks: (payload: {
 		sessionId: string
 		userId: string

diff --git a/packages/worker/src/repo/source-sync.node.test.ts b/packages/worker/src/repo/source-sync.node.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/worker/src/repo/source-sync.node.test.ts
@@ -1,0 +1,193 @@
+import { expect, test, vi } from 'vitest'
+
+const mockModule = vi.hoisted(() => ({
+	getEntitySourceById: vi.fn(),
+	repoSessionRpc: vi.fn(),
+}))
+
+vi.mock('./entity-sources.ts', () => ({
+	getEntitySourceById: (...args: Array<unknown>) =>
+		mockModule.getEntitySourceById(...args),
+}))
+
+vi.mock('./repo-session-do.ts', () => ({
+	repoSessionRpc: (...args: Array<unknown>) => mockModule.repoSessionRpc(...args),
+}))
+
+const { syncArtifactSourceSnapshot } = await import('./source-sync.ts')
+
+test('syncArtifactSourceSnapshot bootstraps unpublished sources directly into the source repo', async () => {
+	mockModule.getEntitySourceById.mockReset()
+	mockModule.repoSessionRpc.mockReset()
+
+	const sessionClient = {
+		bootstrapSource: vi.fn(async () => ({
+			sessionId: 'source-sync-source-1-session',
+			publishedCommit: 'commit-bootstrap-1',
+			message: 'Bootstrapped source source-1 in app-1.',
+		})),
+		openSession: vi.fn(),
+		applyEdits: vi.fn(),
+		publishSession: vi.fn(),
+		discardSession: vi.fn(async () => ({
+			ok: true as const,
+			sessionId: 'source-sync-source-1-session',
+			deleted: false,
+		})),
+	}
+
+	mockModule.getEntitySourceById.mockResolvedValue({
+		id: 'source-1',
+		user_id: 'user-1',
+		entity_kind: 'app',
+		entity_id: 'app-1',
+		repo_id: 'app-1',
+		published_commit: null,
+		indexed_commit: null,
+		manifest_path: 'kody.json',
+		source_root: '/',
+		created_at: '2026-04-18T00:00:00.000Z',
+		updated_at: '2026-04-18T00:00:00.000Z',
+	})
+	mockModule.repoSessionRpc.mockReturnValue(sessionClient as never)
+
+	const publishedCommit = await syncArtifactSourceSnapshot({
+		env: {
+			APP_DB: {
+				prepare() {
+					return {} as D1PreparedStatement
+				},
+			},
+			REPO_SESSION: {},
+			CLOUDFLARE_ACCOUNT_ID: 'account-1',
+			CLOUDFLARE_API_TOKEN: 'token-1',
+		} as unknown as Env,
+		userId: 'user-1',
+		baseUrl: 'https://heykody.dev',
+		sourceId: 'source-1',
+		files: {
+			'kody.json': '{"version":1,"kind":"app"}',
+			'client.html': '<main>Hello</main>',
+		},
+	})
+
+	expect(publishedCommit).toBe('commit-bootstrap-1')
+	expect(sessionClient.bootstrapSource).toHaveBeenCalledWith({
+		sessionId: expect.stringMatching(/^source-sync-source-1-/),
+		sourceId: 'source-1',
+		userId: 'user-1',
+		edits: [
+			{
+				kind: 'write',
+				path: 'kody.json',
+				content: '{"version":1,"kind":"app"}',
+			},
+			{
+				kind: 'write',
+				path: 'client.html',
+				content: '<main>Hello</main>',
+			},
+		],
+	})
+	expect(sessionClient.openSession).not.toHaveBeenCalled()
+	expect(sessionClient.applyEdits).not.toHaveBeenCalled()
+	expect(sessionClient.publishSession).not.toHaveBeenCalled()
+	expect(sessionClient.discardSession).toHaveBeenCalledWith({
+		sessionId: expect.stringMatching(/^source-sync-source-1-/),
+		userId: 'user-1',
+	})
+})
+
+test('syncArtifactSourceSnapshot still uses repo sessions for already-published sources', async () => {
+	mockModule.getEntitySourceById.mockReset()
+	mockModule.repoSessionRpc.mockReset()
+
+	const sessionClient = {
+		bootstrapSource: vi.fn(),
+		openSession: vi.fn(async () => ({
+			id: 'source-sync-source-1-session',
+		})),
+		applyEdits: vi.fn(async () => ({
+			dryRun: false,
+			totalChanged: 1,
+			edits: [],
+		})),
+		publishSession: vi.fn(async () => ({
+			status: 'ok' as const,
+			sessionId: 'source-sync-source-1-session',
+			publishedCommit: 'commit-session-2',
+			message: 'Published session source-sync-source-1-session to app-1.',
+		})),
+		discardSession: vi.fn(async () => ({
+			ok: true as const,
+			sessionId: 'source-sync-source-1-session',
+			deleted: true,
+		})),
+	}
+
+	mockModule.getEntitySourceById.mockResolvedValue({
+		id: 'source-1',
+		user_id: 'user-1',
+		entity_kind: 'app',
+		entity_id: 'app-1',
+		repo_id: 'app-1',
+		published_commit: 'commit-existing-1',
+		indexed_commit: 'commit-existing-1',
+		manifest_path: 'kody.json',
+		source_root: '/',
+		created_at: '2026-04-18T00:00:00.000Z',
+		updated_at: '2026-04-18T00:00:00.000Z',
+	})
+	mockModule.repoSessionRpc.mockReturnValue(sessionClient as never)
+
+	const publishedCommit = await syncArtifactSourceSnapshot({
+		env: {
+			APP_DB: {
+				prepare() {
+					return {} as D1PreparedStatement
+				},
+			},
+			REPO_SESSION: {},
+			CLOUDFLARE_ACCOUNT_ID: 'account-1',
+			CLOUDFLARE_API_TOKEN: 'token-1',
+		} as unknown as Env,
+		userId: 'user-1',
+		baseUrl: 'https://heykody.dev',
+		sourceId: 'source-1',
+		files: {
+			'kody.json': '{"version":1,"kind":"app"}',
+		},
+	})
+
+	expect(publishedCommit).toBe('commit-session-2')
+	expect(sessionClient.bootstrapSource).not.toHaveBeenCalled()
+	expect(sessionClient.openSession).toHaveBeenCalledWith({
+		sessionId: expect.stringMatching(/^source-sync-source-1-/),
+		sourceId: 'source-1',
+		userId: 'user-1',
+		baseUrl: 'https://heykody.dev',
+		sourceRoot: '/',
+	})
+	expect(sessionClient.applyEdits).toHaveBeenCalledWith({
+		sessionId: expect.stringMatching(/^source-sync-source-1-/),
+		userId: 'user-1',
+		edits: [
+			{
+				kind: 'write',
+				path: 'kody.json',
+				content: '{"version":1,"kind":"app"}',
+			},
+		],
+		dryRun: false,
+		rollbackOnError: true,
+	})
+	expect(sessionClient.publishSession).toHaveBeenCalledWith({
+		sessionId: expect.stringMatching(/^source-sync-source-1-/),
+		userId: 'user-1',
+		force: true,
+	})
+	expect(sessionClient.discardSession).toHaveBeenCalledWith({
+		sessionId: expect.stringMatching(/^source-sync-source-1-/),
+		userId: 'user-1',
+	})
+})

diff --git a/packages/worker/src/repo/source-sync.ts b/packages/worker/src/repo/source-sync.ts
--- a/packages/worker/src/repo/source-sync.ts
+++ b/packages/worker/src/repo/source-sync.ts
@@ -34,7 +34,21 @@
 	if (!source) return null
 	const sessionId = buildSyncSessionId(source.id)
 	const session = repoSessionRpc(input.env, sessionId)
+	const edits = Object.entries(input.files).map(([path, content]) => ({
+		kind: 'write' as const,
+		path,
+		content,
+	}))
 	try {
+		if (!source.published_commit) {
+			const bootstrapResult = await session.bootstrapSource({
+				sessionId,
+				sourceId: source.id,
+				userId: input.userId,
+				edits,
+			})
+			return bootstrapResult.publishedCommit
+		}
 		await session.openSession({
 			sessionId,
 			sourceId: source.id,
@@ -45,11 +59,7 @@
 		await session.applyEdits({
 			sessionId,
 			userId: input.userId,
-			edits: Object.entries(input.files).map(([path, content]) => ({
-				kind: 'write' as const,
-				path,
-				content,
-			})),
+			edits,
 			dryRun: false,
 			rollbackOnError: true,
 		})

diff --git a/packages/worker/src/repo/types.ts b/packages/worker/src/repo/types.ts
--- a/packages/worker/src/repo/types.ts
+++ b/packages/worker/src/repo/types.ts
@@ -326,6 +326,12 @@
 			publishedCommit: null
 	  }
 
+export type RepoSourceBootstrapResult = {
+	sessionId: string
+	publishedCommit: string
+	message: string
+}
+
 export type RepoSessionRebaseResult = {
 	ok: true
 	sessionId: string

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 9feeb84. Configure here.

Comment thread packages/worker/src/repo/repo-session-do.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@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: 1

♻️ Duplicate comments (1)
packages/worker/src/repo/repo-session-do.ts (1)

516-539: ⚠️ Potential issue | 🟠 Major

Validate the manifest before pushing to the source remote.

Line 522 pushes before Lines 530-539 read and parse the manifest. If parsing fails, the source remote already contains the commit while entity_sources.published_commit stays null, making retries fragile.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/repo/repo-session-do.ts` around lines 516 - 539, The code
pushes the commit (git.push in this block) before reading/parsing the manifest,
which can leave the remote updated if parseRepoManifest or workspace.readFile
fails and entity_sources.published_commit remains null; move the manifest
validation earlier: call workspace.readFile and parseRepoManifest (using
resolveRepoWorkspacePath and parseRepoManifest) and ensure manifestContent is
non-null and parse succeeds before calling this.git.push, or alternatively delay
the push until after you successfully set entity_sources.published_commit (i.e.,
validate manifest after commitIfDirty but before this.git.push) so failures
don’t leave a pushed but unrecorded published_commit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/src/repo/repo-session-do.ts`:
- Around line 173-181: The current resetWorkspace() swallows fs.rm errors which
allows stale workspace files (including .git created by bootstrapSource()) to
persist and cause initialize() to skip cloning; change resetWorkspace() to treat
workspace removal as a hard precondition by propagating or throwing errors on
failure (do not catch-and-ignore), ensure bootstrapSource() explicitly removes
any direct-source .git under the session if present, and add a guaranteed
cleanup step after bootstrapSource() (or before initialize()) that
retries/validates removal of repoSessionWorkspacePrefix and the .git/config file
so initialize() always sees a clean workspace; refer to resetWorkspace(),
bootstrapSource(), and initialize() when making these changes.

---

Duplicate comments:
In `@packages/worker/src/repo/repo-session-do.ts`:
- Around line 516-539: The code pushes the commit (git.push in this block)
before reading/parsing the manifest, which can leave the remote updated if
parseRepoManifest or workspace.readFile fails and
entity_sources.published_commit remains null; move the manifest validation
earlier: call workspace.readFile and parseRepoManifest (using
resolveRepoWorkspacePath and parseRepoManifest) and ensure manifestContent is
non-null and parse succeeds before calling this.git.push, or alternatively delay
the push until after you successfully set entity_sources.published_commit (i.e.,
validate manifest after commitIfDirty but before this.git.push) so failures
don’t leave a pushed but unrecorded published_commit.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 751f141c-7107-418d-9939-5b836cf14c58

📥 Commits

Reviewing files that changed from the base of the PR and between 9feeb84 and 4e2e7cf.

📒 Files selected for processing (1)
  • packages/worker/src/repo/repo-session-do.ts

Comment thread packages/worker/src/repo/repo-session-do.ts
cursoragent and others added 2 commits April 18, 2026 03:26
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds
kentcdodds merged commit 49ee256 into main Apr 18, 2026
9 checks passed
@kody-bot
kody-bot deleted the cursor/first-publish-bootstrap-35df branch April 30, 2026 23:15
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