@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
|
@@ -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 {
|
package/lib/task-manager.ts
CHANGED
|
@@ -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