@aion0/forge 0.9.11 → 0.9.13
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 +15 -4
- package/app/api/prompts/[name]/route.ts +37 -0
- package/app/api/prompts/route.ts +35 -0
- package/app/api/schedules/extract/route.ts +184 -0
- package/app/api/schedules/route.ts +14 -37
- package/components/ScheduleCreateModal.tsx +237 -537
- package/components/ScheduleQuickCreate.tsx +404 -0
- package/components/SchedulesView.tsx +18 -6
- package/components/SettingsModal.tsx +43 -4
- package/lib/forge-mcp-server.ts +84 -0
- package/lib/init.ts +7 -0
- package/lib/projects.ts +67 -2
- package/lib/prompts/store.ts +142 -0
- package/lib/prompts/types.ts +53 -0
- package/lib/schedules/scheduler.ts +51 -143
- package/lib/schedules/store.ts +6 -15
- package/lib/schedules/types.ts +10 -14
- package/package.json +1 -1
package/lib/projects.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readdirSync, existsSync, statSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { loadSettings } from './settings';
|
|
4
|
+
import { getDataDir } from './dirs';
|
|
4
5
|
|
|
5
6
|
export interface LocalProject {
|
|
6
7
|
name: string;
|
|
@@ -12,6 +13,67 @@ export interface LocalProject {
|
|
|
12
13
|
lastModified: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** Reserved name for the synthetic "scratch" project that lives under
|
|
17
|
+
* <dataDir>/scratch. Default workspace for tasks that don't need a real
|
|
18
|
+
* project (prompt schedules calling connectors / chat-launched temp tasks
|
|
19
|
+
* / anything that just talks to APIs and doesn't touch files). */
|
|
20
|
+
export const SCRATCH_PROJECT_NAME = 'scratch';
|
|
21
|
+
|
|
22
|
+
/** Materialize <dataDir>/scratch on first call. Idempotent. Returns the
|
|
23
|
+
* project path. Called from init.ts on startup so the dir exists before
|
|
24
|
+
* any task tries to cd into it. */
|
|
25
|
+
export function ensureScratchProject(): string {
|
|
26
|
+
const dir = join(getDataDir(), 'scratch');
|
|
27
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
28
|
+
const claudeMd = join(dir, 'CLAUDE.md');
|
|
29
|
+
if (!existsSync(claudeMd)) {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(claudeMd,
|
|
32
|
+
'# Forge Scratch\n\n' +
|
|
33
|
+
'Default workspace for tasks that do not target a specific project.\n' +
|
|
34
|
+
'Files here are managed by Forge (per-instance, follows the data dir).\n' +
|
|
35
|
+
'Safe to wipe by hand if it accumulates clutter.\n',
|
|
36
|
+
'utf-8',
|
|
37
|
+
);
|
|
38
|
+
} catch { /* best-effort */ }
|
|
39
|
+
}
|
|
40
|
+
// Scratch is Forge-owned (no user hand-rolled .mcp.json to defy), so
|
|
41
|
+
// unlike real projects we materialize the root .mcp.json directly here.
|
|
42
|
+
// Without it, prompt-mode schedule tasks running in scratch can't see
|
|
43
|
+
// the Forge MCP server (claude CLI only auto-discovers root .mcp.json,
|
|
44
|
+
// not .forge/mcp.json). Always rewrite to keep mcpPort + user-managed
|
|
45
|
+
// entries in sync.
|
|
46
|
+
try {
|
|
47
|
+
const mcpPort = Number(process.env.MCP_PORT) || 8406;
|
|
48
|
+
let userServers: Record<string, unknown> = {};
|
|
49
|
+
try { userServers = loadSettings().mcpServers || {}; } catch {}
|
|
50
|
+
const cfg = {
|
|
51
|
+
mcpServers: {
|
|
52
|
+
forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse` },
|
|
53
|
+
...userServers,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
writeFileSync(join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2), 'utf-8');
|
|
57
|
+
} catch { /* best-effort */ }
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function scratchProject(): LocalProject {
|
|
62
|
+
const path = ensureScratchProject();
|
|
63
|
+
let mtime: string;
|
|
64
|
+
try { mtime = statSync(path).mtime.toISOString(); }
|
|
65
|
+
catch { mtime = new Date().toISOString(); }
|
|
66
|
+
return {
|
|
67
|
+
name: SCRATCH_PROJECT_NAME,
|
|
68
|
+
path,
|
|
69
|
+
root: getDataDir(),
|
|
70
|
+
hasGit: false,
|
|
71
|
+
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
|
72
|
+
language: null,
|
|
73
|
+
lastModified: mtime,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
15
77
|
export function scanProjects(): LocalProject[] {
|
|
16
78
|
const settings = loadSettings();
|
|
17
79
|
const roots = settings.projectRoots;
|
|
@@ -52,7 +114,10 @@ export function scanProjects(): LocalProject[] {
|
|
|
52
114
|
}
|
|
53
115
|
}
|
|
54
116
|
|
|
55
|
-
|
|
117
|
+
// Prepend the synthetic scratch project so the UI surfaces it as a
|
|
118
|
+
// first-class option. It's stamped with the current date so it tends
|
|
119
|
+
// to sort to the top — that's intentional: it's the default fallback.
|
|
120
|
+
return [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
|
|
56
121
|
}
|
|
57
122
|
|
|
58
123
|
function detectLanguage(projectPath: string): string | null {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts file store — `<dataDir>/prompts/<name>.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* Same external-file pattern as pipelines: name in DB / schedule row,
|
|
5
|
+
* configuration in YAML on disk. Edits don't require migrations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import YAML from 'yaml';
|
|
11
|
+
import { getDataDir } from '@/lib/dirs';
|
|
12
|
+
import type { Prompt, CreatePromptInput, UpdatePromptInput } from './types';
|
|
13
|
+
|
|
14
|
+
function dir(): string {
|
|
15
|
+
return join(getDataDir(), 'prompts');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Same constraint as pipeline names — also serves as filename `<name>.yaml`.
|
|
19
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
20
|
+
|
|
21
|
+
function pathFor(name: string): string {
|
|
22
|
+
return join(dir(), `${name}.yaml`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePrompt(yaml: string): Prompt {
|
|
26
|
+
const raw = YAML.parse(yaml);
|
|
27
|
+
if (!raw || typeof raw !== 'object') throw new Error('prompt yaml must be a mapping');
|
|
28
|
+
if (typeof raw.name !== 'string' || !raw.name) throw new Error('prompt.name required');
|
|
29
|
+
if (typeof raw.prompt !== 'string') throw new Error('prompt.prompt required (string)');
|
|
30
|
+
|
|
31
|
+
const ex = raw.executor || {};
|
|
32
|
+
const skills = Array.isArray(ex.skills)
|
|
33
|
+
? ex.skills.filter((s: unknown) => typeof s === 'string')
|
|
34
|
+
: [];
|
|
35
|
+
const now = new Date().toISOString();
|
|
36
|
+
return {
|
|
37
|
+
name: raw.name,
|
|
38
|
+
prompt: raw.prompt,
|
|
39
|
+
executor: {
|
|
40
|
+
agent: typeof ex.agent === 'string' && ex.agent ? ex.agent : null,
|
|
41
|
+
skills,
|
|
42
|
+
config: ex.config && typeof ex.config === 'object' ? ex.config : undefined,
|
|
43
|
+
},
|
|
44
|
+
created_at: typeof raw.created_at === 'string' ? raw.created_at : now,
|
|
45
|
+
updated_at: typeof raw.updated_at === 'string' ? raw.updated_at : now,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serialize(p: Prompt): string {
|
|
50
|
+
const executorOut: Record<string, unknown> = {
|
|
51
|
+
agent: p.executor.agent,
|
|
52
|
+
skills: p.executor.skills,
|
|
53
|
+
};
|
|
54
|
+
if (p.executor.config) executorOut.config = p.executor.config;
|
|
55
|
+
return YAML.stringify({
|
|
56
|
+
name: p.name,
|
|
57
|
+
prompt: p.prompt,
|
|
58
|
+
executor: executorOut,
|
|
59
|
+
created_at: p.created_at,
|
|
60
|
+
updated_at: p.updated_at,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listPrompts(): Prompt[] {
|
|
65
|
+
const d = dir();
|
|
66
|
+
if (!existsSync(d)) return [];
|
|
67
|
+
const out: Prompt[] = [];
|
|
68
|
+
for (const f of readdirSync(d).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
69
|
+
try {
|
|
70
|
+
out.push(parsePrompt(readFileSync(join(d, f), 'utf-8')));
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.warn(`[listPrompts] skip ${f}: ${(e as Error).message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getPrompt(name: string): Prompt | null {
|
|
79
|
+
if (!NAME_RE.test(name)) return null;
|
|
80
|
+
const path = pathFor(name);
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
try {
|
|
83
|
+
return parsePrompt(readFileSync(path, 'utf-8'));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.warn(`[getPrompt] parse ${name}: ${(e as Error).message}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createPrompt(input: CreatePromptInput): Prompt {
|
|
91
|
+
if (!NAME_RE.test(input.name)) {
|
|
92
|
+
throw new Error(`invalid name (allowed: letters/digits/._-, 1-64 chars): '${input.name}'`);
|
|
93
|
+
}
|
|
94
|
+
if (!input.prompt || !input.prompt.trim()) {
|
|
95
|
+
throw new Error('prompt text required');
|
|
96
|
+
}
|
|
97
|
+
mkdirSync(dir(), { recursive: true });
|
|
98
|
+
if (existsSync(pathFor(input.name))) {
|
|
99
|
+
throw new Error(`prompt '${input.name}' already exists`);
|
|
100
|
+
}
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
const p: Prompt = {
|
|
103
|
+
name: input.name,
|
|
104
|
+
prompt: input.prompt,
|
|
105
|
+
executor: {
|
|
106
|
+
agent: input.executor?.agent ?? null,
|
|
107
|
+
skills: input.executor?.skills ?? [],
|
|
108
|
+
config: input.executor?.config,
|
|
109
|
+
},
|
|
110
|
+
created_at: now,
|
|
111
|
+
updated_at: now,
|
|
112
|
+
};
|
|
113
|
+
writeFileSync(pathFor(input.name), serialize(p), 'utf-8');
|
|
114
|
+
return p;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function updatePrompt(name: string, patch: UpdatePromptInput): Prompt {
|
|
118
|
+
const cur = getPrompt(name);
|
|
119
|
+
if (!cur) throw new Error(`prompt '${name}' not found`);
|
|
120
|
+
const next: Prompt = {
|
|
121
|
+
...cur,
|
|
122
|
+
prompt: patch.prompt ?? cur.prompt,
|
|
123
|
+
executor: patch.executor
|
|
124
|
+
? {
|
|
125
|
+
agent: patch.executor.agent ?? cur.executor.agent,
|
|
126
|
+
skills: patch.executor.skills ?? cur.executor.skills,
|
|
127
|
+
config: patch.executor.config ?? cur.executor.config,
|
|
128
|
+
}
|
|
129
|
+
: cur.executor,
|
|
130
|
+
updated_at: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
writeFileSync(pathFor(name), serialize(next), 'utf-8');
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function deletePrompt(name: string): boolean {
|
|
137
|
+
if (!NAME_RE.test(name)) return false;
|
|
138
|
+
const path = pathFor(name);
|
|
139
|
+
if (!existsSync(path)) return false;
|
|
140
|
+
unlinkSync(path);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Prompts — types (V3 B1)
|
|
3
|
+
*
|
|
4
|
+
* A Prompt is a stored body for `body_kind='prompt'` schedules. The schedule
|
|
5
|
+
* references it by name; the actual configuration (text + executor) lives
|
|
6
|
+
* in `<dataDir>/prompts/<name>.yaml`. Same external-file pattern Pipeline
|
|
7
|
+
* uses, so prompt fields can evolve without DB migrations.
|
|
8
|
+
*
|
|
9
|
+
* Wired into scheduler dispatch in B2 (#263).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PromptExecutorRetry {
|
|
13
|
+
/** 0 = don't retry (default). Failed prompt tasks just wait for the next tick. */
|
|
14
|
+
max: number;
|
|
15
|
+
backoff?: 'linear' | 'exponential';
|
|
16
|
+
/** Seconds before first retry. */
|
|
17
|
+
initial_delay?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PromptExecutorConfig {
|
|
21
|
+
/** Task timeout in seconds. Falls through to system default if unset. */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
retry?: PromptExecutorRetry;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PromptExecutor {
|
|
27
|
+
/** Agent id from Settings. null = use system default. */
|
|
28
|
+
agent: string | null;
|
|
29
|
+
/** Skills attached to the dispatched task; typically includes
|
|
30
|
+
* `ai-orchestration` so the agent can discover + call connectors. */
|
|
31
|
+
skills: string[];
|
|
32
|
+
config?: PromptExecutorConfig;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Prompt {
|
|
36
|
+
name: string;
|
|
37
|
+
/** User-authored prompt text. MUST be preserved verbatim — no LLM rewrites. */
|
|
38
|
+
prompt: string;
|
|
39
|
+
executor: PromptExecutor;
|
|
40
|
+
created_at: string;
|
|
41
|
+
updated_at: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreatePromptInput {
|
|
45
|
+
name: string;
|
|
46
|
+
prompt: string;
|
|
47
|
+
executor?: Partial<PromptExecutor>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UpdatePromptInput {
|
|
51
|
+
prompt?: string;
|
|
52
|
+
executor?: Partial<PromptExecutor>;
|
|
53
|
+
}
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* No sequential / on_failure / dedup / retry knobs. All body-internal.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { randomUUID } from 'node:crypto';
|
|
16
15
|
import { CronExpressionParser } from 'cron-parser';
|
|
17
16
|
import { startPipeline, getWorkflow } from '@/lib/pipeline';
|
|
18
17
|
import {
|
|
@@ -21,9 +20,9 @@ import {
|
|
|
21
20
|
onTaskEvent,
|
|
22
21
|
taskAppendSystemPromptOverrides,
|
|
23
22
|
} from '@/lib/task-manager';
|
|
24
|
-
import { getProjectInfo } from '@/lib/projects';
|
|
25
|
-
import { dispatchTool } from '@/lib/chat/tool-dispatcher';
|
|
23
|
+
import { getProjectInfo, SCRATCH_PROJECT_NAME } from '@/lib/projects';
|
|
26
24
|
import { ensureInstalledInProject } from '@/lib/skills';
|
|
25
|
+
import { getPrompt } from '@/lib/prompts/store';
|
|
27
26
|
import { getDb } from '@/src/core/db/database';
|
|
28
27
|
import { getDbPath } from '@/src/config';
|
|
29
28
|
import {
|
|
@@ -50,7 +49,7 @@ export function startSchedulesScheduler(): void {
|
|
|
50
49
|
if (g[schedulerKey]) return;
|
|
51
50
|
g[schedulerKey] = true;
|
|
52
51
|
ensureSchema();
|
|
53
|
-
|
|
52
|
+
installTaskBackedRunListener();
|
|
54
53
|
console.log('[schedules-scheduler] started');
|
|
55
54
|
void tick();
|
|
56
55
|
setInterval(() => { void tick(); }, TICK_INTERVAL_MS).unref?.();
|
|
@@ -135,9 +134,8 @@ export function advanceSchedule(s: Schedule): void {
|
|
|
135
134
|
* the manual fire API. Returns the target_id (the dispatched body's id).
|
|
136
135
|
*/
|
|
137
136
|
export async function executeSchedule(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
|
|
138
|
-
if (s.body_kind === 'pipeline')
|
|
139
|
-
if (s.body_kind === '
|
|
140
|
-
if (s.body_kind === 'connector_tool') return executeConnectorToolBody(s, trigger);
|
|
137
|
+
if (s.body_kind === 'pipeline') return executePipelineBody(s, trigger);
|
|
138
|
+
if (s.body_kind === 'prompt') return executePromptBody(s, trigger);
|
|
141
139
|
throw new Error(`unknown body_kind: ${s.body_kind}`);
|
|
142
140
|
}
|
|
143
141
|
|
|
@@ -202,38 +200,40 @@ async function preinstallSkillsForPipeline(
|
|
|
202
200
|
}
|
|
203
201
|
}
|
|
204
202
|
|
|
205
|
-
// ───
|
|
203
|
+
// ─── prompt body (V3) ────────────────────────────────────
|
|
206
204
|
|
|
207
205
|
/**
|
|
208
|
-
*
|
|
209
|
-
* with the
|
|
210
|
-
*
|
|
211
|
-
* task.
|
|
206
|
+
* Prompt body: load prompts/<body_ref>.yaml, dispatch a one-shot Claude
|
|
207
|
+
* task with the YAML's prompt text + executor.agent + executor.skills.
|
|
208
|
+
* Schedule.input.project chooses where the task runs. The schedule_run
|
|
209
|
+
* carries task.id as target_id; the task event listener (below) settles
|
|
210
|
+
* status + body_output when the task reaches terminal state.
|
|
212
211
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* Optional input fields:
|
|
217
|
-
* - user_prompt (string, the actual question to ask Claude)
|
|
218
|
-
* defaults to "Run skill /<body_ref>"
|
|
219
|
-
* - <any other key/value> — passed in the user message verbatim
|
|
220
|
-
* (advanced; phase 2 doesn't do template expansion)
|
|
212
|
+
* The user's prompt text is sent VERBATIM — no LLM rewrites, no template
|
|
213
|
+
* expansion. Schedule.input is currently ignored (project is the only
|
|
214
|
+
* required field); future versions may surface input.* as template vars.
|
|
221
215
|
*/
|
|
222
|
-
async function
|
|
223
|
-
const
|
|
224
|
-
if (!
|
|
225
|
-
|
|
226
|
-
|
|
216
|
+
async function executePromptBody(s: Schedule, trigger: ScheduleRunTrigger): Promise<string> {
|
|
217
|
+
const def = getPrompt(s.body_ref);
|
|
218
|
+
if (!def) throw new Error(`prompt definition not found: prompts/${s.body_ref}.yaml`);
|
|
219
|
+
|
|
220
|
+
// input.project is optional for prompt body — empty / unset falls back
|
|
221
|
+
// to the synthetic 'scratch' project (<dataDir>/scratch) materialized
|
|
222
|
+
// at init time. Schedules created before the optional-project change
|
|
223
|
+
// also work: empty string → scratch.
|
|
224
|
+
const projectName = strField(s.input.project) || SCRATCH_PROJECT_NAME;
|
|
227
225
|
const project = getProjectInfo(projectName);
|
|
228
|
-
if (!project) {
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
if (!project) throw new Error(`project not found: "${projectName}"`);
|
|
227
|
+
|
|
228
|
+
// Merge prompt-yaml skills with any extra skills the schedule row carries.
|
|
229
|
+
// YAML is the primary source (designed per-prompt); schedule.skills is a
|
|
230
|
+
// legacy/override hook that we keep for parity with pipeline/skill bodies.
|
|
231
|
+
const skills = [
|
|
232
|
+
...(def.executor.skills || []),
|
|
233
|
+
...(s.skills || []),
|
|
234
|
+
].filter((v, i, arr) => v && arr.indexOf(v) === i);
|
|
231
235
|
|
|
232
|
-
|
|
233
|
-
// body_ref was validated as installed at POST time but only on SOME
|
|
234
|
-
// project (or globally) — make sure it's actually on disk for THIS one.
|
|
235
|
-
const skillsToInstall = [s.body_ref, ...(s.skills || [])].filter((v, i, arr) => v && arr.indexOf(v) === i);
|
|
236
|
-
for (const skill of skillsToInstall) {
|
|
236
|
+
for (const skill of skills) {
|
|
237
237
|
try {
|
|
238
238
|
const r = await ensureInstalledInProject(skill, project.path);
|
|
239
239
|
if (!r.installed) console.warn(`[schedules] skill "${skill}" not installable in ${project.path}: ${r.reason}`);
|
|
@@ -242,31 +242,15 @@ async function executeSkillBody(s: Schedule, trigger: ScheduleRunTrigger): Promi
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
const userPrompt = strField(s.input.user_prompt) || `Run skill /${s.body_ref}`;
|
|
246
|
-
// Pass other input keys as a JSON block appended after user_prompt
|
|
247
|
-
// so the skill can extract them.
|
|
248
|
-
const extras: Record<string, string> = {};
|
|
249
|
-
for (const [k, v] of Object.entries(s.input || {})) {
|
|
250
|
-
if (k === 'project' || k === 'user_prompt') continue;
|
|
251
|
-
extras[k] = strField(v);
|
|
252
|
-
}
|
|
253
|
-
const extrasBlock = Object.keys(extras).length
|
|
254
|
-
? `\n\n--- Schedule input ---\n${JSON.stringify(extras, null, 2)}\n`
|
|
255
|
-
: '';
|
|
256
|
-
|
|
257
245
|
const task = createTask({
|
|
258
246
|
projectName: project.name,
|
|
259
247
|
projectPath: project.path,
|
|
260
|
-
prompt:
|
|
248
|
+
prompt: def.prompt,
|
|
261
249
|
conversationId: '',
|
|
250
|
+
agent: def.executor.agent || undefined,
|
|
262
251
|
});
|
|
263
252
|
|
|
264
|
-
|
|
265
|
-
// the task up. Same mechanism Pipelines use (lib/pipeline.ts:1448+).
|
|
266
|
-
// body_ref is the primary skill; extra s.skills are merged for tasks
|
|
267
|
-
// that need additional skills alongside (e.g. /forge-mr-triage + /git-helpers).
|
|
268
|
-
const skillSet = [s.body_ref, ...(s.skills || [])].filter((v, i, arr) => v && arr.indexOf(v) === i);
|
|
269
|
-
const append = renderSkillsAppendPrompt(skillSet);
|
|
253
|
+
const append = renderSkillsAppendPrompt(skills);
|
|
270
254
|
if (append) taskAppendSystemPromptOverrides.set(task.id, append);
|
|
271
255
|
|
|
272
256
|
insertScheduleRun({ schedule_id: s.id, target_id: task.id, trigger });
|
|
@@ -274,82 +258,6 @@ async function executeSkillBody(s: Schedule, trigger: ScheduleRunTrigger): Promi
|
|
|
274
258
|
return task.id;
|
|
275
259
|
}
|
|
276
260
|
|
|
277
|
-
// ─── connector_tool body ──────────────────────────────────
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Connector tool body: a single synchronous HTTP-style invocation
|
|
281
|
-
* (run through tool-dispatcher; no Claude, no pipeline). body_ref is
|
|
282
|
-
* "<plugin>.<tool>" (e.g. "mantis.search_bugs"). input is passed
|
|
283
|
-
* verbatim as tool input.
|
|
284
|
-
*
|
|
285
|
-
* Synthetic target_id (uuid prefix "ct_") since there's no backing
|
|
286
|
-
* task/pipeline row. The schedule_run carries the full lifecycle.
|
|
287
|
-
*
|
|
288
|
-
* Dispatch is fire-and-forget — we return the target_id immediately
|
|
289
|
-
* so the caller (tick loop / manual fire API) doesn't block on slow
|
|
290
|
-
* connector tools. The async helper settles body_output + status
|
|
291
|
-
* when the call resolves.
|
|
292
|
-
*/
|
|
293
|
-
function executeConnectorToolBody(s: Schedule, trigger: ScheduleRunTrigger): string {
|
|
294
|
-
const targetId = `ct_${randomUUID().slice(0, 12).replace(/-/g, '')}`;
|
|
295
|
-
insertScheduleRun({ schedule_id: s.id, target_id: targetId, trigger });
|
|
296
|
-
setLastRunAt(s.id, toSqlIso(new Date()));
|
|
297
|
-
void invokeConnectorTool(s, targetId).catch((err) => {
|
|
298
|
-
console.error(`[schedules] connector_tool body for ${s.id} crashed`, err);
|
|
299
|
-
updateScheduleRunStatus({
|
|
300
|
-
target_id: targetId,
|
|
301
|
-
status: 'failed',
|
|
302
|
-
error: (err as Error)?.message || String(err),
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
return targetId;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function invokeConnectorTool(s: Schedule, targetId: string): Promise<void> {
|
|
309
|
-
// body_ref is "<plugin_id>.<tool_name>". Some tools could contain
|
|
310
|
-
// dots, but our internal naming is always plugin.tool and the
|
|
311
|
-
// dispatcher splits on the FIRST dot.
|
|
312
|
-
const dot = s.body_ref.indexOf('.');
|
|
313
|
-
if (dot <= 0 || dot === s.body_ref.length - 1) {
|
|
314
|
-
updateScheduleRunStatus({
|
|
315
|
-
target_id: targetId,
|
|
316
|
-
status: 'failed',
|
|
317
|
-
error: `invalid body_ref: expected "<plugin>.<tool>", got "${s.body_ref}"`,
|
|
318
|
-
});
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
const fullName = s.body_ref;
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
const result = await dispatchTool({
|
|
325
|
-
id: `schedule-${targetId}`,
|
|
326
|
-
name: fullName,
|
|
327
|
-
input: s.input || {},
|
|
328
|
-
});
|
|
329
|
-
const output = typeof result.content === 'string' ? result.content : JSON.stringify(result.content ?? '');
|
|
330
|
-
setScheduleRunBodyOutputByTarget(targetId, output);
|
|
331
|
-
if (result.is_error) {
|
|
332
|
-
updateScheduleRunStatus({
|
|
333
|
-
target_id: targetId,
|
|
334
|
-
status: 'failed',
|
|
335
|
-
error: output.slice(0, 500) || 'connector tool returned is_error',
|
|
336
|
-
});
|
|
337
|
-
} else {
|
|
338
|
-
updateScheduleRunStatus({ target_id: targetId, status: 'done' });
|
|
339
|
-
}
|
|
340
|
-
} catch (err) {
|
|
341
|
-
updateScheduleRunStatus({
|
|
342
|
-
target_id: targetId,
|
|
343
|
-
status: 'failed',
|
|
344
|
-
error: (err as Error)?.message || String(err),
|
|
345
|
-
});
|
|
346
|
-
} finally {
|
|
347
|
-
void runActionForTarget(targetId).catch((e) => {
|
|
348
|
-
console.error(`[schedules-scheduler] action for connector_tool target ${targetId} crashed`, e);
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
261
|
function strField(v: unknown): string {
|
|
354
262
|
if (v == null) return '';
|
|
355
263
|
return typeof v === 'string' ? v : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
@@ -367,31 +275,31 @@ function renderSkillsAppendPrompt(skills: string[] | undefined): string {
|
|
|
367
275
|
].join('\n');
|
|
368
276
|
}
|
|
369
277
|
|
|
370
|
-
// ───
|
|
278
|
+
// ─── Task-backed run settler ──────────────────────────────
|
|
371
279
|
|
|
372
280
|
/**
|
|
373
281
|
* Global task event listener that settles schedule_runs whose target_id
|
|
374
|
-
* is a Claude task (
|
|
375
|
-
*
|
|
282
|
+
* is a Claude task (body_kind='prompt' dispatches one-shot tasks and stores
|
|
283
|
+
* task.id as target_id).
|
|
284
|
+
*
|
|
285
|
+
* We only act on terminal status events; reads getTask to capture
|
|
286
|
+
* resultSummary as body_output.
|
|
376
287
|
*
|
|
377
|
-
* Idempotent: updateScheduleRunStatus only touches rows with status='started'
|
|
378
|
-
* so a duplicate event is a no-op.
|
|
288
|
+
* Idempotent: updateScheduleRunStatus only touches rows with status='started'
|
|
289
|
+
* AND target_id=taskId, so a duplicate event (or a non-schedule task) is a no-op.
|
|
379
290
|
*/
|
|
380
|
-
let
|
|
381
|
-
function
|
|
382
|
-
if (
|
|
383
|
-
|
|
291
|
+
let taskBackedListenerInstalled = false;
|
|
292
|
+
function installTaskBackedRunListener(): void {
|
|
293
|
+
if (taskBackedListenerInstalled) return;
|
|
294
|
+
taskBackedListenerInstalled = true;
|
|
384
295
|
onTaskEvent((taskId, event, data) => {
|
|
385
296
|
if (event !== 'status') return;
|
|
386
297
|
if (data !== 'done' && data !== 'failed' && data !== 'cancelled') return;
|
|
387
|
-
|
|
298
|
+
settleTaskBackedRun(taskId, data as ScheduleRunStatus);
|
|
388
299
|
});
|
|
389
300
|
}
|
|
390
301
|
|
|
391
|
-
function
|
|
392
|
-
// We don't know body kind without a join; just attempt the update.
|
|
393
|
-
// updateScheduleRunStatus only touches rows with status='started'
|
|
394
|
-
// AND target_id=taskId, so it's a no-op for non-schedule tasks.
|
|
302
|
+
function settleTaskBackedRun(taskId: string, status: ScheduleRunStatus): void {
|
|
395
303
|
const t = getTask(taskId);
|
|
396
304
|
if (!t) {
|
|
397
305
|
updateScheduleRunStatus({ target_id: taskId, status, error: 'task row gone' });
|
|
@@ -404,7 +312,7 @@ function settleSkillRunIfMatch(taskId: string, status: ScheduleRunStatus): void
|
|
|
404
312
|
error: status === 'failed' ? (t.error || null) : null,
|
|
405
313
|
});
|
|
406
314
|
void runActionForTarget(taskId).catch((e) => {
|
|
407
|
-
console.error(`[schedules-scheduler] action for
|
|
315
|
+
console.error(`[schedules-scheduler] action for task target ${taskId} crashed`, e);
|
|
408
316
|
});
|
|
409
317
|
}
|
|
410
318
|
|
package/lib/schedules/store.ts
CHANGED
|
@@ -485,10 +485,9 @@ export function setScheduleRunAction(args: {
|
|
|
485
485
|
|
|
486
486
|
// ─── Zombie reconciliation ────────────────────────────────
|
|
487
487
|
|
|
488
|
-
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* body_kind=skill (read tasks table). connector_tool lands in phase 3. */
|
|
488
|
+
/** Any started+30s+ schedule_run whose target is gone OR in terminal
|
|
489
|
+
* state → fix the DB row. Handles body_kind=pipeline (read JSON file)
|
|
490
|
+
* and body_kind=prompt (read tasks table). */
|
|
492
491
|
export function reconcileStaleScheduleRuns(): void {
|
|
493
492
|
try {
|
|
494
493
|
const stale = db().prepare(`
|
|
@@ -513,16 +512,8 @@ export function reconcileStaleScheduleRuns(): void {
|
|
|
513
512
|
let settled = false;
|
|
514
513
|
if (row.body_kind === 'pipeline') {
|
|
515
514
|
settled = reconcilePipelineRun(row.id, row.target_id, pipelineDir);
|
|
516
|
-
} else if (row.body_kind === '
|
|
517
|
-
settled =
|
|
518
|
-
} else if (row.body_kind === 'connector_tool') {
|
|
519
|
-
// connector_tool dispatch is in-memory async. If it's still
|
|
520
|
-
// 'started' 30s later it means forge crashed mid-call, or the
|
|
521
|
-
// tool itself is hanging. Either way: fail the run so future
|
|
522
|
-
// ticks aren't blocked by isScheduleBusy.
|
|
523
|
-
markRunStatus(row.id, 'failed');
|
|
524
|
-
console.warn(`[schedules] reconciled stale connector_tool schedule_run ${row.id} → failed (no in-memory progress after 30s)`);
|
|
525
|
-
settled = true;
|
|
515
|
+
} else if (row.body_kind === 'prompt') {
|
|
516
|
+
settled = reconcileTaskBackedRun(row.id, row.target_id);
|
|
526
517
|
} else {
|
|
527
518
|
markRunStatus(row.id, 'failed');
|
|
528
519
|
console.warn(`[schedules] reconciled schedule_run ${row.id} → failed (unsupported body_kind '${row.body_kind}')`);
|
|
@@ -590,7 +581,7 @@ function reconcilePipelineRun(
|
|
|
590
581
|
|
|
591
582
|
/** Same contract as reconcilePipelineRun: true if settled, false if
|
|
592
583
|
* task still in flight (caller must NOT fire action yet). */
|
|
593
|
-
function
|
|
584
|
+
function reconcileTaskBackedRun(runId: string, taskId: string): boolean {
|
|
594
585
|
try {
|
|
595
586
|
const t = db().prepare(
|
|
596
587
|
`SELECT status, result_summary FROM tasks WHERE id = ?`,
|
package/lib/schedules/types.ts
CHANGED
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Forge Schedules — types (
|
|
2
|
+
* Forge Schedules — types (V3)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* connector_tool (skill + connector_tool land in later phases).
|
|
7
|
-
* action is one of none / chat / email / telegram (chat/email/telegram
|
|
8
|
-
* land in later phases).
|
|
9
|
-
*
|
|
10
|
-
* Phase 1 only renames + extends fields; runtime behavior unchanged
|
|
11
|
-
* (body_kind defaults to 'pipeline', action_kind defaults to 'none').
|
|
4
|
+
* trigger + body + action. body is one of pipeline / prompt.
|
|
5
|
+
* action is one of none / chat / email / telegram.
|
|
12
6
|
*/
|
|
13
7
|
|
|
14
8
|
export type ScheduleKind = 'period' | 'once' | 'cron' | 'manual';
|
|
15
9
|
export type ScheduleRunStatus = 'started' | 'done' | 'failed' | 'cancelled';
|
|
16
10
|
export type ScheduleRunTrigger = 'schedule' | 'manual';
|
|
17
11
|
|
|
18
|
-
export type ScheduleBodyKind = 'pipeline' | '
|
|
12
|
+
export type ScheduleBodyKind = 'pipeline' | 'prompt';
|
|
19
13
|
export type ScheduleActionKind = 'none' | 'chat' | 'email' | 'telegram';
|
|
20
14
|
export type ScheduleActionStatus = 'pending' | 'done' | 'failed' | 'skipped';
|
|
21
15
|
|
|
@@ -27,15 +21,17 @@ export interface Schedule {
|
|
|
27
21
|
|
|
28
22
|
// Body — what to run when this schedule fires.
|
|
29
23
|
body_kind: ScheduleBodyKind;
|
|
30
|
-
/** Reference depends on body_kind:
|
|
24
|
+
/** Reference depends on body_kind:
|
|
25
|
+
* pipeline → pipeline name (flows/<name>.yaml)
|
|
26
|
+
* prompt → prompt name (prompts/<name>.yaml) */
|
|
31
27
|
body_ref: string;
|
|
32
28
|
/** Input params; shape depends on body_kind. */
|
|
33
29
|
input: Record<string, unknown>;
|
|
34
30
|
|
|
35
31
|
/** Extra Forge skills attached to the dispatched body. For body=pipeline
|
|
36
32
|
* these are forwarded to startPipeline({skills}) → every task gets the
|
|
37
|
-
* --append-system-prompt block. For body=
|
|
38
|
-
*
|
|
33
|
+
* --append-system-prompt block. For body=prompt they're merged with the
|
|
34
|
+
* prompt yaml's executor.skills and installed before dispatch. */
|
|
39
35
|
skills: string[];
|
|
40
36
|
|
|
41
37
|
// Action — what to do with body's output. action_config JSON shape
|
|
@@ -72,7 +68,7 @@ export interface ScheduleRun {
|
|
|
72
68
|
id: string;
|
|
73
69
|
schedule_id: string;
|
|
74
70
|
/** ID of the dispatched body — pipeline_id for body=pipeline,
|
|
75
|
-
* task_id for body=
|
|
71
|
+
* task_id for body=prompt. */
|
|
76
72
|
target_id: string;
|
|
77
73
|
trigger: ScheduleRunTrigger;
|
|
78
74
|
status: ScheduleRunStatus;
|
package/package.json
CHANGED