clementine-agent 1.2.3 → 1.3.1

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,138 @@
1
+ /**
2
+ * Builder snapshot history — file-based undo for workflow saves.
3
+ *
4
+ * Every successful save writes a copy of the source file to:
5
+ * ~/.clementine/snapshots/builder/<origin>/<key>/<timestamp>.md
6
+ *
7
+ * Bounded: keep at most MAX_PER_WORKFLOW snapshots per workflow id.
8
+ * Older snapshots are pruned on each save.
9
+ *
10
+ * No git dependency, no user-facing CLI — agent invokes via MCP tools
11
+ * (workflow_history / workflow_restore).
12
+ */
13
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { parseBuilderId } from './serializer.js';
17
+ function snapRoot() {
18
+ return path.join(process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine'), 'snapshots', 'builder');
19
+ }
20
+ const MAX_PER_WORKFLOW = 20;
21
+ let _snapshotCounter = 0;
22
+ function nextSnapshotFilename() {
23
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
24
+ const seq = (++_snapshotCounter).toString(36).padStart(3, '0');
25
+ return `${ts}-${seq}.md`;
26
+ }
27
+ function snapshotDirFor(id) {
28
+ const parsed = parseBuilderId(id);
29
+ if (!parsed)
30
+ return null;
31
+ return path.join(snapRoot(), parsed.origin, sanitizeKey(parsed.key));
32
+ }
33
+ function sanitizeKey(key) {
34
+ return key.replace(/[^a-z0-9._-]/gi, '_').slice(0, 120);
35
+ }
36
+ /**
37
+ * Write a snapshot of the current state of a workflow's source file.
38
+ * Best-effort — failures are logged but never block the underlying save.
39
+ */
40
+ export function snapshotWorkflow(id, sourceFile) {
41
+ if (!sourceFile || !existsSync(sourceFile))
42
+ return null;
43
+ const dir = snapshotDirFor(id);
44
+ if (!dir)
45
+ return null;
46
+ try {
47
+ mkdirSync(dir, { recursive: true });
48
+ const filename = nextSnapshotFilename();
49
+ const dst = path.join(dir, filename);
50
+ copyFileSync(sourceFile, dst);
51
+ pruneOldSnapshots(dir);
52
+ return entryFromFile(id, dir, filename);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** List snapshots for a builder id, newest first. */
59
+ export function listSnapshots(id) {
60
+ const dir = snapshotDirFor(id);
61
+ if (!dir || !existsSync(dir))
62
+ return [];
63
+ try {
64
+ const files = readdirSync(dir).filter(f => f.endsWith('.md'));
65
+ return files
66
+ .map(f => entryFromFile(id, dir, f))
67
+ .filter((e) => e != null)
68
+ .sort((a, b) => b.ts.localeCompare(a.ts));
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ /** Restore a snapshot by filename. Writes the snapshot's contents back into sourceFile. */
75
+ export function restoreSnapshot(id, snapshotFilename, sourceFile) {
76
+ const dir = snapshotDirFor(id);
77
+ if (!dir)
78
+ return { ok: false, error: 'unknown id' };
79
+ const safe = path.basename(snapshotFilename);
80
+ const src = path.join(dir, safe);
81
+ if (!existsSync(src))
82
+ return { ok: false, error: 'snapshot not found' };
83
+ if (!sourceFile)
84
+ return { ok: false, error: 'missing sourceFile' };
85
+ try {
86
+ // Snapshot the *current* contents first so the restore itself is reversible.
87
+ snapshotWorkflow(id, sourceFile);
88
+ const content = readFileSync(src, 'utf-8');
89
+ writeFileSync(sourceFile, content, 'utf-8');
90
+ return { ok: true };
91
+ }
92
+ catch (err) {
93
+ return { ok: false, error: err.message };
94
+ }
95
+ }
96
+ function pruneOldSnapshots(dir) {
97
+ let files;
98
+ try {
99
+ files = readdirSync(dir).filter(f => f.endsWith('.md'));
100
+ }
101
+ catch {
102
+ return;
103
+ }
104
+ if (files.length <= MAX_PER_WORKFLOW)
105
+ return;
106
+ const sorted = files.sort(); // ISO timestamps sort naturally
107
+ const overflow = sorted.length - MAX_PER_WORKFLOW;
108
+ for (let i = 0; i < overflow; i++) {
109
+ try {
110
+ unlinkSync(path.join(dir, sorted[i]));
111
+ }
112
+ catch { /* */ }
113
+ }
114
+ }
115
+ function entryFromFile(id, dir, filename) {
116
+ try {
117
+ const full = path.join(dir, filename);
118
+ const stat = statSync(full);
119
+ const ts = filename.replace(/\.md$/, '').replace(/-/g, ':').replace(/^(\d{4}):(\d{2}):(\d{2})T/, '$1-$2-$3T').replace(/T(\d{2}):(\d{2}):(\d{2}):(\d{3})Z?$/, 'T$1:$2:$3.$4Z');
120
+ let preview = '';
121
+ try {
122
+ const head = readFileSync(full, 'utf-8').slice(0, 240);
123
+ preview = head.replace(/\n/g, ' ').slice(0, 120);
124
+ }
125
+ catch { /* */ }
126
+ return {
127
+ id,
128
+ filename,
129
+ ts,
130
+ size: stat.size,
131
+ preview,
132
+ };
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ //# sourceMappingURL=snapshots.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Builder workflow validation.
3
+ *
4
+ * Static, side-effect-free checks. Used both by `workflow_validate` (agent
5
+ * runs it explicitly) and by save handlers (refuse to persist invalid
6
+ * graphs). Errors block save; warnings are surfaced for the agent and UI
7
+ * but don't prevent persistence (e.g., disabled cron with no schedule —
8
+ * legal but probably wrong).
9
+ */
10
+ import type { WorkflowDefinition } from '../../types.js';
11
+ export type ValidationSeverity = 'error' | 'warning';
12
+ export interface ValidationIssue {
13
+ severity: ValidationSeverity;
14
+ code: string;
15
+ stepId?: string;
16
+ field?: string;
17
+ message: string;
18
+ }
19
+ export interface ValidationResult {
20
+ ok: boolean;
21
+ issues: ValidationIssue[];
22
+ }
23
+ export declare function validateWorkflow(wf: WorkflowDefinition): ValidationResult;
24
+ /** Lightweight cron expression validation. Accepts standard 5-field cron and common ranges/lists/steps. */
25
+ export declare function isCronExpression(expr: string): boolean;
26
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Builder workflow validation.
3
+ *
4
+ * Static, side-effect-free checks. Used both by `workflow_validate` (agent
5
+ * runs it explicitly) and by save handlers (refuse to persist invalid
6
+ * graphs). Errors block save; warnings are surfaced for the agent and UI
7
+ * but don't prevent persistence (e.g., disabled cron with no schedule —
8
+ * legal but probably wrong).
9
+ */
10
+ export function validateWorkflow(wf) {
11
+ const issues = [];
12
+ // ── Workflow-level ──
13
+ if (!wf.name || !wf.name.trim()) {
14
+ issues.push({ severity: 'error', code: 'name-empty', message: 'Workflow has no name' });
15
+ }
16
+ if (wf.steps.length === 0) {
17
+ issues.push({ severity: 'error', code: 'no-steps', message: 'Workflow has no steps' });
18
+ }
19
+ if (wf.trigger.schedule && !isCronExpression(wf.trigger.schedule)) {
20
+ issues.push({
21
+ severity: 'error', code: 'bad-schedule',
22
+ field: 'trigger.schedule',
23
+ message: `Invalid cron expression: "${wf.trigger.schedule}"`,
24
+ });
25
+ }
26
+ if (wf.enabled && !wf.trigger.schedule && !wf.trigger.manual) {
27
+ issues.push({
28
+ severity: 'warning', code: 'enabled-no-trigger',
29
+ message: 'Workflow is enabled but has neither a cron schedule nor a manual trigger',
30
+ });
31
+ }
32
+ // ── Step-level ──
33
+ const seenIds = new Set();
34
+ for (const step of wf.steps) {
35
+ if (seenIds.has(step.id)) {
36
+ issues.push({
37
+ severity: 'error', code: 'duplicate-step-id',
38
+ stepId: step.id,
39
+ message: `Duplicate step id: "${step.id}"`,
40
+ });
41
+ }
42
+ seenIds.add(step.id);
43
+ issues.push(...validateStepConfig(step));
44
+ for (const dep of step.dependsOn) {
45
+ if (dep === step.id) {
46
+ issues.push({
47
+ severity: 'error', code: 'self-dep',
48
+ stepId: step.id,
49
+ message: `Step "${step.id}" depends on itself`,
50
+ });
51
+ }
52
+ }
53
+ }
54
+ // Missing dep references
55
+ for (const step of wf.steps) {
56
+ for (const dep of step.dependsOn) {
57
+ if (dep !== step.id && !seenIds.has(dep)) {
58
+ issues.push({
59
+ severity: 'error', code: 'missing-dep',
60
+ stepId: step.id,
61
+ message: `Step "${step.id}" depends on unknown step "${dep}"`,
62
+ });
63
+ }
64
+ }
65
+ }
66
+ // Cycle detection (only worthwhile if there are no missing-dep errors)
67
+ if (!issues.some(i => i.code === 'missing-dep' || i.code === 'self-dep')) {
68
+ const cycle = findCycle(wf.steps);
69
+ if (cycle) {
70
+ issues.push({
71
+ severity: 'error', code: 'cycle',
72
+ message: `Cycle detected through steps: ${cycle.join(' → ')}`,
73
+ });
74
+ }
75
+ }
76
+ return { ok: !issues.some(i => i.severity === 'error'), issues };
77
+ }
78
+ function validateStepConfig(step) {
79
+ const issues = [];
80
+ const kind = step.kind ?? 'prompt';
81
+ switch (kind) {
82
+ case 'prompt':
83
+ if (!step.prompt || !step.prompt.trim()) {
84
+ issues.push({ severity: 'error', code: 'prompt-empty', stepId: step.id, message: `Prompt step "${step.id}" has empty prompt` });
85
+ }
86
+ break;
87
+ case 'mcp':
88
+ if (!step.mcp) {
89
+ issues.push({ severity: 'error', code: 'mcp-missing', stepId: step.id, message: `MCP step "${step.id}" has no config` });
90
+ }
91
+ else {
92
+ if (!step.mcp.server)
93
+ issues.push({ severity: 'error', code: 'mcp-no-server', stepId: step.id, message: `MCP step "${step.id}" missing server` });
94
+ if (!step.mcp.tool)
95
+ issues.push({ severity: 'error', code: 'mcp-no-tool', stepId: step.id, message: `MCP step "${step.id}" missing tool` });
96
+ }
97
+ break;
98
+ case 'channel':
99
+ if (!step.channel) {
100
+ issues.push({ severity: 'error', code: 'channel-missing', stepId: step.id, message: `Channel step "${step.id}" has no config` });
101
+ }
102
+ else {
103
+ if (!step.channel.channel)
104
+ issues.push({ severity: 'error', code: 'channel-no-channel', stepId: step.id, message: `Channel step "${step.id}" missing channel` });
105
+ if (!step.channel.target)
106
+ issues.push({ severity: 'error', code: 'channel-no-target', stepId: step.id, message: `Channel step "${step.id}" missing target` });
107
+ if (!step.channel.content)
108
+ issues.push({ severity: 'warning', code: 'channel-no-content', stepId: step.id, message: `Channel step "${step.id}" has empty content` });
109
+ }
110
+ break;
111
+ case 'transform':
112
+ if (!step.transform || !step.transform.expression) {
113
+ issues.push({ severity: 'error', code: 'transform-no-expr', stepId: step.id, message: `Transform step "${step.id}" missing expression` });
114
+ }
115
+ break;
116
+ case 'conditional':
117
+ if (!step.conditional || !step.conditional.condition) {
118
+ issues.push({ severity: 'error', code: 'conditional-no-cond', stepId: step.id, message: `Conditional step "${step.id}" missing condition` });
119
+ }
120
+ break;
121
+ case 'loop':
122
+ if (!step.loop || !step.loop.items) {
123
+ issues.push({ severity: 'error', code: 'loop-no-items', stepId: step.id, message: `Loop step "${step.id}" missing items expression` });
124
+ }
125
+ break;
126
+ }
127
+ if (typeof step.tier !== 'number' || step.tier < 1 || step.tier > 5) {
128
+ issues.push({ severity: 'warning', code: 'tier-range', stepId: step.id, field: 'tier', message: `Step "${step.id}" tier out of usual range (1-5): ${step.tier}` });
129
+ }
130
+ if (typeof step.maxTurns !== 'number' || step.maxTurns < 1) {
131
+ issues.push({ severity: 'warning', code: 'max-turns-range', stepId: step.id, field: 'maxTurns', message: `Step "${step.id}" maxTurns invalid: ${step.maxTurns}` });
132
+ }
133
+ return issues;
134
+ }
135
+ /** Return the cycle as a list of step ids (first→last→first), or null if acyclic. */
136
+ function findCycle(steps) {
137
+ const adj = new Map();
138
+ for (const s of steps)
139
+ adj.set(s.id, s.dependsOn.slice());
140
+ const WHITE = 0, GRAY = 1, BLACK = 2;
141
+ const color = new Map();
142
+ for (const id of adj.keys())
143
+ color.set(id, WHITE);
144
+ const stack = [];
145
+ function dfs(node) {
146
+ color.set(node, GRAY);
147
+ stack.push(node);
148
+ for (const dep of adj.get(node) ?? []) {
149
+ const c = color.get(dep);
150
+ if (c === GRAY) {
151
+ const cycleStart = stack.indexOf(dep);
152
+ return [...stack.slice(cycleStart), dep];
153
+ }
154
+ if (c === WHITE) {
155
+ const found = dfs(dep);
156
+ if (found)
157
+ return found;
158
+ }
159
+ }
160
+ stack.pop();
161
+ color.set(node, BLACK);
162
+ return null;
163
+ }
164
+ for (const id of adj.keys()) {
165
+ if (color.get(id) === WHITE) {
166
+ const found = dfs(id);
167
+ if (found)
168
+ return found;
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+ const CRON_FIELD = /^(\*|(\d+|\*\/\d+)([,-/](\d+))*)$/;
174
+ /** Lightweight cron expression validation. Accepts standard 5-field cron and common ranges/lists/steps. */
175
+ export function isCronExpression(expr) {
176
+ if (!expr || typeof expr !== 'string')
177
+ return false;
178
+ const fields = expr.trim().split(/\s+/);
179
+ if (fields.length !== 5)
180
+ return false;
181
+ return fields.every(f => CRON_FIELD.test(f) || /^[A-Z*]+$/i.test(f));
182
+ }
183
+ //# sourceMappingURL=validation.js.map
@@ -1445,9 +1445,38 @@ export class Gateway {
1445
1445
  try {
1446
1446
  logger.info({ workflow: workflow.name, inputs }, 'Running workflow');
1447
1447
  try {
1448
- const { WorkflowRunner } = await import('../agent/workflow-runner.js');
1448
+ const [{ WorkflowRunner }, { emitBuilderEvent }, { workflowId }] = await Promise.all([
1449
+ import('../agent/workflow-runner.js'),
1450
+ import('../dashboard/builder/events.js'),
1451
+ import('../dashboard/builder/serializer.js'),
1452
+ ]);
1449
1453
  const runner = new WorkflowRunner(this.assistant);
1450
- const result = await runner.run(workflow, inputs);
1454
+ // Derive builder id so the dashboard canvas can light up live if it's open.
1455
+ const baseName = workflow.sourceFile
1456
+ ? workflow.sourceFile.split('/').pop()?.replace(/\.md$/, '') ?? workflow.name
1457
+ : workflow.name;
1458
+ const builderId = workflowId(baseName);
1459
+ const runId = `sched-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
1460
+ emitBuilderEvent({ type: 'run:started', workflowId: builderId, runId, payload: { mode: 'real', stepCount: workflow.steps.length } });
1461
+ const result = await runner.run(workflow, inputs, (updates) => {
1462
+ for (const u of updates) {
1463
+ if (u.status === 'waiting')
1464
+ continue;
1465
+ const status = u.status === 'done' ? 'done' : u.status === 'failed' ? 'failed' : u.status === 'skipped' ? 'skipped' : 'running';
1466
+ emitBuilderEvent({
1467
+ type: 'run:step-status',
1468
+ workflowId: builderId,
1469
+ runId,
1470
+ payload: { stepId: u.stepId, status, durationMs: u.durationMs, mocked: false },
1471
+ });
1472
+ }
1473
+ });
1474
+ emitBuilderEvent({
1475
+ type: 'run:completed',
1476
+ workflowId: builderId,
1477
+ runId,
1478
+ payload: { status: result.status === 'ok' ? 'ok' : 'error', durationMs: result.entry.durationMs },
1479
+ });
1451
1480
  // Re-baseline integrity checksums after workflow (may write to vault)
1452
1481
  scanner.refreshIntegrity();
1453
1482
  return result.output || '*(workflow completed — no output)*';
package/dist/index.js CHANGED
@@ -694,6 +694,16 @@ async function asyncMain() {
694
694
  const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
695
695
  const cronScheduler = new CronScheduler(gateway, dispatcher);
696
696
  heartbeat.setCronScheduler(cronScheduler);
697
+ // Builder runner — wire MCP invoke handler so canvas test runs can hit
698
+ // real read-only MCP tools (gmail.list_unread, github.list_prs, etc.).
699
+ // Stdio clients are pooled per server with idle teardown.
700
+ try {
701
+ const { installBuilderMcpHandler } = await import('./dashboard/builder/mcp-invoke.js');
702
+ installBuilderMcpHandler();
703
+ }
704
+ catch (err) {
705
+ logger.warn({ err }, 'Builder MCP invoke handler install failed (non-fatal)');
706
+ }
697
707
  // Per-agent heartbeats — one cheap-path observer per registered specialist.
698
708
  // LLM tick fires on signal change with the agent's profile and routes
699
709
  // output to their Discord channel.
@@ -1019,6 +1029,14 @@ async function asyncMain() {
1019
1029
  catch (err) {
1020
1030
  logger.warn({ err }, 'Memory write queue drain failed');
1021
1031
  }
1032
+ // Tear down builder MCP client pool (best-effort).
1033
+ try {
1034
+ const { shutdownBuilderMcpHandler } = await import('./dashboard/builder/mcp-invoke.js');
1035
+ await shutdownBuilderMcpHandler();
1036
+ }
1037
+ catch (err) {
1038
+ logger.warn({ err }, 'Builder MCP handler shutdown failed (non-fatal)');
1039
+ }
1022
1040
  // Now safe to tear down remaining infrastructure
1023
1041
  heartbeat.stop();
1024
1042
  cronScheduler.stop();
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Clementine — Builder MCP tools.
3
+ *
4
+ * Agent-facing surface for managing workflows + crons on the visual
5
+ * canvas. Read, edit, validate, and dry-run. Actual execution lives
6
+ * separately in the runner (Phase 2+).
7
+ *
8
+ * Outputs are terse plain text by default for prompt efficiency; pass
9
+ * `verbose: true` to get the underlying JSON for debugging.
10
+ */
11
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ export declare function registerBuilderTools(server: McpServer): void;
13
+ //# sourceMappingURL=builder-tools.d.ts.map