mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(apps): Rowboat Apps M1 — per-app origins, Host API core, apps skill
Implements M1 of the Apps V1 spec, replacing the mini-apps prototype: - rowboat-app.json manifest + AppSummary/install/publish/registry schemas; sourceApp on background tasks - apps server on 127.0.0.1:3210 (absorbs local-sites): Host-header routing to <slug>.apps.localhost origins, 421 rebinding guard, static from dist/ with entry/SPA fallback + realpath confinement, control host /health, chokidar watcher + per-app SSE with dist/data areas, embed-opt-in autosize bootstrap and cancelable rowboat:data-change event - Host API: D17 anti-CSRF (X-Rowboat-App + strict Origin on non-GET), GET /_rowboat/app (manifest + theme), /_rowboat/events, data API under data/ with atomic writes and dataContracts enforcement - indexer: list (ok/invalid surfaced) / create scaffold / delete - IPC: apps:list/get/create/delete/setTheme; renderer reports theme live - renderer: Apps home (My apps grid + catalog placeholder, server banner, invalid/kind badges) and full-height app view on the app origin - apps copilot skill (replaces build-mini-app); app-set-data builtin replaces mini-app-install/set-data; deleted the app://miniapp protocol, postMessage bridge, mini-apps handler/channels, and local-sites
This commit is contained in:
parent
2c038fe518
commit
7f15f67273
24 changed files with 1567 additions and 2359 deletions
|
|
@ -33,6 +33,9 @@ export type BackgroundTask = {
|
|||
projectId?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
// Folder slug of the Rowboat App that installed this task (spec §8.2).
|
||||
// Runtime-managed; tasks with sourceApp are owned by the app lifecycle.
|
||||
sourceApp?: string;
|
||||
createdAt: string;
|
||||
// Runtime-managed — never hand-write. Mirrors live-note's flat-field
|
||||
// pattern: `lastAttemptAt` is bumped at every run start (backoff anchor),
|
||||
|
|
@ -53,6 +56,7 @@ export type BackgroundTaskSummary = {
|
|||
active: boolean;
|
||||
triggers?: Triggers;
|
||||
projectId?: string;
|
||||
sourceApp?: string;
|
||||
createdAt: string;
|
||||
lastAttemptAt?: string;
|
||||
lastRunId?: string;
|
||||
|
|
@ -71,6 +75,7 @@ export const BackgroundTaskSchema = z.object({
|
|||
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
|
||||
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
|
||||
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
|
||||
sourceApp: z.string().optional().describe('Folder slug of the app that installed this task. Runtime-managed.'),
|
||||
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
|
||||
lastAttemptAt: z.string().optional().describe('Runtime-managed — never write this yourself. Bumped at the start of every agent run; used by the scheduler for backoff so failures do not retry-storm.'),
|
||||
lastRunId: z.string().optional().describe('Runtime-managed — never write this yourself. The id of the most recent run (success or failure); used by the bg-task:stop handler.'),
|
||||
|
|
@ -86,6 +91,7 @@ export const BackgroundTaskSummarySchema = z.object({
|
|||
active: z.boolean(),
|
||||
triggers: TriggersSchema.optional(),
|
||||
projectId: z.string().optional(),
|
||||
sourceApp: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
lastAttemptAt: z.string().optional(),
|
||||
lastRunId: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -19,5 +19,5 @@ export * as browserControl from './browser-control.js';
|
|||
export * as billing from './billing.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export * as miniApp from './mini-app.js';
|
||||
export * as rowboatApp from './rowboat-app.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
import { UserMessageContent } from './message.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { MiniAppManifest } from './mini-app.js';
|
||||
import { AppSummarySchema } from './rowboat-app.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
|
|
@ -1008,60 +1008,34 @@ const ipcSchemas = {
|
|||
shouldShow: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: seed built-in apps to ~/.rowboat/apps/<id>/ (idempotent).
|
||||
'mini-apps:seed': {
|
||||
req: z.object({
|
||||
apps: z.array(z.object({
|
||||
manifest: MiniAppManifest,
|
||||
html: z.string(),
|
||||
data: z.unknown().optional(),
|
||||
})),
|
||||
}),
|
||||
// Rowboat Apps (spec §13) — M1 local channels.
|
||||
'apps:list': {
|
||||
req: z.object({}),
|
||||
res: z.object({
|
||||
seeded: z.array(z.string()),
|
||||
serverRunning: z.boolean(),
|
||||
serverError: z.string().optional(),
|
||||
apps: z.array(AppSummarySchema),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: list installed app manifests from ~/.rowboat/apps/.
|
||||
'mini-apps:list': {
|
||||
req: z.null(),
|
||||
'apps:get': {
|
||||
req: z.object({ folder: z.string() }),
|
||||
res: z.object({
|
||||
manifests: z.array(MiniAppManifest),
|
||||
app: AppSummarySchema,
|
||||
readme: z.string().optional(),
|
||||
rollbackAvailable: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Mini Apps: read an app's latest data.json (agent output).
|
||||
'mini-apps:get-data': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.object({ data: z.unknown().nullable() }),
|
||||
'apps:create': {
|
||||
req: z.object({ folder: z.string(), name: z.string(), description: z.string() }),
|
||||
res: z.object({ app: AppSummarySchema }),
|
||||
},
|
||||
// Mini Apps: event pushed main→renderer when an app's data.json changes on
|
||||
// disk (agent refresh), so an open app can reload its data live.
|
||||
'mini-apps:dataChanged': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.null(),
|
||||
'apps:delete': {
|
||||
req: z.object({ folder: z.string() }),
|
||||
res: z.object({ ok: z.literal(true) }),
|
||||
},
|
||||
// Mini Apps: event pushed main→renderer when an app is installed/updated
|
||||
// (manifest or index.html changed), so the gallery re-lists and an open app
|
||||
// reloads once the install settles.
|
||||
'mini-apps:appsChanged': {
|
||||
req: z.object({ id: z.string() }),
|
||||
res: z.null(),
|
||||
},
|
||||
// Mini Apps: proxy an HTTP request through main (bypasses browser CORS for the
|
||||
// sandboxed app origin). GET/POST to http(s) only.
|
||||
'mini-apps:fetch': {
|
||||
req: z.object({
|
||||
url: z.string(),
|
||||
method: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
body: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
status: z.number(),
|
||||
statusText: z.string(),
|
||||
text: z.string(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
'apps:setTheme': {
|
||||
req: z.object({ theme: z.enum(['light', 'dark']) }),
|
||||
res: z.object({ ok: z.literal(true) }),
|
||||
},
|
||||
'composio:didConnect': {
|
||||
req: z.object({
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Manifest for a Mini App stored at ~/.rowboat/apps/<id>/manifest.json.
|
||||
*
|
||||
* Static assets are served from `<id>/dist/` via app://miniapp/<id>/. The
|
||||
* optional `agent` links a background task (bg-tasks engine) that writes
|
||||
* `<id>/data.json`, which the host reads and pushes to the app.
|
||||
*/
|
||||
export const MiniAppManifest = z.object({
|
||||
/** Stable slug; also the on-disk folder name and the app:// host path. */
|
||||
id: z.string(),
|
||||
/** Display name shown on the card and in the open view. */
|
||||
title: z.string(),
|
||||
/** One-line description for the card. */
|
||||
description: z.string().default(''),
|
||||
/** Primary integration shown in the card footer pill (e.g. 'GitHub'). */
|
||||
source: z.string().default(''),
|
||||
/** Composio toolkits this app may use; enforced host-side on bridge calls. */
|
||||
scope: z.array(z.string()).default([]),
|
||||
/** Whether the app's agent is active (drives the status badge). */
|
||||
active: z.boolean().default(true),
|
||||
/** Human last-run label for the card footer (e.g. '2m ago'). */
|
||||
lastRun: z.string().default(''),
|
||||
/** Entry file within the app folder, served via app://miniapp/<id>/. */
|
||||
entry: z.string().default('dist/index.html'),
|
||||
/** Optional associated background-task slug that produces data.json. */
|
||||
agent: z.string().optional(),
|
||||
/**
|
||||
* Optional guard on what may be written to data.json (via mini-app-set-data).
|
||||
* Prevents a stray/legacy task from corrupting the app with the wrong shape or
|
||||
* wiping good series with empty ones.
|
||||
*/
|
||||
dataContract: z.object({
|
||||
/** Top-level keys that must be present and non-null. */
|
||||
requiredKeys: z.array(z.string()).default([]),
|
||||
/** Top-level keys that must be non-empty arrays. */
|
||||
nonEmptyArrayKeys: z.array(z.string()).default([]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type MiniAppManifest = z.infer<typeof MiniAppManifest>;
|
||||
113
apps/x/packages/shared/src/rowboat-app.ts
Normal file
113
apps/x/packages/shared/src/rowboat-app.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Rowboat Apps schemas (spec §4.2, §5.1, §9.1, §11.4, §12.2).
|
||||
|
||||
export const PACKAGE_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
export const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest — rowboat-app.json (§4.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RowboatAppManifestSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
name: z.string().min(3).max(64).regex(PACKAGE_NAME_RE)
|
||||
.describe('Package identity. Globally unique and immutable once published.'),
|
||||
version: z.string().regex(SEMVER_RE)
|
||||
.describe('Strict semver, no prerelease/build suffix in V1.'),
|
||||
description: z.string().max(500).default(''),
|
||||
icon: z.string().optional()
|
||||
.describe('Path relative to dist/. Same traversal rules as entry.'),
|
||||
entry: z.string().default('index.html')
|
||||
.describe('Path relative to dist/. Serves as app root and SPA fallback.'),
|
||||
agents: z.array(z.string().regex(/^[a-z0-9][a-z0-9-_]*\.yaml$/)).default([])
|
||||
.describe('Filenames under agents/. Each must exist in the package.'),
|
||||
capabilities: z.array(z.string()).default([])
|
||||
.describe('Capability identifiers this app may use (D7): Composio toolkit slugs for /_rowboat/tools/*, plus the reserved identifiers "llm" (§7.6) and "copilot" (§7.7). Empty = none.'),
|
||||
dataContracts: z.array(z.object({
|
||||
file: z.string(), // path relative to data/, e.g. "data.json"
|
||||
requiredKeys: z.array(z.string()).default([]),
|
||||
nonEmptyArrayKeys: z.array(z.string()).default([]),
|
||||
})).default([])
|
||||
.describe('Write guards for specific data/ files: required top-level keys and keys that must stay non-empty arrays. Enforced on writes (§7.3, §8.6) so a buggy agent run cannot corrupt the app\'s data shape or wipe good series with empties.'),
|
||||
// RESERVED — validated if present, ignored by V1 runtime:
|
||||
build: z.object({ command: z.string() }).optional()
|
||||
.describe('RESERVED. Rowboat MUST NOT execute this in V1.'),
|
||||
minRowboatVersion: z.string().regex(SEMVER_RE).optional()
|
||||
.describe('RESERVED. Minimum compatible Rowboat version; not enforced in V1.'),
|
||||
}).passthrough(); // unknown fields survive round-trips (forward compatibility)
|
||||
|
||||
export type RowboatAppManifest = z.infer<typeof RowboatAppManifestSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install record — .rowboat-install.json (§12.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AppInstallRecordSchema = z.object({
|
||||
name: z.string(),
|
||||
repo: z.string().optional(), // owner/repo at install time; absent only for non-GitHub URL installs (§12.5)
|
||||
sourceUrl: z.string().optional(), // present only for §12.5 URL installs
|
||||
version: z.string(),
|
||||
sha256: z.string(), // bundle checksum, pinned at install
|
||||
installedAt: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
files: z.record(z.string(), z.string()), // relpath → sha256 of release-managed files
|
||||
previousVersion: z.string().optional(), // set while .previous/ exists
|
||||
});
|
||||
|
||||
export type AppInstallRecord = z.infer<typeof AppInstallRecordSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publish record — .rowboat-publish.json (§11.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AppPublishRecordSchema = z.object({
|
||||
name: z.string(),
|
||||
login: z.string(), // publisher GitHub login
|
||||
repo: z.string(), // owner/repo
|
||||
lastPublishedVersion: z.string().optional(),
|
||||
lastSha256: z.string().optional(),
|
||||
pendingSteps: z.object({ // present only mid-publish (resume state)
|
||||
version: z.string(),
|
||||
completed: z.array(z.string()), // step names from §11.2
|
||||
releaseId: z.number().optional(),
|
||||
prUrl: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type AppPublishRecord = z.infer<typeof AppPublishRecordSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App summary — apps:list / apps:get (§5.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AppSummarySchema = z.object({
|
||||
folder: z.string(), // folder slug
|
||||
status: z.enum(['ok', 'invalid']),
|
||||
manifest: RowboatAppManifestSchema.optional(), // present when ok
|
||||
manifestError: z.string().optional(), // present when invalid
|
||||
origin: z.string(), // http://<folder>.apps.localhost:3210
|
||||
kind: z.enum(['local', 'installed']),
|
||||
install: AppInstallRecordSchema.optional(), // §12.2
|
||||
publish: AppPublishRecordSchema.optional(), // §11.4
|
||||
hasDist: z.boolean(),
|
||||
agentSlugs: z.array(z.string()), // materialized bg-task slugs (§8.3)
|
||||
});
|
||||
|
||||
export type AppSummary = z.infer<typeof AppSummarySchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry record — apps/<name>.json in the registry repo (§9.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RegistryRecordSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
name: z.string().min(3).max(64).regex(PACKAGE_NAME_RE),
|
||||
owner: z.string().min(1), // GitHub login of the publisher
|
||||
repo: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), // "owner/repo"
|
||||
description: z.string().max(500).default(''),
|
||||
iconUrl: z.string().url().optional(), // https URL for the catalog listing icon
|
||||
createdAt: z.string(), // ISO 8601
|
||||
}).strict();
|
||||
|
||||
export type RegistryRecord = z.infer<typeof RegistryRecordSchema>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue