@aion0/forge 0.10.89 → 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.
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Settles task-mode kanban cards. Two paths, same logic:
3
+ * - event: a global task listener fires on terminal status.
4
+ * - reconcile: the tick / API sweep catches cards stuck 'running' whose task
5
+ * already reached terminal (missed events from races / multi-worker / restart).
6
+ *
7
+ * The widget FILE wins over the task's exit code: the CLI often writes a valid
8
+ * widget.json and then exits non-zero ("Process exited with code 1") — that is
9
+ * still a success. Mirrors Schedules V3's settle + zombie reconciliation.
10
+ */
11
+
12
+ import { onTaskEvent, getTask } from '../task-manager';
13
+ import { getCardByTaskId, setCardResult, setCardError } from './store';
14
+ import { readWidgetFile } from './artifacts';
15
+ import { parseWidget } from './executor';
16
+ import type { KanbanCard } from './types';
17
+
18
+ const TERMINAL = new Set(['done', 'failed', 'cancelled', 'error']);
19
+ let installed = false;
20
+
21
+ export function installKanbanTaskListener(): void {
22
+ if (installed) return;
23
+ installed = true;
24
+ onTaskEvent((taskId, event, data) => {
25
+ if (event !== 'status' || !TERMINAL.has(String(data))) return;
26
+ try { settleKanbanTask(taskId); } catch (e) {
27
+ console.error(`[kanban] settle task ${taskId} failed: ${(e as Error).message}`);
28
+ }
29
+ });
30
+ }
31
+
32
+ /** Settle one card from its (terminal) task. File wins over exit code. */
33
+ export function settleKanbanTask(taskId: string): void {
34
+ const card = getCardByTaskId(taskId);
35
+ if (!card) return; // not a kanban task
36
+
37
+ const fromFile = readWidgetFile(card.id);
38
+ if (fromFile) { setCardResult(card.id, fromFile); return; }
39
+
40
+ const t = getTask(taskId);
41
+ const fromSummary = t?.resultSummary ? parseWidget(t.resultSummary) : null;
42
+ if (fromSummary) { setCardResult(card.id, fromSummary); return; }
43
+
44
+ setCardError(card.id, t?.error || 'task finished but wrote no widget.json');
45
+ }
46
+
47
+ /** Backstop for missed terminal events: settle any task-mode card stuck in
48
+ * 'running' whose task is already terminal (or whose task row is gone). */
49
+ export function reconcileRunningKanbanTaskCards(cards: KanbanCard[]): void {
50
+ for (const c of cards) {
51
+ if (c.execMode !== 'task' || c.status !== 'running' || !c.taskId) continue;
52
+ const t = getTask(c.taskId);
53
+ if (!t) {
54
+ // Task row vanished but a widget may still be on disk — prefer it.
55
+ const w = readWidgetFile(c.id);
56
+ if (w) setCardResult(c.id, w); else setCardError(c.id, 'task row gone');
57
+ continue;
58
+ }
59
+ if (TERMINAL.has(String(t.status))) {
60
+ try { settleKanbanTask(c.taskId); } catch { /* keep going */ }
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Kanban tick — find due cards and run them. Driven by the same loop that
3
+ * ticks schedules (period reuse, per-card cadence). Inline cards run in-process
4
+ * (sequential, a single in-flight guard prevents overlap); task-mode cards are
5
+ * dispatched as one-shot tasks (fire-and-forget — the task listener settles
6
+ * them) with a small per-tick cap so the board can't flood the task queue.
7
+ */
8
+
9
+ import { listCards, listDueCards } from './store';
10
+ import { runKanbanCard } from './executor';
11
+ import { runKanbanCardViaTask } from './task-executor';
12
+ import { installKanbanTaskListener, reconcileRunningKanbanTaskCards } from './task-listener';
13
+
14
+ let running = false;
15
+ const MAX_TASK_DISPATCH_PER_TICK = 2;
16
+
17
+ export async function kanbanTick(): Promise<void> {
18
+ if (running) return;
19
+ running = true;
20
+ installKanbanTaskListener(); // idempotent; ensures task cards get settled
21
+ reconcileRunningKanbanTaskCards(listCards()); // backstop for missed events
22
+ try {
23
+ const due = listDueCards();
24
+ let taskDispatched = 0;
25
+ for (const card of due) {
26
+ try {
27
+ if (card.execMode === 'task') {
28
+ if (taskDispatched >= MAX_TASK_DISPATCH_PER_TICK) continue; // next tick
29
+ runKanbanCardViaTask(card.id); // async; listener settles on terminal
30
+ taskDispatched++;
31
+ } else {
32
+ await runKanbanCard(card.id);
33
+ }
34
+ } catch (e) {
35
+ console.warn(`[kanban] card ${card.id} (${card.connectorId}) failed: ${(e as Error).message}`);
36
+ }
37
+ }
38
+ } finally {
39
+ running = false;
40
+ }
41
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Kanban dashboard — types. A card = one connector + a prompt + a period.
3
+ * The card's prompt runs (connector-scoped LLM turn) and must return a
4
+ * WidgetSpec, which a generic renderer draws. See
5
+ * obsidian forge/forge-kanban-dashboard-design.md.
6
+ */
7
+
8
+ export type WidgetColor =
9
+ | 'gray' | 'red' | 'amber' | 'yellow' | 'green' | 'emerald' | 'blue' | 'violet';
10
+
11
+ /** The constrained block vocabulary the card LLM may emit. The renderer maps
12
+ * each to a component; unknown types degrade to 'text' so it never crashes.
13
+ * Deliberately NOT free HTML — keeps it XSS/injection-safe and visually
14
+ * consistent. Extend the union (+ renderer) to add new block kinds. */
15
+ export type WidgetBlock =
16
+ | { type: 'metric_row'; items: { label: string; value: string | number; color?: WidgetColor }[] }
17
+ | { type: 'list'; items: { text: string; badge?: string; badgeColor?: WidgetColor; sub?: string; href?: string; avatar?: string }[] }
18
+ | { type: 'stat'; value: string | number; label: string; delta?: string; deltaColor?: WidgetColor }
19
+ | { type: 'progress'; label: string; value: number; max: number; color?: WidgetColor }
20
+ | { type: 'table'; columns: string[]; rows: string[][] }
21
+ | { type: 'badges'; items: { text: string; color?: WidgetColor }[] }
22
+ | { type: 'callout'; text: string; color?: WidgetColor; icon?: string }
23
+ | { type: 'text'; markdown: string };
24
+
25
+ export interface WidgetSpec {
26
+ accent?: WidgetColor;
27
+ blocks: WidgetBlock[];
28
+ footer?: string;
29
+ }
30
+
31
+ export type KanbanCardStatus = 'idle' | 'running' | 'ok' | 'error';
32
+
33
+ /** How a card's prompt is executed:
34
+ * - inline: fast headless streamLlm turn (API profile) — simple cards.
35
+ * - task: dispatched as a one-shot CLI task (relay backend) that can do
36
+ * multi-step work + write the widget to a file. Heavy cards. */
37
+ export type KanbanExecMode = 'inline' | 'task';
38
+
39
+ export interface KanbanCard {
40
+ id: string;
41
+ connectorId: string; // card runs scoped to THIS connector's tools
42
+ title: string;
43
+ icon?: string | null; // emoji
44
+ prompt: string; // what to pull + how to summarize
45
+ periodSec: number; // refresh cadence (per card)
46
+ order: number; // drag-to-reorder
47
+ enabled: boolean;
48
+ execMode: KanbanExecMode; // inline (default) | task
49
+ taskId?: string | null; // current/last dispatched task (task mode)
50
+ lastResult?: WidgetSpec | null;
51
+ lastRunAt?: string | null;
52
+ lastError?: string | null;
53
+ status: KanbanCardStatus;
54
+ createdAt: string;
55
+ updatedAt: string;
56
+ /** Derived (not stored): the connector's website / base_url, attached by the
57
+ * API so the card can link out. */
58
+ connectorUrl?: string | null;
59
+ }
60
+
61
+ export interface CreateKanbanCardInput {
62
+ connectorId: string;
63
+ title: string;
64
+ prompt: string;
65
+ icon?: string | null;
66
+ periodSec?: number; // default 1800
67
+ order?: number;
68
+ enabled?: boolean;
69
+ execMode?: KanbanExecMode; // default 'inline'
70
+ seedKey?: string; // set when auto-created from a connector default
71
+ }
72
+
73
+ export interface UpdateKanbanCardInput {
74
+ title?: string;
75
+ prompt?: string;
76
+ icon?: string | null;
77
+ periodSec?: number;
78
+ order?: number;
79
+ enabled?: boolean;
80
+ execMode?: KanbanExecMode;
81
+ }
82
+
83
+ // ─── Connector-declared kanban defaults ───────────────────
84
+ // These live HERE (not in lib/connectors/types.ts) on purpose: the core
85
+ // connector subsystem stays untouched, so old connectors load exactly as
86
+ // before. seed.ts reads `definition.kanban` via a structural cast — a manifest
87
+ // `kanban:` block is plain pass-through YAML the connector loader never parses.
88
+
89
+ export interface ConnectorKanban {
90
+ enabled?: boolean; // default: true when `cards` is non-empty
91
+ cards?: KanbanCardDecl[];
92
+ }
93
+
94
+ export interface KanbanCardDecl {
95
+ title: string;
96
+ icon?: string;
97
+ prompt: string;
98
+ period_sec?: number; // refresh cadence; default 1800
99
+ mode?: KanbanExecMode; // 'task' for heavy multi-step cards; default inline
100
+ }
101
+
102
+ /** A connector definition that may carry kanban defaults — used by seed.ts to
103
+ * read the field without coupling lib/connectors/types.ts to kanban. */
104
+ export type WithKanban = { id: string; icon?: string | null; kanban?: ConnectorKanban };
@@ -83,6 +83,12 @@ async function tick(): Promise<void> {
83
83
  console.error(`[schedules-scheduler] execute ${s.id} crashed`, err);
84
84
  });
85
85
  }
86
+
87
+ // Kanban cards reuse this same tick (per-card period). Lazy-imported so the
88
+ // scheduler module doesn't statically pull in the chat agent-loop graph.
89
+ void import('../kanban/tick')
90
+ .then(({ kanbanTick }) => kanbanTick())
91
+ .catch((e) => console.warn(`[kanban] tick failed: ${(e as Error).message}`));
86
92
  }
87
93
 
88
94
  function toSqlIso(d: Date): string {
@@ -5,7 +5,9 @@
5
5
 
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { spawn, execSync } from 'node:child_process';
8
- import { realpathSync } from 'node:fs';
8
+ import { realpathSync, existsSync } from 'node:fs';
9
+ import { join as pathJoin } from 'node:path';
10
+ import { getConfigDir } from './dirs';
9
11
  import * as pty from 'node-pty';
10
12
  import { getDb } from '../src/core/db/database';
11
13
  import { getDbPath } from '../src/config';
@@ -676,6 +678,14 @@ function executeTask(task: Task): Promise<void> {
676
678
  const env = { ...process.env, ...connectorEnv(), ...(spawnOpts.env || {}) };
677
679
  delete env.CLAUDECODE;
678
680
 
681
+ // Corporate SSL: if the server was launched without NODE_EXTRA_CA_CERTS but
682
+ // a CA bundle exists at <configDir>/corporate-ca.pem, wire it through so the
683
+ // spawned CLI doesn't die with "SSL certificate verification failed".
684
+ if (!env.NODE_EXTRA_CA_CERTS) {
685
+ const corpCa = pathJoin(getConfigDir(), 'corporate-ca.pem');
686
+ if (existsSync(corpCa)) env.NODE_EXTRA_CA_CERTS = corpCa;
687
+ }
688
+
679
689
  updateTaskStatus(task.id, 'running');
680
690
  db().prepare('UPDATE tasks SET started_at = datetime(\'now\') WHERE id = ?').run(task.id);
681
691
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.89",
3
+ "version": "0.11.0",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,7 +50,7 @@
50
50
  "better-sqlite3": "^12.6.2",
51
51
  "cron-parser": "^5.5.0",
52
52
  "esbuild": "^0.27.3",
53
- "next": "^16.2.1",
53
+ "next": "^16.2.9",
54
54
  "next-auth": "5.0.0-beta.30",
55
55
  "node-pty": "1.0.0",
56
56
  "nodemailer": "^6.10.1",
package/publish.sh CHANGED
@@ -55,7 +55,7 @@ echo ""
55
55
  # checked separately, so they're excluded.
56
56
  echo "Verifying clean build before publish ..."
57
57
  rm -rf .next
58
- npx next build --webpack
58
+ npx next build
59
59
  TSC_ERRORS=$(npx tsc --noEmit 2>&1 | grep "error TS" | grep -v "__tests__" || true)
60
60
  if [ -n "$TSC_ERRORS" ]; then
61
61
  echo "$TSC_ERRORS"