Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/topic-bind-project-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/the-framework": minor
---

feat(the-framework): let a project-less topic run bind to a project via an await gate, with the registered projects injected into its context as the list to pick from (#1121)
108 changes: 100 additions & 8 deletions packages/the-framework/src/await-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
BROWSER_NOT_HANDLED,
CONFIRM_APPROVED,
CONFIRM_DECLINED,
CREATE_PROJECT_APPROVE,
CREATE_PROJECT_DECLINE,
MAX_AWAIT_ROUNDS,
NO_PROJECTS_TO_BIND,
PLAN_DECLINED_MESSAGE,
continuationPrompt,
isDeclinedConfirmation,
Expand All @@ -20,6 +23,45 @@ import type { RunMessages } from './run-messages.js'
// what removed the run <-> todo-loop cycle. run.ts composes these primitives into a lifecycle;
// it does not own them.

/** One project the bind gate (#1121) can offer or register: the minimal fields the resolver needs. */
export interface BindProjectChoice {
id: string
path: string
}

/**
* The outcome of an `await-create-project` registration (#1121). A result rather than a throw so an
* unusable path (relative, missing, not a directory) declines cleanly back to the agent instead of
* crashing the run; `created` is false when the path was already registered, so the gate can say so.
*/
export type AddProjectResult =
| { ok: true; project: BindProjectChoice; created: boolean }
| { ok: false; error: string }

/**
* The registry + recording seams a bind gate (#1121) resolves against, injected so the await
* machinery stays orchestrator-free: the CLI wires these to the real registry + control channel,
* a test to spies. Present only for a project-less topic run (#1120); absent for every other run.
*/
export interface BindProjectDeps {
/** The projects an `await-bind-project` gate offers, from the registry. */
listProjects: () => Promise<BindProjectChoice[]>
/** Validate + register (idempotent) a project by path, for an `await-create-project` gate. */
addProject: (path: string) => Promise<AddProjectResult>
/** Record the bind onto the run once a project is picked or created (#1121). */
recordBind: (projectId: string) => void
}

/** The gate-id stems by kind, so a POST-back is matched to the gate that asked (round 0 keeps it). */
const GATE_BASE_ID: Record<ParsedAwaitGate['kind'], string> = {
choices: 'await-choices',
multi: 'await-multiselect',
confirm: 'await-confirmation',
browser: 'await-browser',
'bind-project': 'await-bind-project',
'create-project': 'await-create-project',
}

/**
* Resolve one parsed await gate (#337/#339) to the user's answer text, ready to
* seed the continuation prompt: emits the `choice`, parks for the pick (or the
Expand All @@ -35,18 +77,13 @@ export async function resolveAwaitGate(
requestChoice?: ((req: ChoiceRequest) => Promise<ChoicePick>) | undefined
emit: (event: FrameworkEvent) => void
signal?: AbortSignal | undefined
/** The bind seams for a topic run (#1121); absent for every other run. */
bind?: BindProjectDeps | undefined
},
): Promise<string> {
const signalOpt = deps.signal ? { signal: deps.signal } : {}
const choiceOpt = deps.requestChoice ? { requestChoice: deps.requestChoice } : {}
const baseId =
gate.kind === 'multi'
? 'await-multiselect'
: gate.kind === 'confirm'
? 'await-confirmation'
: gate.kind === 'browser'
? 'await-browser'
: 'await-choices'
const baseId = GATE_BASE_ID[gate.kind]
const id = round === 0 ? baseId : `${baseId}-${round}`
if (gate.kind === 'browser') {
// The agent is stuck on a page and needs a human to act on it (#796). Rides the same
Expand Down Expand Up @@ -94,6 +131,56 @@ export async function resolveAwaitGate(
const labels = gate.options.filter(o => picked.includes(o.id)).map(o => o.label)
return labels.length ? labels.join(', ') : '(none)'
}
if (gate.kind === 'bind-project') {
// A project-less topic run (#1120) picks from the registered projects (#1121). The framework
// fills the options from the registry, not the agent's block, so it can never offer one that
// is gone. With no registry wired or nothing registered, there is nothing to bind to.
const projects = deps.bind ? await deps.bind.listProjects() : []
if (!deps.bind || projects.length === 0) return NO_PROJECTS_TO_BIND
const picked = await requestChoices({
id,
title: gate.title,
options: projects.map(p => ({ id: p.id, label: p.path })),
emit: deps.emit,
...choiceOpt,
...signalOpt,
})
// Defensive: requestChoices coerces any invalid pick back to a valid registry id, so this miss
// is unreachable in practice. It stays as a safety net rather than an assumed-present lookup.
const project = projects.find(p => p.id === picked)
if (!project) return NO_PROJECTS_TO_BIND
// #1122 re-homes the run into the bound project's worktree; here the bind is only recorded.
deps.bind.recordBind(project.id)
return `Bound this run to ${project.path}.`
}
if (gate.kind === 'create-project') {
// Registering a path grants the app filesystem access to it, so the confirmation IS the grant:
// recommended Approve, like the plan gate (#358). Declining just keeps the run project-less.
const picked = await requestChoices({
id,
title: gate.path ? `${gate.title} (${gate.path})` : gate.title,
options: [
{ id: 'approve', label: CREATE_PROJECT_APPROVE },
{ id: 'decline', label: CREATE_PROJECT_DECLINE },
],
recommended: 'approve',
confirm: true,
emit: deps.emit,
...choiceOpt,
...signalOpt,
})
if (picked === 'decline' || !deps.bind) return 'You chose not to register a project; continue without one.'
if (!gate.path) return 'No path was given, so nothing was registered; continue without a project, or bind to a registered one.'
// The seam validates the path (absolute, exists, is a directory) and never throws: an unusable
// path declines back to the agent, so a bad path can never crash the run.
const result = await deps.bind.addProject(gate.path)
if (!result.ok) return `Could not register that project: ${result.error}. Continue without a project, or give an existing absolute repo path.`
// #1122 re-homes the run into the bound project's worktree; here the bind is only recorded.
deps.bind.recordBind(result.project.id)
return result.created
? `Registered and bound this run to ${result.project.path}.`
: `That project was already registered; bound this run to ${result.project.path}.`
}
const pickedId = await requestChoices({
id,
title: gate.title,
Expand Down Expand Up @@ -143,6 +230,8 @@ export interface AwaitRoundsOptions {
* persisting must never stall or fail a run. Unset for a headless run, which has no chat.
*/
recordMessage?: RecordMessage | undefined
/** The bind seams for a project-less topic run (#1121); absent for every other run. */
bind?: BindProjectDeps | undefined
}

/**
Expand All @@ -160,6 +249,8 @@ export interface AwaitTurnDeps {
emitTurnSignals: (text: string) => void
signal?: AbortSignal | undefined
recordMessage?: RecordMessage | undefined
/** The bind seams for a project-less topic run (#1121); absent for every other run. */
bind?: BindProjectDeps | undefined
}

/**
Expand Down Expand Up @@ -256,6 +347,7 @@ export async function runAwaitRounds(opts: AwaitRoundsOptions): Promise<AwaitRou
emitTurnSignals,
signal: opts.signal,
recordMessage: opts.recordMessage,
bind: opts.bind,
}
const signalOpt = opts.signal ? { signal: opts.signal } : {}

Expand Down
38 changes: 35 additions & 3 deletions packages/the-framework/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ import { loadUserSystemPrompt, SYSTEM_PROMPT_FILE } from './system-prompt-file.j
import { checkForUpdate, formatUpdateStatus, nodeVersionFetcher, type VersionFetcher } from './update-check.js'
import { appendLog, type LogEntry } from './logs.js'
import { appendMessage, isSafeVia } from './conversations.js'
import type { RecordMessage } from './await-gate.js'
import type { BindProjectDeps, RecordMessage } from './await-gate.js'
import { preflight } from './preflight.js'
import { RunStore, commitPendingWork, currentBranch, nodeStoreFs, renameRunBranch, runBranchName, type StoreFs } from './store/index.js'
import { materializePresets } from './presets.js'
import { daemonStatus, ensureDaemon, isLoopbackHost, registerHomeProject, runDaemon, stopDaemon, DEFAULT_DAEMON_HOST, DEFAULT_DAEMON_PORT } from './daemon.js'
import { resetControl, watchControl, type ControlWatcher } from './control.js'
import { appendControl, resetControl, watchControl, type ControlWatcher } from './control.js'
import { RunMessageQueue } from './run-messages.js'
import { nodeGitRunner } from './project.js'
import { ensureDaemonToken, listProjects, readDaemonToken, readPreferences } from './registry.js'
import { addProject, ensureDaemonToken, listProjects, readDaemonToken, readPreferences, resolveProjectPath } from './registry.js'
import { startConsumptionGuard } from './consumption-guard.js'
import {
planMaintenanceSweep,
Expand Down Expand Up @@ -1431,6 +1431,10 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise<number> {
// Assigned once the journal exists, which is after the control watcher is wired: a change that
// arrives before then still lands on `armedHandoff`, it just has no event to announce it yet.
let announceHandoff: ((push: boolean, pr: boolean) => void) | undefined
// Same deferral as announceHandoff: an await-bind-project / await-create-project gate binds this
// topic run to a project (#1121), and only an event puts that on the meta a later-opened tab can
// read. Wired once the journal exists (below).
let recordBind: ((projectId: string) => void) | undefined

let control: ControlWatcher | undefined
if (isSteerable(opts, newDashboard, await daemonStatus() !== undefined)) {
Expand All @@ -1452,6 +1456,11 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise<number> {
announceHandoff?.(armedHandoff.push, armedHandoff.pr)
return
}
if (entry.kind === 'bind') {
// #1122 re-homes the run into the bound project's worktree; here we only record it.
recordBind?.(entry.projectId)
return
}
const resolve = pendingChoices.get(entry.id)
if (resolve) {
pendingChoices.delete(entry.id)
Expand Down Expand Up @@ -1527,6 +1536,8 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise<number> {
// only thing a dashboard tab opened mid-run can read the boxes back from.
announceHandoff = (push, pr) => onEvent({ kind: 'handoff-armed', push, pr })
announceHandoff(armedHandoff.push, armedHandoff.pr)
// Wired now the journal exists: a bind resolved from a topic run's gate folds to meta (#1121).
recordBind = projectId => onEvent({ kind: 'bind', projectId })

// Fire the #326 on-before-mergeable prompt once a --on-before-mergeable run has settled and the agent
// signalled setReadyForMerge(). Skipped for a fake/offline run and when the run was stopped —
Expand Down Expand Up @@ -1709,11 +1720,32 @@ async function runBuild(opts: CliOptions, io: CliIO): Promise<number> {
// it reads, and who can answer it. They were written out twice, thirteen conditional spreads
// each, so a new one had to be added to both by hand — and a run started as a build and a run
// started as a prompt are the same run in every respect but the scaffolding around the prompt.
// A project-less topic run (#1120) can bind to a project mid-run via an await gate (#1121): the
// resolver lists / registers projects against the real registry and signals the bind over the
// control channel, which the watcher above folds to the run's meta. Topic runs only.
const bindDeps: BindProjectDeps | undefined = opts.topic
? {
listProjects: () => listProjects(),
addProject: async path => {
// Validate before touching the registry: a relative / missing / non-directory path
// declines cleanly back to the agent rather than landing junk in the home file (#1121).
const resolved = await resolveProjectPath(path)
if (!resolved.ok) return { ok: false, error: resolved.error }
const already = (await listProjects()).some(p => p.path === resolved.path)
const record = await addProject(resolved.path, new Date().toISOString())
return { ok: true, project: { id: record.id, path: record.path }, created: !already }
},
recordBind: projectId => void appendControl(cwd, { kind: 'bind', projectId }),
}
: undefined

const sharedRunOptions = {
driver,
cwd,
onEvent,
signal: controller.signal,
...(opts.topic ? { topic: true } : {}),
...(bindDeps ? { bind: bindDeps } : {}),
...(requestChoice ? { requestChoice } : {}),
...chatQueue,
...(recordMessage ? { recordMessage } : {}),
Expand Down
8 changes: 8 additions & 0 deletions packages/the-framework/src/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export type ControlEntry =
* event, which is what puts it on the meta the checkboxes read.
*/
| { kind: 'handoff'; push: boolean; pr: boolean }
/**
* Bind a project-less topic run to a project (#1121): the await-gate resolver appends this once
* it registers + binds the picked project, and the run folds `projectId` onto its meta. The
* worktree re-home this implies is #1122.
*/
| { kind: 'bind'; projectId: string }

/** The control log path for a workspace. */
export function controlPath(cwd: string): string {
Expand Down Expand Up @@ -93,6 +99,8 @@ function isControlEntry(value: unknown): value is ControlEntry {
// Both halves must be real booleans: a half-written entry would otherwise disarm by accident,
// and this decides whether the session's work reaches the remote at all.
if (v['kind'] === 'handoff') return typeof v['push'] === 'boolean' && typeof v['pr'] === 'boolean'
// A bind needs a non-empty projectId; it decides which project the run re-homes into (#1121).
if (v['kind'] === 'bind') return typeof v['projectId'] === 'string' && v['projectId'].length > 0
// `via` is optional (older entries have none), but a present one must be a safe transport name:
// it is written into a line-parsed conversation heading, and a surface names itself (#917).
if (v['kind'] === 'message') {
Expand Down
7 changes: 7 additions & 0 deletions packages/the-framework/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ export type FrameworkEvent =
* a status that only changes when the run ends.
*/
| { kind: 'settled' }
/**
* A project-less topic run (#1120) bound itself to a project (#1121): the agent ended a turn on
* an `await-bind-project` / `await-create-project` gate, which the framework resolved by
* registering + binding the project. Recorded so the run's meta names the project it re-homes
* into (#1122).
*/
| { kind: 'bind'; projectId: string }
/**
* Cumulative token + cost usage for the run so far (#322). Emitted after each
* agent turn that reports usage; the dashboard renders a live spend readout and
Expand Down
Loading
Loading