@aion0/forge 0.10.90 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +14 -6
- package/app/api/kanban/[id]/artifact/[...name]/route.ts +33 -0
- package/app/api/kanban/[id]/run/route.ts +27 -0
- package/app/api/kanban/route.ts +102 -0
- package/app/api/kanban/seed/route.ts +14 -0
- package/bin/forge-server.mjs +15 -3
- package/components/HomeView.tsx +9 -1
- package/components/KanbanBoard.tsx +354 -0
- package/lib/chat/agent-loop.ts +1 -1
- package/lib/claude-process.ts +12 -0
- package/lib/help-docs/14-kanban.md +59 -0
- package/lib/kanban/artifacts.ts +52 -0
- package/lib/kanban/executor.ts +141 -0
- package/lib/kanban/seed.ts +43 -0
- package/lib/kanban/store.ts +219 -0
- package/lib/kanban/task-executor.ts +71 -0
- package/lib/kanban/task-listener.ts +63 -0
- package/lib/kanban/tick.ts +41 -0
- package/lib/kanban/types.ts +104 -0
- package/lib/schedules/scheduler.ts +6 -0
- package/lib/task-manager.ts +11 -1
- package/package.json +1 -1
package/lib/claude-process.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
9
11
|
import { loadSettings } from './settings';
|
|
12
|
+
import { getConfigDir } from './dirs';
|
|
10
13
|
|
|
11
14
|
export interface ClaudeMessage {
|
|
12
15
|
type: 'system' | 'assistant' | 'result';
|
|
@@ -93,6 +96,15 @@ export function sendToClaudeSession(
|
|
|
93
96
|
const env = { ...process.env };
|
|
94
97
|
delete env.CLAUDECODE;
|
|
95
98
|
|
|
99
|
+
// Corporate SSL: behind an intercepting proxy the spawned CLI fails with
|
|
100
|
+
// "SSL certificate verification failed" unless it trusts the corp CA. If the
|
|
101
|
+
// server was launched without NODE_EXTRA_CA_CERTS but a CA bundle is dropped
|
|
102
|
+
// at <configDir>/corporate-ca.pem, wire it through so the child inherits it.
|
|
103
|
+
if (!env.NODE_EXTRA_CA_CERTS) {
|
|
104
|
+
const corpCa = join(getConfigDir(), 'corporate-ca.pem');
|
|
105
|
+
if (existsSync(corpCa)) env.NODE_EXTRA_CA_CERTS = corpCa;
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
// Resolve full path so spawn works without shell for PATH lookup.
|
|
97
109
|
// Was a lazy require() — fired ReferenceError on Claude task spawn
|
|
98
110
|
// under ESM concurrent loads. Now top-level imported.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Home Kanban
|
|
2
|
+
|
|
3
|
+
The home page shows a collapsible **Kanban** board of connector-driven cards.
|
|
4
|
+
Each card runs a prompt against one connector on its own schedule and renders the
|
|
5
|
+
result as a compact widget. Nothing is hardcoded per connector — a card is just
|
|
6
|
+
*connector + prompt + period*, and the prompt's output drives what's drawn.
|
|
7
|
+
|
|
8
|
+
## Cards
|
|
9
|
+
|
|
10
|
+
- **Compact + fixed-size**: cards are small previews so the board never dominates
|
|
11
|
+
the page. Click a card to open a floating modal with the full widget.
|
|
12
|
+
- **Status dot**: grey idle · amber running · green ok · red error.
|
|
13
|
+
- **⟳ refresh** runs the card now. **✕** deletes it. **↗** opens the connector site.
|
|
14
|
+
- **+ defaults** seeds cards from your installed connectors' declared defaults
|
|
15
|
+
(idempotent — re-running won't duplicate).
|
|
16
|
+
|
|
17
|
+
## Editing a card (no connector republish)
|
|
18
|
+
|
|
19
|
+
A card's prompt and period live in Forge's local database, seeded from the
|
|
20
|
+
connector manifest's `kanban:` block only on first install / `+ defaults`. To
|
|
21
|
+
tune a card, open it and click **Edit prompt / period** — change the prompt text,
|
|
22
|
+
the refresh interval, or the run mode, then **Save & run**. This only changes the
|
|
23
|
+
local card; you never need to edit or republish the connector.
|
|
24
|
+
|
|
25
|
+
## Run modes
|
|
26
|
+
|
|
27
|
+
- **inline** (default): a fast single LLM turn using your default API profile.
|
|
28
|
+
Best for simple count/list cards.
|
|
29
|
+
- **task**: the card is dispatched as a one-shot CLI task (the same backend as
|
|
30
|
+
normal Forge tasks) that can work over many steps, call the connector via the
|
|
31
|
+
Forge MCP tools, and write the final widget to
|
|
32
|
+
`<dataDir>/kanban/<cardId>/widget.json`. Best for heavy connectors (many tools)
|
|
33
|
+
or richer, multi-step content. The task may also write supplementary files
|
|
34
|
+
(e.g. `report.md`) into the same folder and link to them from the widget via
|
|
35
|
+
`href` `/api/kanban/<cardId>/artifact/<filename>`.
|
|
36
|
+
|
|
37
|
+
Heavy connectors can declare `mode: task` per card in their manifest; you can
|
|
38
|
+
also flip any card's mode in the Edit panel.
|
|
39
|
+
|
|
40
|
+
## Declaring cards in a connector
|
|
41
|
+
|
|
42
|
+
A connector manifest may carry kanban defaults (read by Forge's seeder, ignored
|
|
43
|
+
by the connector loader — fully additive, old Forge versions skip it):
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
kanban:
|
|
47
|
+
cards:
|
|
48
|
+
- title: "My widget"
|
|
49
|
+
icon: "📊"
|
|
50
|
+
prompt: |
|
|
51
|
+
What to gather + how to summarize.
|
|
52
|
+
period_sec: 1800 # refresh cadence (default 1800)
|
|
53
|
+
mode: task # optional: 'inline' (default) | 'task'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Refresh cadence
|
|
57
|
+
|
|
58
|
+
Cards refresh on their own period via the same scheduler tick as Schedules, plus
|
|
59
|
+
manual ⟳. Snapshots are stored, so a card shows its last result until the next run.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-card artifact directory under <dataDir>/kanban/<cardId>/. A task-mode run
|
|
3
|
+
* writes its final widget to widget.json here, plus optional supplementary
|
|
4
|
+
* files (report.md, data, …) the widget can link to via the artifact route.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join, normalize } from 'node:path';
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { getDataDir } from '../dirs';
|
|
10
|
+
import type { WidgetSpec } from './types';
|
|
11
|
+
|
|
12
|
+
const WIDGET_FILE = 'widget.json';
|
|
13
|
+
|
|
14
|
+
export function cardArtifactDir(cardId: string): string {
|
|
15
|
+
return join(getDataDir(), 'kanban', cardId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ensureArtifactDir(cardId: string): string {
|
|
19
|
+
const dir = cardArtifactDir(cardId);
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function widgetFilePath(cardId: string): string {
|
|
25
|
+
return join(cardArtifactDir(cardId), WIDGET_FILE);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Remove a stale widget.json before a run so we never read a previous result. */
|
|
29
|
+
export function clearWidgetFile(cardId: string): void {
|
|
30
|
+
try { rmSync(widgetFilePath(cardId), { force: true }); } catch { /* ignore */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read + validate the widget.json a task wrote. Null if missing/invalid. */
|
|
34
|
+
export function readWidgetFile(cardId: string): WidgetSpec | null {
|
|
35
|
+
const p = widgetFilePath(cardId);
|
|
36
|
+
if (!existsSync(p)) return null;
|
|
37
|
+
try {
|
|
38
|
+
const v = JSON.parse(readFileSync(p, 'utf-8'));
|
|
39
|
+
if (v && typeof v === 'object' && Array.isArray((v as any).blocks)) return v as WidgetSpec;
|
|
40
|
+
} catch { /* invalid */ }
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve a requested artifact name to an absolute path INSIDE the card dir,
|
|
45
|
+
* or null on traversal / escape. Used by the artifact-serving route. */
|
|
46
|
+
export function safeArtifactPath(cardId: string, name: string): string | null {
|
|
47
|
+
const base = cardArtifactDir(cardId);
|
|
48
|
+
const full = normalize(join(base, name));
|
|
49
|
+
if (full !== base && !full.startsWith(base + '/')) return null; // escaped the dir
|
|
50
|
+
if (!existsSync(full)) return null;
|
|
51
|
+
return full;
|
|
52
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban card executor (P2). Runs a card's prompt as a headless,
|
|
3
|
+
* connector-scoped LLM turn: only the target connector's tools are exposed,
|
|
4
|
+
* the system prompt constrains output to a WidgetSpec JSON, and the loop
|
|
5
|
+
* dispatches any tool calls until the model returns a final widget.
|
|
6
|
+
*
|
|
7
|
+
* Reuses the chat primitives — streamLlm (LLM adapter), resolveProvider
|
|
8
|
+
* (default API profile), buildConnectorTools (tool specs), dispatchTool
|
|
9
|
+
* (connector execution) — so it stays consistent with the chat agent loop
|
|
10
|
+
* without dragging in the session machinery.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { streamLlm } from '../chat/llm';
|
|
15
|
+
import { resolveProvider, buildConnectorTools } from '../chat/agent-loop';
|
|
16
|
+
import { dispatchTool } from '../chat/tool-dispatcher';
|
|
17
|
+
import type { Message, ContentBlock } from '../chat/types';
|
|
18
|
+
import type { WidgetSpec } from './types';
|
|
19
|
+
import { getCard, setCardRunning, setCardResult, setCardError } from './store';
|
|
20
|
+
|
|
21
|
+
const MAX_TOOL_ROUNDS = 6;
|
|
22
|
+
const MAX_TOKENS = 2000;
|
|
23
|
+
|
|
24
|
+
/** The WidgetSpec schema, shared by the inline system prompt and the task-mode
|
|
25
|
+
* contract prompt so both paths produce the same shape. */
|
|
26
|
+
export const WIDGET_SCHEMA_TEXT = [
|
|
27
|
+
'A widget is a single JSON object matching:',
|
|
28
|
+
'{ "accent"?: Color, "blocks": Block[], "footer"?: string }',
|
|
29
|
+
'Color = "gray"|"red"|"amber"|"yellow"|"green"|"emerald"|"blue"|"violet"',
|
|
30
|
+
'Block is one of:',
|
|
31
|
+
' {"type":"metric_row","items":[{"label":string,"value":string|number,"color"?:Color}]}',
|
|
32
|
+
' {"type":"list","items":[{"text":string,"badge"?:string,"badgeColor"?:Color,"sub"?:string,"href"?:string,"avatar"?:string}]}',
|
|
33
|
+
' {"type":"stat","value":string|number,"label":string,"delta"?:string,"deltaColor"?:Color}',
|
|
34
|
+
' {"type":"progress","label":string,"value":number,"max":number,"color"?:Color}',
|
|
35
|
+
' {"type":"table","columns":string[],"rows":string[][]}',
|
|
36
|
+
' {"type":"badges","items":[{"text":string,"color"?:Color}]}',
|
|
37
|
+
' {"type":"callout","text":string,"color"?:Color,"icon"?:string} // highlighted alert box, e.g. "2 items overdue"',
|
|
38
|
+
' {"type":"text","markdown":string} // a short **bold** line works as a section header; supports bold/`code`/[links](url)/- bullets',
|
|
39
|
+
'',
|
|
40
|
+
'Guidance: lead with a metric_row (counts) and/or a callout for anything urgent. Use a progress block for "X of Y done / release readiness" — it renders as a nice bar. For people, set list item "avatar" to their initials (e.g. "YC"). Group long lists under short **bold** text headers.',
|
|
41
|
+
'BE TERSE: the card is small. List item "text" should be short (aim ≤ 6 words / fits one line — the compact card truncates the rest); put any extra detail in "sub", also short. Prefer keywords over sentences. metric labels ≤ 1-2 words.',
|
|
42
|
+
].join('\n');
|
|
43
|
+
|
|
44
|
+
function systemPrompt(): string {
|
|
45
|
+
return [
|
|
46
|
+
'You generate a Forge dashboard "kanban card". Use the available connector tools to gather exactly what the user prompt asks for, then summarize into a compact, glanceable widget.',
|
|
47
|
+
'',
|
|
48
|
+
'Output ONLY a single JSON object (no prose, no markdown fences) matching:',
|
|
49
|
+
WIDGET_SCHEMA_TEXT.split('\n').slice(1).join('\n'),
|
|
50
|
+
'',
|
|
51
|
+
'Keep it glanceable: short labels, at most ~6 list items. Choose block types that fit the data (e.g. counts → metric_row, items → list). If a tool errors or returns nothing, still return a valid widget (a text block explaining the state).',
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function msg(role: 'user' | 'assistant', cardId: string, blocks: ContentBlock[]): Message {
|
|
56
|
+
return { id: randomUUID(), session_id: `kanban:${cardId}`, role, blocks, ts: Date.now() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run one card. Persists the result/error snapshot; never throws. */
|
|
60
|
+
export async function runKanbanCard(cardId: string): Promise<{ ok: boolean; error?: string }> {
|
|
61
|
+
const card = getCard(cardId);
|
|
62
|
+
if (!card) return { ok: false, error: 'card not found' };
|
|
63
|
+
|
|
64
|
+
const provider = resolveProvider(null, null);
|
|
65
|
+
if ('error' in provider) {
|
|
66
|
+
setCardError(cardId, `provider: ${provider.error}`);
|
|
67
|
+
return { ok: false, error: provider.error };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tools = buildConnectorTools().filter((t) => t.name.split('.')[0] === card.connectorId);
|
|
71
|
+
if (tools.length === 0) {
|
|
72
|
+
setCardError(cardId, `connector '${card.connectorId}' has no usable tools (installed + enabled?)`);
|
|
73
|
+
return { ok: false, error: 'no connector tools' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setCardRunning(cardId);
|
|
77
|
+
const history: Message[] = [msg('user', cardId, [{ type: 'text', text: card.prompt }])];
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
81
|
+
const res = await streamLlm(
|
|
82
|
+
{
|
|
83
|
+
provider: provider.type,
|
|
84
|
+
apiKey: provider.apiKey,
|
|
85
|
+
baseUrl: provider.baseUrl,
|
|
86
|
+
model: provider.model,
|
|
87
|
+
system: systemPrompt(),
|
|
88
|
+
history,
|
|
89
|
+
tools,
|
|
90
|
+
maxTokens: MAX_TOKENS,
|
|
91
|
+
},
|
|
92
|
+
{ onTextDelta: () => {}, onToolUse: () => {} },
|
|
93
|
+
);
|
|
94
|
+
history.push(msg('assistant', cardId, res.content));
|
|
95
|
+
|
|
96
|
+
const toolUses = res.content.filter(
|
|
97
|
+
(b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use',
|
|
98
|
+
);
|
|
99
|
+
if (res.stopReason === 'tool_use' && toolUses.length > 0) {
|
|
100
|
+
const results: ContentBlock[] = [];
|
|
101
|
+
for (const tu of toolUses) {
|
|
102
|
+
const r = await dispatchTool({ id: tu.id, name: tu.name, input: tu.input });
|
|
103
|
+
results.push({ type: 'tool_result', tool_use_id: tu.id, content: r.content, is_error: r.is_error });
|
|
104
|
+
}
|
|
105
|
+
history.push(msg('user', cardId, results));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const text = res.content
|
|
110
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
111
|
+
.map((b) => b.text)
|
|
112
|
+
.join('')
|
|
113
|
+
.trim();
|
|
114
|
+
const spec = parseWidget(text);
|
|
115
|
+
if (!spec) {
|
|
116
|
+
setCardError(cardId, 'LLM did not return a valid widget JSON');
|
|
117
|
+
return { ok: false, error: 'invalid widget' };
|
|
118
|
+
}
|
|
119
|
+
setCardResult(cardId, spec);
|
|
120
|
+
return { ok: true };
|
|
121
|
+
}
|
|
122
|
+
setCardError(cardId, `exceeded ${MAX_TOOL_ROUNDS} tool rounds without a final widget`);
|
|
123
|
+
return { ok: false, error: 'max rounds' };
|
|
124
|
+
} catch (e) {
|
|
125
|
+
setCardError(cardId, (e as Error).message);
|
|
126
|
+
return { ok: false, error: (e as Error).message };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Tolerant extract: strip optional code fences, take the outermost {...}. */
|
|
131
|
+
export function parseWidget(text: string): WidgetSpec | null {
|
|
132
|
+
const stripped = text.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
|
133
|
+
const start = stripped.indexOf('{');
|
|
134
|
+
const end = stripped.lastIndexOf('}');
|
|
135
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
136
|
+
try {
|
|
137
|
+
const v = JSON.parse(stripped.slice(start, end + 1));
|
|
138
|
+
if (v && typeof v === 'object' && Array.isArray((v as any).blocks)) return v as WidgetSpec;
|
|
139
|
+
} catch { /* not valid JSON */ }
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed kanban cards from connector-declared defaults. Each installed + enabled
|
|
3
|
+
* connector whose definition carries a `kanban` block (and isn't opted out via
|
|
4
|
+
* enabled:false) contributes its declared cards. Idempotent: a card is keyed by
|
|
5
|
+
* `<connectorId>#<index>` (seed_key) so re-seeding never duplicates, and a card
|
|
6
|
+
* the user later deleted stays deleted (its seed_key already exists only if the
|
|
7
|
+
* row exists — deleted rows are re-seeded; that's acceptable for a "restore
|
|
8
|
+
* defaults" action). The cards are DECLARATIVE — Forge core hardcodes nothing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { listInstalledConnectors } from '../connectors/registry';
|
|
12
|
+
import { createCard, existingSeedKeys } from './store';
|
|
13
|
+
import type { KanbanCard, WithKanban } from './types';
|
|
14
|
+
|
|
15
|
+
export function seedKanbanFromConnectors(): { created: KanbanCard[] } {
|
|
16
|
+
const have = existingSeedKeys();
|
|
17
|
+
const created: KanbanCard[] = [];
|
|
18
|
+
|
|
19
|
+
for (const inst of listInstalledConnectors()) {
|
|
20
|
+
if (!inst.enabled) continue;
|
|
21
|
+
// Read the kanban block structurally — the connector loader never parses it,
|
|
22
|
+
// so the core connector types stay free of any kanban coupling.
|
|
23
|
+
const def = inst.definition as unknown as WithKanban;
|
|
24
|
+
const kb = def.kanban;
|
|
25
|
+
if (!kb || kb.enabled === false) continue;
|
|
26
|
+
const cards = kb.cards || [];
|
|
27
|
+
cards.forEach((decl, i) => {
|
|
28
|
+
if (!decl?.prompt || !decl?.title) return;
|
|
29
|
+
const seedKey = `${def.id}#${i}`;
|
|
30
|
+
if (have.has(seedKey)) return;
|
|
31
|
+
created.push(createCard({
|
|
32
|
+
connectorId: def.id,
|
|
33
|
+
title: decl.title,
|
|
34
|
+
prompt: decl.prompt,
|
|
35
|
+
icon: decl.icon ?? def.icon ?? null,
|
|
36
|
+
periodSec: typeof decl.period_sec === 'number' && decl.period_sec > 0 ? decl.period_sec : undefined,
|
|
37
|
+
execMode: decl.mode === 'task' ? 'task' : 'inline',
|
|
38
|
+
seedKey,
|
|
39
|
+
}));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { created };
|
|
43
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban dashboard SQLite store — table `kanban_cards`. Mirrors the
|
|
3
|
+
* lib/schedules/store.ts conventions (single workflow.db, ensureSchema guard,
|
|
4
|
+
* row mappers, ISO time). Card execution (connector-scoped LLM turn → WidgetSpec)
|
|
5
|
+
* lands in P2; this file is pure CRUD + result/status setters.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { getDb } from '../../src/core/db/database';
|
|
10
|
+
import { getDbPath } from '../../src/config';
|
|
11
|
+
import { toIsoUTC } from '../iso-time';
|
|
12
|
+
import type {
|
|
13
|
+
KanbanCard,
|
|
14
|
+
KanbanCardStatus,
|
|
15
|
+
WidgetSpec,
|
|
16
|
+
CreateKanbanCardInput,
|
|
17
|
+
UpdateKanbanCardInput,
|
|
18
|
+
} from './types';
|
|
19
|
+
|
|
20
|
+
function db() { return getDb(getDbPath()); }
|
|
21
|
+
|
|
22
|
+
let ensured = false;
|
|
23
|
+
export function ensureSchema(): void {
|
|
24
|
+
if (ensured) return;
|
|
25
|
+
db().exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS kanban_cards (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
connector_id TEXT NOT NULL,
|
|
29
|
+
title TEXT NOT NULL,
|
|
30
|
+
icon TEXT,
|
|
31
|
+
prompt TEXT NOT NULL,
|
|
32
|
+
period_sec INTEGER NOT NULL DEFAULT 1800,
|
|
33
|
+
card_order INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
35
|
+
last_result TEXT,
|
|
36
|
+
last_run_at TEXT,
|
|
37
|
+
last_error TEXT,
|
|
38
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
39
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
40
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_kanban_order ON kanban_cards(card_order ASC);
|
|
43
|
+
`);
|
|
44
|
+
// Idempotent add: seed_key marks a card auto-created from a connector's
|
|
45
|
+
// declared default (so re-seeding doesn't duplicate). NULL = user-created.
|
|
46
|
+
try { db().exec('ALTER TABLE kanban_cards ADD COLUMN seed_key TEXT'); } catch { /* exists */ }
|
|
47
|
+
// Execution mode (inline default | task) + the task it's tied to in task mode.
|
|
48
|
+
try { db().exec("ALTER TABLE kanban_cards ADD COLUMN exec_mode TEXT NOT NULL DEFAULT 'inline'"); } catch { /* exists */ }
|
|
49
|
+
try { db().exec('ALTER TABLE kanban_cards ADD COLUMN task_id TEXT'); } catch { /* exists */ }
|
|
50
|
+
ensured = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** seed_keys already present — lets seeding skip cards it already created. */
|
|
54
|
+
export function existingSeedKeys(): Set<string> {
|
|
55
|
+
ensureSchema();
|
|
56
|
+
const rows = db().prepare(
|
|
57
|
+
"SELECT seed_key FROM kanban_cards WHERE seed_key IS NOT NULL",
|
|
58
|
+
).all() as { seed_key: string }[];
|
|
59
|
+
return new Set(rows.map((r) => r.seed_key));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Row mapper ───────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function safeWidget(raw: unknown): WidgetSpec | null {
|
|
65
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
66
|
+
try {
|
|
67
|
+
const v = JSON.parse(raw);
|
|
68
|
+
return v && typeof v === 'object' && Array.isArray((v as any).blocks) ? (v as WidgetSpec) : null;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rowToCard(r: any): KanbanCard {
|
|
73
|
+
return {
|
|
74
|
+
id: r.id,
|
|
75
|
+
connectorId: r.connector_id,
|
|
76
|
+
title: r.title,
|
|
77
|
+
icon: r.icon ?? null,
|
|
78
|
+
prompt: r.prompt,
|
|
79
|
+
periodSec: typeof r.period_sec === 'number' ? r.period_sec : 1800,
|
|
80
|
+
order: typeof r.card_order === 'number' ? r.card_order : 0,
|
|
81
|
+
enabled: !!r.enabled,
|
|
82
|
+
execMode: r.exec_mode === 'task' ? 'task' : 'inline',
|
|
83
|
+
taskId: r.task_id ?? null,
|
|
84
|
+
lastResult: safeWidget(r.last_result),
|
|
85
|
+
lastRunAt: toIsoUTC(r.last_run_at),
|
|
86
|
+
lastError: r.last_error ?? null,
|
|
87
|
+
status: (r.status as KanbanCardStatus) || 'idle',
|
|
88
|
+
createdAt: toIsoUTC(r.created_at) || r.created_at,
|
|
89
|
+
updatedAt: toIsoUTC(r.updated_at) || r.updated_at,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── CRUD ─────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export function listCards(): KanbanCard[] {
|
|
96
|
+
ensureSchema();
|
|
97
|
+
const rows = db().prepare(
|
|
98
|
+
'SELECT * FROM kanban_cards ORDER BY card_order ASC, created_at ASC',
|
|
99
|
+
).all() as any[];
|
|
100
|
+
return rows.map(rowToCard);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getCard(id: string): KanbanCard | null {
|
|
104
|
+
ensureSchema();
|
|
105
|
+
const r = db().prepare('SELECT * FROM kanban_cards WHERE id = ?').get(id) as any;
|
|
106
|
+
return r ? rowToCard(r) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createCard(input: CreateKanbanCardInput): KanbanCard {
|
|
110
|
+
ensureSchema();
|
|
111
|
+
const id = `kb_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
|
|
112
|
+
// Default order = append to the end.
|
|
113
|
+
const maxOrder = (db().prepare('SELECT MAX(card_order) AS m FROM kanban_cards').get() as { m: number | null }).m;
|
|
114
|
+
const order = typeof input.order === 'number' ? input.order : (maxOrder ?? -1) + 1;
|
|
115
|
+
db().prepare(`
|
|
116
|
+
INSERT INTO kanban_cards (id, connector_id, title, icon, prompt, period_sec, card_order, enabled, seed_key, exec_mode)
|
|
117
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
118
|
+
`).run(
|
|
119
|
+
id,
|
|
120
|
+
input.connectorId,
|
|
121
|
+
input.title,
|
|
122
|
+
input.icon ?? null,
|
|
123
|
+
input.prompt,
|
|
124
|
+
input.periodSec ?? 1800,
|
|
125
|
+
order,
|
|
126
|
+
input.enabled === false ? 0 : 1,
|
|
127
|
+
input.seedKey ?? null,
|
|
128
|
+
input.execMode === 'task' ? 'task' : 'inline',
|
|
129
|
+
);
|
|
130
|
+
return getCard(id)!;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function updateCard(id: string, patch: UpdateKanbanCardInput): boolean {
|
|
134
|
+
ensureSchema();
|
|
135
|
+
const sets: string[] = [];
|
|
136
|
+
const vals: any[] = [];
|
|
137
|
+
if (patch.title !== undefined) { sets.push('title = ?'); vals.push(patch.title); }
|
|
138
|
+
if (patch.prompt !== undefined) { sets.push('prompt = ?'); vals.push(patch.prompt); }
|
|
139
|
+
if (patch.icon !== undefined) { sets.push('icon = ?'); vals.push(patch.icon ?? null); }
|
|
140
|
+
if (patch.periodSec !== undefined) { sets.push('period_sec = ?'); vals.push(patch.periodSec); }
|
|
141
|
+
if (patch.order !== undefined) { sets.push('card_order = ?'); vals.push(patch.order); }
|
|
142
|
+
if (patch.enabled !== undefined) { sets.push('enabled = ?'); vals.push(patch.enabled ? 1 : 0); }
|
|
143
|
+
if (patch.execMode !== undefined) { sets.push('exec_mode = ?'); vals.push(patch.execMode === 'task' ? 'task' : 'inline'); }
|
|
144
|
+
if (sets.length === 0) return false;
|
|
145
|
+
sets.push("updated_at = datetime('now')");
|
|
146
|
+
vals.push(id);
|
|
147
|
+
return db().prepare(`UPDATE kanban_cards SET ${sets.join(', ')} WHERE id = ?`).run(...vals).changes > 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function deleteCard(id: string): boolean {
|
|
151
|
+
ensureSchema();
|
|
152
|
+
return db().prepare('DELETE FROM kanban_cards WHERE id = ?').run(id).changes > 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Persist a batch of {id, order} — used by drag-to-reorder. */
|
|
156
|
+
export function reorderCards(order: { id: string; order: number }[]): void {
|
|
157
|
+
ensureSchema();
|
|
158
|
+
const stmt = db().prepare("UPDATE kanban_cards SET card_order = ?, updated_at = datetime('now') WHERE id = ?");
|
|
159
|
+
const tx = db().transaction((rows: { id: string; order: number }[]) => {
|
|
160
|
+
for (const row of rows) stmt.run(row.order, row.id);
|
|
161
|
+
});
|
|
162
|
+
tx(order);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Cards due to run: enabled, not already running, and either never run or
|
|
166
|
+
* past their period. Used by the kanban tick. */
|
|
167
|
+
export function listDueCards(): KanbanCard[] {
|
|
168
|
+
ensureSchema();
|
|
169
|
+
const rows = db().prepare(`
|
|
170
|
+
SELECT * FROM kanban_cards
|
|
171
|
+
WHERE enabled = 1 AND status != 'running'
|
|
172
|
+
AND (last_run_at IS NULL
|
|
173
|
+
OR datetime(last_run_at, '+' || period_sec || ' seconds') <= datetime('now'))
|
|
174
|
+
ORDER BY (last_run_at IS NULL) DESC, last_run_at ASC
|
|
175
|
+
`).all() as any[];
|
|
176
|
+
return rows.map(rowToCard);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Run status / result (used by P2 executor + manual refresh) ──
|
|
180
|
+
|
|
181
|
+
export function setCardRunning(id: string): void {
|
|
182
|
+
ensureSchema();
|
|
183
|
+
db().prepare("UPDATE kanban_cards SET status = 'running', updated_at = datetime('now') WHERE id = ?").run(id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Mark a card running AND tie it to the dispatched task (task mode). */
|
|
187
|
+
export function setCardTask(id: string, taskId: string): void {
|
|
188
|
+
ensureSchema();
|
|
189
|
+
db().prepare("UPDATE kanban_cards SET task_id = ?, status = 'running', updated_at = datetime('now') WHERE id = ?").run(taskId, id);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Find the card a task was dispatched for (used by the terminal listener). */
|
|
193
|
+
export function getCardByTaskId(taskId: string): KanbanCard | null {
|
|
194
|
+
ensureSchema();
|
|
195
|
+
const r = db().prepare('SELECT * FROM kanban_cards WHERE task_id = ?').get(taskId) as any;
|
|
196
|
+
return r ? rowToCard(r) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Store a successful run's WidgetSpec snapshot. */
|
|
200
|
+
export function setCardResult(id: string, result: WidgetSpec): void {
|
|
201
|
+
ensureSchema();
|
|
202
|
+
db().prepare(`
|
|
203
|
+
UPDATE kanban_cards
|
|
204
|
+
SET last_result = ?, last_run_at = datetime('now'), last_error = NULL,
|
|
205
|
+
status = 'ok', updated_at = datetime('now')
|
|
206
|
+
WHERE id = ?
|
|
207
|
+
`).run(JSON.stringify(result), id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Record a failed run — keep the previous snapshot (last_result) intact. */
|
|
211
|
+
export function setCardError(id: string, error: string): void {
|
|
212
|
+
ensureSchema();
|
|
213
|
+
db().prepare(`
|
|
214
|
+
UPDATE kanban_cards
|
|
215
|
+
SET last_error = ?, last_run_at = datetime('now'), status = 'error',
|
|
216
|
+
updated_at = datetime('now')
|
|
217
|
+
WHERE id = ?
|
|
218
|
+
`).run(error, id);
|
|
219
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban task-mode executor. For heavy / multi-step cards the inline streamLlm
|
|
3
|
+
* turn (executor.ts) chokes on big tool schemas; instead we dispatch a one-shot
|
|
4
|
+
* CLI task (the relay backend) that can work over many steps, then writes the
|
|
5
|
+
* final widget to <dataDir>/kanban/<cardId>/widget.json. The terminal listener
|
|
6
|
+
* (task-listener.ts) reads that file back into the card. Mirrors the Schedules
|
|
7
|
+
* V3 prompt-body dispatch pattern.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createTask } from '../task-manager';
|
|
11
|
+
import { ensureScratchProject, getProjectInfo, SCRATCH_PROJECT_NAME } from '../projects';
|
|
12
|
+
import { WIDGET_SCHEMA_TEXT } from './executor';
|
|
13
|
+
import { getCard, setCardTask, setCardError } from './store';
|
|
14
|
+
import { ensureArtifactDir, clearWidgetFile, widgetFilePath } from './artifacts';
|
|
15
|
+
|
|
16
|
+
/** Compose the task prompt: the card's own prompt + a fixed contract telling the
|
|
17
|
+
* agent how to reach the connector and where to write the widget. */
|
|
18
|
+
function composePrompt(connectorId: string, cardId: string, userPrompt: string): string {
|
|
19
|
+
const outFile = widgetFilePath(cardId);
|
|
20
|
+
return [
|
|
21
|
+
`You are generating a Forge home "kanban card" for the connector "${connectorId}".`,
|
|
22
|
+
'',
|
|
23
|
+
'TASK:',
|
|
24
|
+
userPrompt,
|
|
25
|
+
'',
|
|
26
|
+
'HOW TO GET DATA:',
|
|
27
|
+
`- Call connector tools with mcp__forge__call_connector, passing plugin_id="${connectorId}", tool="<toolName>", and the tool's args. The TASK above names the tools + args to use.`,
|
|
28
|
+
'- Do NOT call mcp__forge__list_connectors — its output is huge and will waste the run. You already know plugin_id; call the named tools directly.',
|
|
29
|
+
'- You may work in multiple steps and refine. Take the time to produce accurate, well-organized content.',
|
|
30
|
+
'',
|
|
31
|
+
'OUTPUT (required):',
|
|
32
|
+
`- Write the FINAL widget as a single JSON object to this exact path: ${outFile}`,
|
|
33
|
+
'- The JSON MUST match this schema (no markdown fences inside the file):',
|
|
34
|
+
WIDGET_SCHEMA_TEXT,
|
|
35
|
+
'- Keep it glanceable: short labels, group with the block types that fit the data.',
|
|
36
|
+
`- You MAY also write supplementary files into the same directory (e.g. report.md) and link to them from list/badge "href" as "/api/kanban/${cardId}/artifact/<filename>".`,
|
|
37
|
+
'- If a tool errors or returns nothing, still write a valid widget (a text block explaining the state).',
|
|
38
|
+
'',
|
|
39
|
+
`Success = ${outFile} exists and is valid JSON. Write it as your final action.`,
|
|
40
|
+
].join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Dispatch a task-mode run. Returns the task id, or sets card error + returns
|
|
44
|
+
* null on dispatch failure. Never throws. */
|
|
45
|
+
export function runKanbanCardViaTask(cardId: string): string | null {
|
|
46
|
+
const card = getCard(cardId);
|
|
47
|
+
if (!card) return null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
ensureScratchProject();
|
|
51
|
+
const project = getProjectInfo(SCRATCH_PROJECT_NAME);
|
|
52
|
+
if (!project) { setCardError(cardId, 'scratch project unavailable'); return null; }
|
|
53
|
+
|
|
54
|
+
ensureArtifactDir(cardId);
|
|
55
|
+
clearWidgetFile(cardId); // never read a stale widget from a prior run
|
|
56
|
+
|
|
57
|
+
const task = createTask({
|
|
58
|
+
projectName: project.name,
|
|
59
|
+
projectPath: project.path,
|
|
60
|
+
prompt: composePrompt(card.connectorId, cardId, card.prompt),
|
|
61
|
+
conversationId: '', // one-shot, fresh conversation
|
|
62
|
+
// agent omitted → default CLI agent (the working relay backend)
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
setCardTask(cardId, task.id);
|
|
66
|
+
return task.id;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
setCardError(cardId, `task dispatch failed: ${(e as Error).message}`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|