mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #681 from rowboatlabs/feature/apps-bundled-agent-skill
Apps: ship bundled agents that actually work for installers
This commit is contained in:
commit
98fe9106cb
7 changed files with 239 additions and 17 deletions
|
|
@ -1646,6 +1646,9 @@ export function setupIpcHandlers() {
|
|||
const preview = appInstallPreviews.get(args.name) ?? await appsInstaller.previewInstall(record);
|
||||
const result = await appsInstaller.installFromRegistry(record, preview);
|
||||
appInstallPreviews.delete(args.name);
|
||||
// Materialize bundled agents NOW, not on the next apps:list poll — the
|
||||
// renderer's post-install enable dialog patches these tasks immediately.
|
||||
if (result.app) await appsAgents.syncAppAgents(result.app);
|
||||
capture('app_installed', { name: args.name });
|
||||
return result;
|
||||
},
|
||||
|
|
@ -1654,6 +1657,7 @@ export function setupIpcHandlers() {
|
|||
return appsInstaller.previewUrlInstall(args.url);
|
||||
}
|
||||
const result = await appsInstaller.confirmUrlInstall(args.url);
|
||||
if (result.app) await appsAgents.syncAppAgents(result.app);
|
||||
capture('app_installed', { name: result.app.manifest?.name ?? result.app.folder });
|
||||
return result;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -272,6 +272,7 @@ export function AppDetail({ folder, onClose }: { folder: string; onClose: () =>
|
|||
<PublishDialog
|
||||
folder={folder}
|
||||
appName={manifest?.name ?? folder}
|
||||
published={!!app.publish}
|
||||
onClose={() => setShowPublish(false)}
|
||||
onPublished={() => setReloadNonce((n) => n + 1)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -186,6 +186,11 @@ export function AppsView({ initialAppFolder, initialVersion, onNewApp }: {
|
|||
const r = await window.ipc.invoke('apps:list', {})
|
||||
if (cancelled) return
|
||||
setApps(r.apps)
|
||||
// Drop a selection whose app no longer exists (uninstalled while
|
||||
// open). Left stale, a later reinstall makes this view yank the user
|
||||
// into the app frame mid-flow — e.g. while they're in the catalog's
|
||||
// post-install agent dialog.
|
||||
setSelectedFolder((cur) => (cur && !r.apps.some((a) => a.folder === cur) ? null : cur))
|
||||
setServerError(r.serverRunning ? null : (r.serverError ?? 'Apps server is not running.'))
|
||||
} catch (e) {
|
||||
if (!cancelled) setServerError(e instanceof Error ? e.message : String(e))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
|
||||
import { BadgeCheck, Bot, Download, Link2, RefreshCw, Search, ShieldAlert } from 'lucide-react'
|
||||
import type { rowboatApp } from '@x/shared'
|
||||
|
||||
// Catalog tab (spec §14): search the registry, install with the D18 capability
|
||||
|
|
@ -74,6 +74,81 @@ function InstallConfirmDialog({ preview, busy, onConfirm, onCancel }: {
|
|||
)
|
||||
}
|
||||
|
||||
type ModelChoice = { provider: string; model: string }
|
||||
|
||||
/** Post-install opt-in (§8.3): bundled agents land disabled; without this
|
||||
* prompt a fresh installer opens an empty app with no hint that the refresher
|
||||
* exists, buried in bg-tasks. The model picker defaults to the host-pinned
|
||||
* model but lets the user override before the first run. */
|
||||
function EnableAgentsDialog({ appName, names, defaultModel, busy, onEnable, onSkip }: {
|
||||
appName: string
|
||||
names: string[]
|
||||
defaultModel?: ModelChoice
|
||||
busy: boolean
|
||||
onEnable: (model: ModelChoice | null) => void
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const [options, setOptions] = useState<Array<ModelChoice & { label: string }>>([])
|
||||
const [selected, setSelected] = useState<string>(defaultModel ? `${defaultModel.provider}::${defaultModel.model}` : '')
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('models:list', null)
|
||||
setOptions(r.providers.flatMap((p) => p.models.map((m) => ({
|
||||
provider: p.id,
|
||||
model: m.id,
|
||||
label: `${m.name ?? m.id} (${p.name})`,
|
||||
}))))
|
||||
} catch { /* no picker — enable keeps the pinned model */ }
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const choice = (): ModelChoice | null => {
|
||||
const [provider, model] = selected.split('::')
|
||||
return provider && model ? { provider, model } : null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-border bg-background p-5 shadow-xl">
|
||||
<div className="mb-1 flex items-center gap-2 text-base font-semibold">
|
||||
<Bot className="size-5" /> Turn on {names.length === 1 ? 'its background agent' : 'its background agents'}?
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{appName} ships {names.length === 1 ? 'an agent' : 'agents'} that keep{names.length === 1 ? 's' : ''} its
|
||||
data fresh on a schedule, using your connected accounts and AI models. {names.length === 1 ? 'It is' : 'They are'} currently off.
|
||||
</p>
|
||||
<ul className="mb-3 list-inside list-disc text-sm text-muted-foreground">
|
||||
{names.map((n) => <li key={n}>{n}</li>)}
|
||||
</ul>
|
||||
{options.length > 0 && (
|
||||
<label className="mb-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
Run with
|
||||
<select value={selected} onChange={(e) => setSelected(e.target.value)}
|
||||
className="min-w-0 flex-1 truncate rounded-md border border-border bg-background px-2 py-1 text-sm">
|
||||
{defaultModel && !options.some((o) => o.provider === defaultModel.provider && o.model === defaultModel.model) && (
|
||||
<option value={`${defaultModel.provider}::${defaultModel.model}`}>{defaultModel.model} (default)</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={`${o.provider}::${o.model}`} value={`${o.provider}::${o.model}`}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onSkip} disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">Not now</button>
|
||||
<button type="button" onClick={() => onEnable(choice())} disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50">
|
||||
{busy ? 'Turning on…' : 'Turn on & run now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => void }) {
|
||||
const [records, setRecords] = useState<rowboatApp.RegistryRecord[]>([])
|
||||
const [stale, setStale] = useState(false)
|
||||
|
|
@ -83,6 +158,20 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
|||
const [busy, setBusy] = useState(false)
|
||||
const [urlDialog, setUrlDialog] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
const [agentPrompt, setAgentPrompt] = useState<{ folder: string; appName: string; slugs: string[]; names: string[]; defaultModel?: ModelChoice } | null>(null)
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
// Registry name → local folder, for apps already installed from the catalog.
|
||||
const [installedByName, setInstalledByName] = useState<Map<string, string>>(new Map())
|
||||
|
||||
const loadInstalled = async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:list', {})
|
||||
setInstalledByName(new Map(
|
||||
r.apps.filter((a) => a.kind === 'installed' && a.install).map((a) => [a.install!.name, a.folder]),
|
||||
))
|
||||
} catch { /* cards just show Install */ }
|
||||
}
|
||||
useEffect(() => { void loadInstalled() }, [])
|
||||
|
||||
const load = async (force = false) => {
|
||||
setError(null)
|
||||
|
|
@ -127,6 +216,26 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
|||
}
|
||||
}
|
||||
|
||||
const enableAgents = async (model: ModelChoice | null) => {
|
||||
if (!agentPrompt) return
|
||||
setEnabling(true)
|
||||
try {
|
||||
for (const slug of agentPrompt.slugs) {
|
||||
await window.ipc.invoke('bg-task:patch', {
|
||||
slug,
|
||||
partial: { active: true, ...(model ? { model: model.model, provider: model.provider } : {}) },
|
||||
})
|
||||
// First run so the app opens with data; resolves when the run ends, so
|
||||
// fire-and-forget.
|
||||
void window.ipc.invoke('bg-task:run', { slug }).catch(() => { /* surfaced in bg-tasks */ })
|
||||
}
|
||||
} catch { /* patch failures land the user in the same place as "Not now" */ }
|
||||
const folder = agentPrompt.folder
|
||||
setAgentPrompt(null)
|
||||
setEnabling(false)
|
||||
onInstalled(folder)
|
||||
}
|
||||
|
||||
const confirmInstall = async () => {
|
||||
if (!preview) return
|
||||
setBusy(true)
|
||||
|
|
@ -136,7 +245,25 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
|||
? await window.ipc.invoke('apps:installFromUrl', { url: preview.url, confirmed: true })
|
||||
: await window.ipc.invoke('apps:install', { name: preview.name ?? '', confirmed: true })
|
||||
setPreview(null)
|
||||
if (r.status === 'installed' && r.app) onInstalled(r.app.folder)
|
||||
void loadInstalled()
|
||||
if (r.status === 'installed' && r.app) {
|
||||
if (r.app.agentSlugs.length > 0) {
|
||||
// Offer to switch the bundled agents on (they install disabled).
|
||||
let defaultModel: ModelChoice | undefined
|
||||
const names = await Promise.all(r.app.agentSlugs.map(async (slug) => {
|
||||
try {
|
||||
const g = await window.ipc.invoke('bg-task:get', { slug })
|
||||
if (g.task?.model && g.task.provider) defaultModel = { model: g.task.model, provider: g.task.provider }
|
||||
return g.task?.name ?? slug
|
||||
} catch {
|
||||
return slug
|
||||
}
|
||||
}))
|
||||
setAgentPrompt({ folder: r.app.folder, appName: r.app.manifest?.name ?? r.app.folder, slugs: r.app.agentSlugs, names, defaultModel })
|
||||
} else {
|
||||
onInstalled(r.app.folder)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
|
|
@ -186,10 +313,18 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
|||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{r.description || 'No description.'}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void startInstall(r.name)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
<Download className="size-4" /> Install
|
||||
</button>
|
||||
{installedByName.has(r.name) ? (
|
||||
<button type="button" title="Installed — open it"
|
||||
onClick={() => onInstalled(installedByName.get(r.name)!)}
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-green-600 hover:bg-green-500/10 dark:text-green-500">
|
||||
<BadgeCheck className="size-4" /> Installed
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => void startInstall(r.name)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-accent">
|
||||
<Download className="size-4" /> Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -226,6 +361,21 @@ export function CatalogTab({ onInstalled }: { onInstalled: (folder: string) => v
|
|||
onCancel={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{agentPrompt && (
|
||||
<EnableAgentsDialog
|
||||
appName={agentPrompt.appName}
|
||||
names={agentPrompt.names}
|
||||
defaultModel={agentPrompt.defaultModel}
|
||||
busy={enabling}
|
||||
onEnable={(model) => void enableAgents(model)}
|
||||
onSkip={() => {
|
||||
const folder = agentPrompt.folder
|
||||
setAgentPrompt(null)
|
||||
onInstalled(folder)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ import { CheckCircle2, Github, Loader2, XCircle } from 'lucide-react'
|
|||
|
||||
// Publish dialog (spec §14): device-flow sign-in → confirmation → step
|
||||
// progress (§11.2 step names) → success links / typed error (name_taken gets
|
||||
// an inline hint to rename and retry).
|
||||
// an inline hint to rename and retry). With `published` the dialog runs the
|
||||
// UPDATE path instead (§11.3: version bump + new release, no registry) —
|
||||
// re-running first-publish on a published app always fails with name_taken.
|
||||
|
||||
type Phase = 'auth' | 'device' | 'confirm' | 'publishing' | 'done' | 'error'
|
||||
type Increment = 'patch' | 'minor' | 'major'
|
||||
|
||||
export function PublishDialog({ folder, appName, onClose, onPublished }: {
|
||||
export function PublishDialog({ folder, appName, published, onClose, onPublished }: {
|
||||
folder: string
|
||||
appName: string
|
||||
/** App already has a publish record — run publish-update instead. */
|
||||
published?: boolean
|
||||
onClose: () => void
|
||||
onPublished: () => void
|
||||
}) {
|
||||
|
|
@ -18,7 +23,8 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
|
|||
const [userCode, setUserCode] = useState('')
|
||||
const [steps, setSteps] = useState<string[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ repoUrl: string; releaseUrl: string; prUrl?: string; status: string } | null>(null)
|
||||
const [increment, setIncrement] = useState<Increment>('patch')
|
||||
const [result, setResult] = useState<{ repoUrl?: string; releaseUrl: string; prUrl?: string; status: string; version?: string } | null>(null)
|
||||
const pollTimer = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -75,8 +81,13 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
|
|||
setError(null)
|
||||
setSteps([])
|
||||
try {
|
||||
const r = await window.ipc.invoke('apps:publish', { folder })
|
||||
setResult(r)
|
||||
if (published) {
|
||||
const r = await window.ipc.invoke('apps:publishUpdate', { folder, increment })
|
||||
setResult({ releaseUrl: r.releaseUrl, status: 'published', version: r.version })
|
||||
} else {
|
||||
const r = await window.ipc.invoke('apps:publish', { folder })
|
||||
setResult(r)
|
||||
}
|
||||
setPhase('done')
|
||||
onPublished()
|
||||
} catch (e) {
|
||||
|
|
@ -128,15 +139,47 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: {
|
|||
|
||||
{phase === 'confirm' && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">Signed in as <span className="font-medium text-foreground">@{login}</span>. This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Signed in as <span className="font-medium text-foreground">@{login}</span>.{' '}
|
||||
{published
|
||||
? <>This will publish a new version of <span className="font-medium text-foreground">{appName}</span> (a new release on the existing repo).</>
|
||||
: <>This will publish <span className="font-medium text-foreground">{appName}</span> publicly.</>}
|
||||
</p>
|
||||
{published && (
|
||||
<label className="flex items-center gap-2 text-muted-foreground">
|
||||
Version bump
|
||||
<select value={increment} onChange={(e) => setIncrement(e.target.value as Increment)}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-sm">
|
||||
<option value="patch">patch</option>
|
||||
<option value="minor">minor</option>
|
||||
<option value="major">major</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button type="button" onClick={() => void publish()}
|
||||
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
|
||||
Publish
|
||||
{published ? 'Publish update' : 'Publish'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === 'publishing' || phase === 'done' || phase === 'error') && (
|
||||
{published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
|
||||
<div className="space-y-3 text-sm">
|
||||
{phase === 'publishing' && (
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Publishing update…
|
||||
</p>
|
||||
)}
|
||||
{phase === 'done' && result && (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-2"><CheckCircle2 className="size-4 text-green-600" /> Published v{result.version}</p>
|
||||
<div className="text-xs">Release: <a className="text-primary underline" href={result.releaseUrl} target="_blank" rel="noreferrer">{result.releaseUrl}</a></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!published && (phase === 'publishing' || phase === 'done' || phase === 'error') && (
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{Object.entries(STEP_LABELS).map(([key, label]) => {
|
||||
const doneStep = steps.includes(key) || phase === 'done'
|
||||
|
|
|
|||
|
|
@ -143,9 +143,16 @@ shell**; never generate a refresh script) and store it via the
|
|||
**\`app-set-data\`** builtin: \`{ appFolder, file: "data.json", data: <object> }\`
|
||||
— pass the object directly, never \`JSON.stringify\` it. The write is atomic and
|
||||
contract-checked; on a failed fetch keep the last good data (never overwrite
|
||||
good series with empties). If the app should ship the agent, mirror the
|
||||
definition into \`agents/<slug>.yaml\` with ONLY \`name\`, \`instructions\`,
|
||||
\`triggers\`, and list the filename in \`manifest.agents\`.
|
||||
good series with empties).
|
||||
|
||||
**ALWAYS bundle the agent into the app as well** (required, not optional):
|
||||
mirror the definition into \`agents/<slug>.yaml\` with ONLY \`name\`,
|
||||
\`instructions\`, \`triggers\` (no \`active\`, no \`model\` — the schema rejects
|
||||
them) and list the filename in \`manifest.agents\`. The app package ships only
|
||||
what's inside the app folder: without this mirror, a published copy of the app
|
||||
is dead on arrival — installers get a UI whose data never refreshes. The
|
||||
bundled copy materializes as a disabled bg-task for installers (they opt in);
|
||||
the \`create-background-task\` task you made stays the author's live agent.
|
||||
|
||||
## 6. Prohibitions
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|||
import { TriggersSchema, type BackgroundTask } from '@x/shared/dist/background-task.js';
|
||||
import type { AppSummary } from '@x/shared/dist/rowboat-app.js';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { getDefaultModelAndProvider } from '../models/defaults.js';
|
||||
import { appDir, agentTaskSlug } from './indexer.js';
|
||||
|
||||
// Bundled background agents (spec §8). Definitions ship in the app package at
|
||||
|
|
@ -94,6 +95,17 @@ async function materializeAgent(folder: string, agentFile: string): Promise<stri
|
|||
sourceApp: folder,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
// Packages can't pin a model (§8.1 — the author's providers aren't the
|
||||
// installer's), but the bg-task category default is a lite model that
|
||||
// reliably mangles large tool payloads (invalid JSON → contract
|
||||
// rejections → the app never gets data). Pin the HOST's default model at
|
||||
// materialization instead — the installer's machine decides, exactly like
|
||||
// the copilot does for tasks it authors.
|
||||
try {
|
||||
const sel = await getDefaultModelAndProvider();
|
||||
task.model = sel.model;
|
||||
task.provider = sel.provider;
|
||||
} catch { /* no model config — leave the category default */ }
|
||||
await fs.mkdir(taskDir, { recursive: true });
|
||||
await fs.writeFile(taskYaml, stringifyYaml(task), 'utf-8');
|
||||
await fs.writeFile(path.join(taskDir, 'index.md'), '', 'utf-8').catch(() => undefined);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue