Workspace-less Forgejo conversation sessions for repos without a workspace #208
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#208
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
When a Forgejo webhook arrives for a repository that matches no omp-deck workspace, the dispatcher posts
NOT_CONNECTED("This repository isn't connected to an omp-deck workspace…") and drops the trigger (apps/server/src/forgejo/dispatcher.ts, gate aftermatchWorkspacesat ~652). That rules out repos that are only discussion spaces and have no code: nobody can talk to the agent there.Wanted: sessions without a workspace. A trigger from an unmatched repo starts a session that:
forgejo_conversation, read-only plus comment and create-issue tools);Scope:
CONVERSATION_REASONS), on issues and PRs alike.NOT_CONNECTED: review requests, auto-review, CI, pushes, labels, intake, merges, and anything on a PR the agent authored.AMBIGUOUS, unchanged.Plan
Context
Today a trigger session's Forgejo repo binding comes from its cwd's git
origin:buildForgejoTools(cwd)(apps/server/src/forgejo/tools/index.ts:66) derives the repo that way.assertTriggerRepo(apps/server/src/bridge/session-policy.ts:633) refuses the session if the derived repo doesn't match the trigger scope.An empty dir has no origin, so workspace-less sessions must bind Forgejo tools to the trigger scope's
owner/repoexplicitly. They also need:git_*tools.A repo with no
.omp/forgejo.yamlalready works:readRepoFilereturnsdata: nullon 404 (apps/server/src/forgejo/dispatch-api.ts:455).Approach
Steps 1–3 are independent of each other. Step 4 depends on 1–3, and step 5 depends on 4.
Mark workspace-less conversation scopes (
apps/server/src/bridge/session-policy.ts)Add an optional field to
ForgejoConversationScope. Its doc comment should say: "Set for sessions of a repository that matches no workspace: the session runs in an empty directory (no code, no git) and its Forgejo tools are bound toowner/repoinstead of the cwd's origin. Absent on every other session."The field is persisted automatically, because
insertSessionTriggerstoresJSON.stringify(trigger.scope)andgetSessionTriggerreturns the parsed object as-is (apps/server/src/db/session-triggers.ts). No migration is needed.Add
const CONVERSATION_NO_CHECKOUT_TOOL_NAMES = CONVERSATION_TOOL_NAMES.filter((n) => !n.startsWith("git_"));.policyToolNames,forgejo_conversationcase: returnCONVERSATION_NO_CHECKOUT_TOOL_NAMESwhentrigger.scope.noCheckout, elsetoolNamesas today.forgejoConversationPolicy.decide: before the finalthis.toolNames.includescheck, addThe read-tool confinement stays as is: with cwd being the empty dir,
readstill reaches http(s)/local:///artifact://.forgejoConversationPolicy.systemContext: whenscope.noCheckout, replaceREAD_ACCESS_BULLETwith a new constantNO_CHECKOUT_BULLET:Also change the opening sentence's "comment or a label change" to "comment" in that case, since labels never start these sessions.
repoMismatchneeds no change. Step 2 makes the bound repo equal to the scope.Bind Forgejo tools to an explicit repo (
apps/server/src/forgejo/tools/index.ts)buildForgejoTools(buildingctx, visibility, tools, system context) into: It readsreadForgejoConfig()itself and returnsEMPTYwhen that is unset, with the same debug log and try/catch →EMPTY.buildForgejoTools(cwd, signal)becomes: config check,resolveRepoFromCwd, thenbuildForgejoToolsForRepo(repo, cwd, signal).Empty checkout dir (
apps/server/src/forgejo/checkout.ts)dest = checkoutPath(...).fs.promises.rm(dest, { recursive: true, force: true }), thenmkdir(dest, { recursive: true }), then returndest. This replaces a leftover dir, mirroringcreateCheckout.checkout ${dir} is not at ${expected}whenpath.resolve(dir) !== checkoutPath(...), likerestoreCheckout.mkdir(dir, { recursive: true }), leaving existing contents alone.removeCheckout(on session delete inroutes.ts:216and archive insession-archive.ts:20) cleans these up with no changes.Bridge create/resume (
apps/server/src/bridge/in-process.ts){ owner, repo }from the scope whentrigger?.kind === "forgejo_conversation" && trigger.scope.noCheckout, elsenull.createSession(~280): ifnoCheckoutRepo(opts.trigger ?? null)is non-null, callbuildForgejoToolsForRepo(thatRepo, opts.cwd); otherwise callbuildForgejoTools(opts.cwd).assertTriggerRepostays and now passes.buildGitTools(opts.cwd, forgejoCtx)is still constructed. The restricted tool list frompolicyToolNamesexcludesgit_*, so they are never registered.resumeSession(~385): before the existingif (trigger && placement?.checkoutPath)block, handle workspace-less sessions:trigger?.kind === "forgejo_conversation" && trigger.scope.noCheckout && placement?.checkoutPath.restoreEmptyCheckout(placement.checkoutPath, { owner, repo, number: trigger.scope.number, kind: trigger.scope.isPull ? "pr" : "issue" })and skip the workspace lookup, which today throws "belongs to a workspace that no longer exists".else if.noCheckoutReposwitch as create.placement.workspaceIdisnull, sobuildPortainerTools(null)already yields no tools. Nothing to change.Dispatcher fallback (
apps/server/src/forgejo/dispatcher.ts)Ready.workspace: change it toWorkspace | null. Update its doc comment ("matched to one workspace, or to none for a workspace-less conversation").agentPras today.workspaces.length === 0, continue only ifCONVERSATION_REASONS.has(reason) && !(row.isPull && agentPr).NOT_CONNECTEDnotice and the"repository is not connected to a workspace"drop.workspace: workspaces[0] ?? nullinto the returnedReady(~725).othersPrHeadblock (~700): skip it whenworkspaces.length === 0, leavingothersPrHead = null. With no checkout there is nothing to push from, and skipping also avoids failing on config reads.resolveSession(~1700), whenready.workspace === null:prHeadis always null here because agent PRs were excluded at the gate.cwd = await createEmptyCheckout({ owner, repo, kind: row.isPull ? "pr" : "issue", number: row.number }), keeping the samecould not prepare a checkout:error wrapping.createSession({ cwd, trigger, placement: { workspaceId: null, checkoutPath: cwd } }). RunremoveCheckout(cwd)on failure, as today.Forgejo: owner/repo#n) is unchanged.resolveBranchWatchSession: it only runs for branch reasons, which never reach it with a null workspace. Add a guard that throws"branch watch needs a workspace"whenready.workspace === null, to satisfy the type.reuseMapped(~1820), after loadingtrigger:trigger.kind === "forgejo_conversation" && trigger.scope.noCheckout && ready.workspace !== null, return"the repository is now connected to a workspace". The caller logs it and creates a normal session.getSessionPlacement(sessionId)?.checkoutPath ?? ready.workspace?.path. If both are null, return"it has no checkout".ready.workspaceuses the compiler flags the same way: null means the workspace-less path.Critical files & anchors
apps/server/src/forgejo/dispatcher.ts: thematchWorkspacesgate (~652),Ready(~356),resolveSession(~1677),reuseMapped(~1816).apps/server/src/bridge/in-process.ts:createSession(~273) andresumeSession(~370–430), the tool binding and checkout restore.apps/server/src/bridge/session-policy.ts:ForgejoConversationScope,forgejoConversationPolicy,policyToolNames.apps/server/src/forgejo/tools/index.ts:buildForgejoTools.apps/server/src/forgejo/dispatcher.test.ts: the"no workspace: the notice is posted once…"test (~1194) encodes the old behaviour for a mention and must be rewritten.Verification
Run from the repo root with
bun test(or the package's test script).apps/server/src/forgejo/dispatcher.test.tsh.workspaces = []and a mention on issue #4 is enqueued. Expect:NOT_CONNECTEDis posted;h.sessions[0].optshascwd === checkoutPath("lucas","omp-deck","issue",4);placementis{ workspaceId: null, checkoutPath: cwd };triggeris{ kind: "forgejo_conversation", scope: { owner: "lucas", repo: "omp-deck", number: 4, isPull: false, noCheckout: true } }.kind: "forgejo_conversation",isPull: true,noCheckout: true, cwd atpr-N."repository is not connected to a workspace"andNOT_CONNECTEDposted once. Keep the "posted once" assertion there.#4(insertSessionTriggerwithnoCheckout: true, placement{ workspaceId: null, checkoutPath }, plussetForgejoSession). Then seth.workspaces = [workspace("deck")]and enqueue a mention on #4. Expect:createCheckoutwithplacement.workspaceId === ws.id;noCheckout;Session-policy tests (
apps/server/src/bridge/session-policy.test.ts, if present; else add cases next to existing policy tests)policyToolNamesfor anoCheckoutconversation trigger contains nogit_*name and still containsforgejo_create_issue_comment.decidePolicy(trigger, "git_status", {}, id, cwd)→{ deny: "this session has no checkout" }.policySystemContextcontainsNO_CHECKOUT_BULLETand notREAD_ACCESS_BULLET.checkout.test.tscreateEmptyCheckoutreplaces a leftover dir that has files with an empty dir.restoreEmptyCheckoutrecreates a missing dir and throws for a path not atcheckoutPath(...).Manual (needs a running deck with Forgejo configured; the user's environment)
orphanrendering inapps/web/src/components/Sidebar.tsxhandles a cwd that matches no workspace, so no UI change is needed.Assumptions & contingencies
apps/server/src/bridge/session-policy.test.tsdoes not exist, put the policy cases in whichever test file currently coverspolicyToolNames/decidePolicy(find it withgrep -l decidePolicy apps/server/src). If none exists, createsession-policy.test.ts.CONVERSATION_REASONS(e.g. as a review submission), choose a fixture whosedetectresult is"mention". The rule stays: onlyCONVERSATION_REASONSfall back.