@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.
@@ -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.90",
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": {