mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
Merge pull request #723 from rowboatlabs/feature/apps-copilot-bridge
Apps: generic copilot bridge — answer from app data, surface the app
This commit is contained in:
commit
25a465880d
8 changed files with 209 additions and 9 deletions
|
|
@ -79,6 +79,9 @@ import * as appsPublisher from '@x/core/dist/apps/publisher.js';
|
||||||
|
|
||||||
// D18 install previews awaiting confirmation, keyed by app name.
|
// D18 install previews awaiting confirmation, keyed by app name.
|
||||||
const appInstallPreviews = new Map<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
|
const appInstallPreviews = new Map<string, Awaited<ReturnType<typeof appsInstaller.previewInstall>>>();
|
||||||
|
// Last-seen app-set fingerprint; a change invalidates the copilot
|
||||||
|
// instructions cache (they embed the installed-apps list).
|
||||||
|
let lastAppsFingerprint: string | null = null;
|
||||||
import { consumePendingDeepLink } from './deeplink.js';
|
import { consumePendingDeepLink } from './deeplink.js';
|
||||||
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
||||||
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
||||||
|
|
@ -1618,6 +1621,15 @@ export function setupIpcHandlers() {
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
if (app.agentSlugs.length) await appsAgents.syncAppAgents(app);
|
if (app.agentSlugs.length) await appsAgents.syncAppAgents(app);
|
||||||
}
|
}
|
||||||
|
// The copilot instructions embed the installed-apps list. This handler
|
||||||
|
// is the one place that sees every change to the app set (installs,
|
||||||
|
// deletes, copilot-created folders — the renderer polls it), so refresh
|
||||||
|
// the instructions cache when the set actually changes.
|
||||||
|
const fingerprint = JSON.stringify(apps.map((a) => [a.folder, a.manifest?.name, a.manifest?.description, a.hasDist]));
|
||||||
|
if (fingerprint !== lastAppsFingerprint) {
|
||||||
|
lastAppsFingerprint = fingerprint;
|
||||||
|
invalidateCopilotInstructionsCache();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
serverRunning: status.running,
|
serverRunning: status.running,
|
||||||
...(status.error ? { serverError: status.error } : {}),
|
...(status.error ? { serverError: status.error } : {}),
|
||||||
|
|
|
||||||
|
|
@ -4773,6 +4773,7 @@ function App() {
|
||||||
case 'knowledge': void navigateToView({ type: 'knowledge-view' }); break
|
case 'knowledge': void navigateToView({ type: 'knowledge-view' }); break
|
||||||
case 'workspace': void navigateToView({ type: 'workspace' }); break
|
case 'workspace': void navigateToView({ type: 'workspace' }); break
|
||||||
case 'code': void navigateToView({ type: 'code' }); break
|
case 'code': void navigateToView({ type: 'code' }); break
|
||||||
|
case 'apps': openAppsView(); break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,43 @@ import { composioAccountsRepo } from "../../composio/repo.js";
|
||||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
|
||||||
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
|
import { knowledgeSourcesRepo } from "../../knowledge/sources/repo.js";
|
||||||
|
import { listApps } from "../../apps/indexer.js";
|
||||||
|
|
||||||
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic section listing the user's Rowboat apps, so questions an app
|
||||||
|
* already tracks route to it AMBIENTLY (no discovery call). Deliberately
|
||||||
|
* generic — apps are matched by their own name/description; nothing here is
|
||||||
|
* specific to any app. Cache staleness is handled by the apps:list handler
|
||||||
|
* invalidating the instructions cache when the app set changes.
|
||||||
|
*/
|
||||||
|
async function getInstalledAppsPrompt(): Promise<string> {
|
||||||
|
let apps;
|
||||||
|
try {
|
||||||
|
apps = await listApps();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const usable = apps.filter((a) => a.status === 'ok' && a.hasDist);
|
||||||
|
if (usable.length === 0) return '';
|
||||||
|
const lines = usable.map((a) => {
|
||||||
|
const name = a.manifest?.name ?? a.folder;
|
||||||
|
const desc = (a.manifest?.description ?? '').slice(0, 160);
|
||||||
|
const agents = a.agentSlugs.length ? ` (self-updating via ${a.agentSlugs.length} background agent${a.agentSlugs.length > 1 ? 's' : ''})` : '';
|
||||||
|
return `- \`${a.folder}\` — **${name}**: ${desc}${agents}`;
|
||||||
|
});
|
||||||
|
return `
|
||||||
|
## Installed Rowboat Apps
|
||||||
|
|
||||||
|
The user has these Rowboat apps (mini web apps in the Apps view, each holding fresh data its background agent maintains):
|
||||||
|
|
||||||
|
${lines.join('\n')}
|
||||||
|
|
||||||
|
When a question matches what an app tracks, PREFER the app over external calls: load the \`app-navigation\` skill, read the app's data with \`app-read-data\` (fresh, instant), answer from it, and surface the app on screen with \`app-navigation\` \`open-app\` — show while telling. This applies to ANY app above; match by its name/description.
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate dynamic instructions section for Composio integrations.
|
* Generate dynamic instructions section for Composio integrations.
|
||||||
* Lists connected toolkits and explains the meta-tool discovery flow.
|
* Lists connected toolkits and explains the meta-tool discovery flow.
|
||||||
|
|
@ -312,7 +346,7 @@ ${slackToolsLine}${composioToolsLine}
|
||||||
Every other builtin is skill-scoped — load the owning skill from the catalog to attach it:
|
Every other builtin is skill-scoped — load the owning skill from the catalog to attach it:
|
||||||
- Write-side file tools (\`file-writeText\`, \`file-editText\`, \`file-mkdir\`, \`file-rename\`, \`file-copy\`, \`file-remove\`, \`file-stat\`) via \`organize-files\`, \`doc-collab\`, and related skills
|
- Write-side file tools (\`file-writeText\`, \`file-editText\`, \`file-mkdir\`, \`file-rename\`, \`file-copy\`, \`file-remove\`, \`file-stat\`) via \`organize-files\`, \`doc-collab\`, and related skills
|
||||||
- MCP server management (\`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\`) via \`mcp-integration\`
|
- MCP server management (\`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\`) via \`mcp-integration\`
|
||||||
- \`app-navigation\` / \`app-set-data\` via the \`app-navigation\` skill; \`browser-control\` via the \`browser-control\` skill; notifications via \`notify-user\`; background tasks via \`background-task\`; everything else via the \`builtin-tools\` escape hatch
|
- \`app-navigation\` / \`app-read-data\` / \`app-set-data\` via the \`app-navigation\` skill; \`browser-control\` via the \`browser-control\` skill; notifications via \`notify-user\`; background tasks via \`background-task\`; everything else via the \`builtin-tools\` escape hatch
|
||||||
|
|
||||||
**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
|
**Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
|
||||||
|
|
||||||
|
|
@ -404,8 +438,9 @@ export async function buildCopilotInstructions(): Promise<string> {
|
||||||
const catalog = await buildAvailableSkillCatalog();
|
const catalog = await buildAvailableSkillCatalog();
|
||||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint, googleConnected);
|
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, slackConnected, slackChannelsHint, googleConnected);
|
||||||
const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected);
|
const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected);
|
||||||
cachedInstructions = composioPrompt
|
const appsPrompt = await getInstalledAppsPrompt();
|
||||||
? baseInstructions + '\n' + composioPrompt
|
cachedInstructions = baseInstructions
|
||||||
: baseInstructions;
|
+ (composioPrompt ? '\n' + composioPrompt : '')
|
||||||
|
+ (appsPrompt ? '\n' + appsPrompt : '');
|
||||||
return cachedInstructions;
|
return cachedInstructions;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ that view so the user sees it.
|
||||||
any thread the search returned, including old threads outside the inbox.
|
any thread the search returned, including old threads outside the inbox.
|
||||||
- ` + "`view: \"bg-tasks\"`" + ` → background agents: ` + "`{ name, slug, active, triggers, lastRunAt, lastRunSummary, lastRunError }`" + `.
|
- ` + "`view: \"bg-tasks\"`" + ` → background agents: ` + "`{ name, slug, active, triggers, lastRunAt, lastRunSummary, lastRunError }`" + `.
|
||||||
- ` + "`view: \"chat-history\"`" + ` → past chats: ` + "`{ sessionId, title, updatedAt, turnCount }`" + `.
|
- ` + "`view: \"chat-history\"`" + ` → past chats: ` + "`{ sessionId, title, updatedAt, turnCount }`" + `.
|
||||||
|
- ` + "`view: \"apps\"`" + ` → installed Rowboat apps: ` + "`{ folder, name, description, kind, dataFiles, agentSlugs }`" + `.
|
||||||
- ` + "`limit`" + ` (optional, default 15).
|
- ` + "`limit`" + ` (optional, default 15).
|
||||||
|
|
||||||
For notes, meetings, and live notes use the ` + "`file-*`" + ` tools (they are
|
For notes, meetings, and live notes use the ` + "`file-*`" + ` tools (they are
|
||||||
|
|
@ -55,9 +56,28 @@ markdown files in the workspace) and then open-note / open-item to show them.
|
||||||
- ` + "`kind: \"session\"`" + ` + ` + "`sessionId`" + ` (from read-view chat-history)
|
- ` + "`kind: \"session\"`" + ` + ` + "`sessionId`" + ` (from read-view chat-history)
|
||||||
|
|
||||||
### open-view — just switch the screen
|
### open-view — just switch the screen
|
||||||
` + "`view`" + `: ` + "`home | email | meetings | live-notes | bg-tasks | chat-history | knowledge | workspace | code | bases | graph`" + `
|
` + "`view`" + `: ` + "`home | email | meetings | live-notes | bg-tasks | chat-history | knowledge | workspace | code | bases | graph | apps`" + `
|
||||||
Use when the user asks to "go to"/"show" a view without a question to answer.
|
Use when the user asks to "go to"/"show" a view without a question to answer.
|
||||||
|
|
||||||
|
## Answering from Rowboat apps (any app — match by description)
|
||||||
|
|
||||||
|
Installed Rowboat apps hold FRESH data their background agents maintain (see
|
||||||
|
the "Installed Rowboat Apps" section of your context, or ` + "`read-view view: \"apps\"`" + `).
|
||||||
|
When a question matches what an app tracks, the app IS the answer source —
|
||||||
|
no external API call needed, and the user gets a visual:
|
||||||
|
|
||||||
|
1. Identify the app by its name/description (context list, or ` + "`read-view apps`" + `).
|
||||||
|
2. ` + "`app-read-data({ appFolder, file: \"data.json\" })`" + ` — omit ` + "`file`" + ` to list
|
||||||
|
what data files exist. Answer from this data, concisely.
|
||||||
|
3. ` + "`app-navigation({ action: \"open-app\", appId: appFolder })`" + ` — the app opens
|
||||||
|
on the user's screen while you answer. Show while telling.
|
||||||
|
|
||||||
|
This is GENERIC: never hardcode a mapping from topics to specific apps. A
|
||||||
|
PR-dashboard app answers "what PRs do I have?" today; a weather app answers
|
||||||
|
"what's the weekend look like?" the moment it's installed — same three steps.
|
||||||
|
If the data looks stale and the app has an agent, offer to run it
|
||||||
|
(` + "`run-background-task-agent`" + ` via the ` + "`background-task`" + ` skill).
|
||||||
|
|
||||||
### open-note
|
### open-note
|
||||||
Open a knowledge file in the editor. ` + "`path`" + `: full workspace-relative path
|
Open a knowledge file in the editor. ` + "`path`" + `: full workspace-relative path
|
||||||
(e.g. ` + "`knowledge/People/John Smith.md`" + `). Use ` + "`file-grep`" + ` first if unsure
|
(e.g. ` + "`knowledge/People/John Smith.md`" + `). Use ` + "`file-grep`" + ` first if unsure
|
||||||
|
|
@ -90,6 +110,12 @@ Knowledge-base table control (unchanged):
|
||||||
1. ` + "`get-base-state`" + ` to see available categories, then
|
1. ` + "`get-base-state`" + ` to see available categories, then
|
||||||
2. ` + "`update-base-view`" + ` with ` + "`filters.set: [{ category: \"relationship\", value: \"customer\" }]`" + `
|
2. ` + "`update-base-view`" + ` with ` + "`filters.set: [{ category: \"relationship\", value: \"customer\" }]`" + `
|
||||||
|
|
||||||
|
**"What PRs do I have?"** (an installed app tracks open PRs)
|
||||||
|
1. The context lists an app whose description matches (e.g. ` + "`pr-dashboard`" + ` — "Open PRs on …").
|
||||||
|
2. ` + "`app-read-data({ appFolder: \"pr-dashboard\", file: \"data.json\" })`" + `
|
||||||
|
3. ` + "`app-navigation({ action: \"open-app\", appId: \"pr-dashboard\" })`" + ` — the dashboard opens.
|
||||||
|
4. "Seven open — two need your review: #676 apps M3, and #675 the packaging fix."
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- read-view/open-view/open-item change what the user is looking at — that is
|
- read-view/open-view/open-item change what the user is looking at — that is
|
||||||
the point, but don't bounce their screen around needlessly; navigate when
|
the point, but don't bounce their screen around needlessly; navigate when
|
||||||
|
|
|
||||||
|
|
@ -131,9 +131,9 @@ const definitions: SkillDefinition[] = [
|
||||||
{
|
{
|
||||||
id: "app-navigation",
|
id: "app-navigation",
|
||||||
title: "App Navigation",
|
title: "App Navigation",
|
||||||
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
summary: "Navigate the app UI - open notes, switch views, answer from an installed Rowboat app's data and surface it, filter/search the knowledge base, and manage saved views.",
|
||||||
content: appNavigationSkill,
|
content: appNavigationSkill,
|
||||||
tools: ["app-navigation", "app-set-data"],
|
tools: ["app-navigation", "app-read-data", "app-set-data"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "code-with-agents",
|
id: "code-with-agents",
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ const HISTORICAL_KEY_ORDER = [
|
||||||
"composio-search-tools",
|
"composio-search-tools",
|
||||||
"composio-execute-tool",
|
"composio-execute-tool",
|
||||||
"composio-connect-toolkit",
|
"composio-connect-toolkit",
|
||||||
|
"app-read-data",
|
||||||
"app-set-data",
|
"app-set-data",
|
||||||
"list-models",
|
"list-models",
|
||||||
"fetch-url",
|
"fetch-url",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import container from "../../../di/container.js";
|
||||||
import * as files from "../../../filesystem/files.js";
|
import * as files from "../../../filesystem/files.js";
|
||||||
import { WorkDir } from "../../../config/config.js";
|
import { WorkDir } from "../../../config/config.js";
|
||||||
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.js";
|
import { RowboatAppManifestSchema } from "@x/shared/dist/rowboat-app.js";
|
||||||
|
import { listApps } from "../../../apps/indexer.js";
|
||||||
import { listImportantThreads, searchThreads } from "../../../knowledge/sync_gmail.js";
|
import { listImportantThreads, searchThreads } from "../../../knowledge/sync_gmail.js";
|
||||||
import { listTasks as listBackgroundTasks } from "../../../background-tasks/fileops.js";
|
import { listTasks as listBackgroundTasks } from "../../../background-tasks/fileops.js";
|
||||||
import type { ISessions } from "../../../sessions/api.js";
|
import type { ISessions } from "../../../sessions/api.js";
|
||||||
|
|
@ -27,7 +28,7 @@ export const appNavigationTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
// open-app
|
// open-app
|
||||||
appId: z.string().optional().describe("App folder slug under ~/.rowboat/apps (for open-app) — opens the app in the middle pane."),
|
appId: z.string().optional().describe("App folder slug under ~/.rowboat/apps (for open-app) — opens the app in the middle pane."),
|
||||||
// open-view / read-view
|
// open-view / read-view
|
||||||
view: z.enum(["home", "email", "meetings", "live-notes", "bg-tasks", "chat-history", "knowledge", "workspace", "code", "bases", "graph"]).optional().describe("Which view to open (open-view) or read (read-view; supported for read: email, bg-tasks, chat-history)"),
|
view: z.enum(["home", "email", "meetings", "live-notes", "bg-tasks", "chat-history", "knowledge", "workspace", "code", "bases", "graph", "apps"]).optional().describe("Which view to open (open-view) or read (read-view; supported for read: email, bg-tasks, chat-history, apps)"),
|
||||||
// read-view (email)
|
// read-view (email)
|
||||||
query: z.string().optional().describe("For read-view on email: runs a LIVE Gmail search over the user's ENTIRE mailbox (not just synced mail) via the Gmail API. Supports full Gmail search operators: from:, to:, subject:, before:/after:, has:attachment, quoted phrases, OR, etc. Omit to list the latest important inbox threads."),
|
query: z.string().optional().describe("For read-view on email: runs a LIVE Gmail search over the user's ENTIRE mailbox (not just synced mail) via the Gmail API. Supports full Gmail search operators: from:, to:, subject:, before:/after:, has:attachment, quoted phrases, OR, etc. Omit to list the latest important inbox threads."),
|
||||||
limit: z.number().int().min(1).max(50).optional().describe("For read-view: max items to return (default 15)"),
|
limit: z.number().int().min(1).max(50).optional().describe("For read-view: max items to return (default 15)"),
|
||||||
|
|
@ -144,10 +145,33 @@ export const appNavigationTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
}));
|
}));
|
||||||
return { success: true, action: 'read-view', view, sessions };
|
return { success: true, action: 'read-view', view, sessions };
|
||||||
}
|
}
|
||||||
|
case 'apps': {
|
||||||
|
// Installed/local Rowboat apps — the copilot uses this to
|
||||||
|
// route questions to an app's data (app-read-data) and to
|
||||||
|
// surface the app (open-app). Generic: apps are matched by
|
||||||
|
// their own name/description, nothing app-specific here.
|
||||||
|
const summaries = await listApps();
|
||||||
|
const apps = await Promise.all(summaries.slice(0, limit).map(async (a) => {
|
||||||
|
let dataFiles: string[] = [];
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(path.join(WorkDir, 'apps', a.folder, 'data'), { withFileTypes: true });
|
||||||
|
dataFiles = entries.filter((e) => e.isFile()).map((e) => e.name).slice(0, 10);
|
||||||
|
} catch { /* no data dir */ }
|
||||||
|
return {
|
||||||
|
folder: a.folder,
|
||||||
|
name: a.manifest?.name ?? a.folder,
|
||||||
|
description: a.manifest?.description ?? '',
|
||||||
|
kind: a.kind,
|
||||||
|
dataFiles,
|
||||||
|
agentSlugs: a.agentSlugs,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return { success: true, action: 'read-view', view, apps };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `read-view supports: email, bg-tasks, chat-history. For notes/meetings/live-notes use the file-* tools (they are files under the workspace); for other views use open-view and describe what you need.`,
|
error: `read-view supports: email, bg-tasks, chat-history, apps. For notes/meetings/live-notes use the file-* tools (they are files under the workspace); for other views use open-view and describe what you need.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -287,7 +311,71 @@ export const appNavigationTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
// ============================================================================,
|
// ============================================================================,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cap what a data file read returns to the model — app data can be large
|
||||||
|
// (feeds, series); the copilot needs enough to answer, not the whole store.
|
||||||
|
const APP_READ_DATA_MAX_CHARS = 50_000;
|
||||||
|
|
||||||
export const appDataTools: z.infer<typeof BuiltinToolsSchema> = {
|
export const appDataTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
|
'app-read-data': {
|
||||||
|
description: "Read a Rowboat App's data file — the JSON its background agent maintains and its frontend renders. THE way to answer questions an installed app already tracks (fresh, no API calls): find the app via app-navigation read-view apps, read its data file, answer from it. Omit `file` to list the files under the app's data/.",
|
||||||
|
inputSchema: z.object({
|
||||||
|
appFolder: z.string().describe('The app folder slug under ~/.rowboat/apps.'),
|
||||||
|
file: z.string().optional().describe("Path relative to the app's data/ directory, e.g. \"data.json\". Omit to list available files."),
|
||||||
|
}),
|
||||||
|
execute: async ({ appFolder, file }: { appFolder: string; file?: string }) => {
|
||||||
|
try {
|
||||||
|
const dir = path.join(WorkDir, 'apps', appFolder);
|
||||||
|
try {
|
||||||
|
RowboatAppManifestSchema.parse(JSON.parse(await fs.readFile(path.join(dir, 'rowboat-app.json'), 'utf-8')));
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: `No app "${appFolder}" (missing or invalid rowboat-app.json).` };
|
||||||
|
}
|
||||||
|
const dataRoot = path.join(dir, 'data');
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dataRoot, { withFileTypes: true });
|
||||||
|
const files = await Promise.all(entries.filter((e) => e.isFile()).map(async (e) => {
|
||||||
|
const stat = await fs.stat(path.join(dataRoot, e.name)).catch(() => null);
|
||||||
|
return { file: e.name, size: stat?.size ?? 0, mtime: stat ? new Date(stat.mtimeMs).toISOString() : '' };
|
||||||
|
}));
|
||||||
|
return { success: true, appFolder, files };
|
||||||
|
} catch {
|
||||||
|
return { success: true, appFolder, files: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same path confinement rules as app-set-data / the data API.
|
||||||
|
const relNorm = path.posix.normalize(file).replace(/^\/+/, '');
|
||||||
|
if (!relNorm || relNorm === '.' || relNorm.startsWith('..') || relNorm.includes('\0') || relNorm.includes('\\')) {
|
||||||
|
return { success: false, error: `invalid file path: ${file}` };
|
||||||
|
}
|
||||||
|
const abs = path.resolve(dataRoot, relNorm);
|
||||||
|
if (abs !== dataRoot && !abs.startsWith(dataRoot + path.sep)) {
|
||||||
|
return { success: false, error: `file path escapes data/: ${file}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await fs.readFile(abs, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: `no such data file: ${relNorm} (omit \`file\` to list what exists)` };
|
||||||
|
}
|
||||||
|
const truncated = text.length > APP_READ_DATA_MAX_CHARS;
|
||||||
|
if (truncated) text = text.slice(0, APP_READ_DATA_MAX_CHARS);
|
||||||
|
// Parsed JSON is easiest for the model to reason over; fall back
|
||||||
|
// to raw text for non-JSON (or truncated-mid-JSON) content.
|
||||||
|
if (!truncated) {
|
||||||
|
try {
|
||||||
|
return { success: true, appFolder, file: relNorm, data: JSON.parse(text) as unknown };
|
||||||
|
} catch { /* not JSON — return as text */ }
|
||||||
|
}
|
||||||
|
return { success: true, appFolder, file: relNorm, text, ...(truncated ? { truncated: true } : {}) };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
'app-set-data': {
|
'app-set-data': {
|
||||||
description: "Write a Rowboat App's data file — JSON its frontend reads via GET /_rowboat/data/<file>. Deterministic: you supply the content, code handles the path, atomicity (temp→rename), and the app's dataContracts validation. This is how a background task refreshes an app's data — the agent RETURNS the data; never hand-write files under apps/.",
|
description: "Write a Rowboat App's data file — JSON its frontend reads via GET /_rowboat/data/<file>. Deterministic: you supply the content, code handles the path, atomicity (temp→rename), and the app's dataContracts validation. This is how a background task refreshes an app's data — the agent RETURNS the data; never hand-write files under apps/.",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,38 @@ function slugFromAbsolutePath(absolutePath: string): { slug: string; rel: string
|
||||||
return { slug, rel: segments.slice(1).join('/') };
|
return { slug, rel: segments.slice(1).join('/') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config-change → one-shot agent run. An app writes data/config.json when the
|
||||||
|
// user changes its settings (e.g. picks the repo to track); its bundled agents
|
||||||
|
// are what turn that config into fresh data. Without this kick the user would
|
||||||
|
// stare at an empty app until the next cron tick. Generic: any app, any agent
|
||||||
|
// the app owns (app--<slug>--*). Dynamic import keeps apps/server decoupled
|
||||||
|
// from the bg-task runner at module-load time.
|
||||||
|
const agentKickTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
function scheduleAgentKick(slug: string): void {
|
||||||
|
const existing = agentKickTimers.get(slug);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
agentKickTimers.set(slug, setTimeout(() => {
|
||||||
|
agentKickTimers.delete(slug);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const tasksDir = path.join(path.dirname(APPS_DIR), 'bg-tasks');
|
||||||
|
const entries = await fsp.readdir(tasksDir).catch(() => [] as string[]);
|
||||||
|
const owned = entries.filter((e) => e.startsWith(`app--${slug}--`));
|
||||||
|
if (!owned.length) return;
|
||||||
|
const { runBackgroundTask } = await import('../background-tasks/runner.js');
|
||||||
|
for (const taskSlug of owned) {
|
||||||
|
console.log(`[Apps] ${slug}/data/config.json changed — running ${taskSlug}`);
|
||||||
|
void runBackgroundTask(taskSlug, 'manual').catch((e: unknown) => {
|
||||||
|
console.warn(`[Apps] config-change agent run failed for ${taskSlug}:`, e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Apps] config-change agent kick failed for ${slug}:`, e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 400));
|
||||||
|
}
|
||||||
|
|
||||||
async function startWatcher(): Promise<void> {
|
async function startWatcher(): Promise<void> {
|
||||||
if (watcher) return;
|
if (watcher) return;
|
||||||
const w = chokidar.watch(APPS_DIR, {
|
const w = chokidar.watch(APPS_DIR, {
|
||||||
|
|
@ -695,6 +727,9 @@ async function startWatcher(): Promise<void> {
|
||||||
if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return;
|
if (!hit || hit.rel.endsWith('.tmp') || /\.tmp-[0-9a-f]+$/.test(hit.rel)) return;
|
||||||
const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist';
|
const area: 'dist' | 'data' = hit.rel === 'data' || hit.rel.startsWith('data/') ? 'data' : 'dist';
|
||||||
scheduleChangeBroadcast(hit.slug, area, hit.rel);
|
scheduleChangeBroadcast(hit.slug, area, hit.rel);
|
||||||
|
if (hit.rel === 'data/config.json' && (eventName === 'add' || eventName === 'change')) {
|
||||||
|
scheduleAgentKick(hit.slug);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
w.on('error', (error: unknown) => {
|
w.on('error', (error: unknown) => {
|
||||||
console.error('[Apps] watcher error:', error);
|
console.error('[Apps] watcher error:', error);
|
||||||
|
|
@ -795,6 +830,8 @@ export async function shutdown(): Promise<void> {
|
||||||
|
|
||||||
for (const timer of reloadTimers.values()) clearTimeout(timer);
|
for (const timer of reloadTimers.values()) clearTimeout(timer);
|
||||||
reloadTimers.clear();
|
reloadTimers.clear();
|
||||||
|
for (const timer of agentKickTimers.values()) clearTimeout(timer);
|
||||||
|
agentKickTimers.clear();
|
||||||
|
|
||||||
for (const clients of eventClients.values()) {
|
for (const clients of eventClients.values()) {
|
||||||
for (const res of clients) {
|
for (const res of clients) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue