@aion0/forge 0.10.64 → 0.10.65
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 +9 -3
- package/components/SettingsModal.tsx +8 -1
- package/lib/chat/tool-dispatcher.ts +176 -8
- package/lib/connectors/sync.ts +6 -1
- package/lib/help-docs/13-schedules.md +15 -3
- package/package.json +1 -1
- package/publish.sh +23 -6
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.65
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.64
|
|
6
6
|
|
|
7
|
+
### Other
|
|
8
|
+
- fix(connectors): cache-bust github_api manifest fetch
|
|
9
|
+
- feat(chat): connector _files download channel + extract_archive + create_schedule prompt mode
|
|
10
|
+
- feat(settings): ↻ refresh-models button — pull registry past the 24h cache
|
|
11
|
+
- fix(publish): preflight gh with real API call + actionable 401 hint
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
|
|
14
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.64...v0.10.65
|
|
@@ -1799,7 +1799,7 @@ function MarketplaceProvidersSection() {
|
|
|
1799
1799
|
}
|
|
1800
1800
|
|
|
1801
1801
|
function AgentsSection({ settings, setSettings }: { settings: any; setSettings: (s: any) => void }) {
|
|
1802
|
-
const { registry: modelsRegistry } = useModelsRegistry();
|
|
1802
|
+
const { registry: modelsRegistry, refresh: refreshModels, loading: modelsLoading } = useModelsRegistry();
|
|
1803
1803
|
const [agents, setAgents] = useState<AgentEntry[]>([]);
|
|
1804
1804
|
const [loading, setLoading] = useState(true);
|
|
1805
1805
|
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
@@ -2126,6 +2126,13 @@ function AgentsSection({ settings, setSettings }: { settings: any; setSettings:
|
|
|
2126
2126
|
{/* Preset models */}
|
|
2127
2127
|
<div className="flex items-center gap-1 mt-1.5 flex-wrap">
|
|
2128
2128
|
<span className="text-[8px] text-[var(--text-secondary)]">Presets:</span>
|
|
2129
|
+
<button
|
|
2130
|
+
type="button"
|
|
2131
|
+
onClick={() => { void refreshModels(); }}
|
|
2132
|
+
disabled={modelsLoading}
|
|
2133
|
+
title="Refresh model list from the public-info registry (bypasses the 24h cache)"
|
|
2134
|
+
className="text-[8px] px-1 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
2135
|
+
>{modelsLoading ? '…' : '↻'}</button>
|
|
2129
2136
|
{((() => {
|
|
2130
2137
|
const ct = (settings.agents?.[a.id] as any)?.cliType || (a.id === 'claude' ? 'claude-code' : a.id === 'codex' ? 'codex' : a.id === 'aider' ? 'aider' : 'generic');
|
|
2131
2138
|
// Models pulled from forge-public-info repo (lib/public-info/fetch.ts).
|
|
@@ -116,6 +116,53 @@ async function resolveScratchRefsInArgs(args: Record<string, unknown>): Promise<
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Download channel (mirror of the scratch:// upload path). A connector that
|
|
120
|
+
// fetched a binary (e.g. owa.download_attachment) can't return the bytes
|
|
121
|
+
// through the model — the 16KB tool-result cap would shred any base64. So it
|
|
122
|
+
// returns `_files: [{ filename, content_base64, content_type }]` and we
|
|
123
|
+
// materialize them HERE: decode → write to <dataDir>/tmp/ → strip the base64
|
|
124
|
+
// and replace each entry with a small {filename, path, file_url, size_bytes}
|
|
125
|
+
// pointer the model can hand to read_forge_file / extract_archive. Bytes
|
|
126
|
+
// never reach the LLM. Generic — any protocol's result is scanned.
|
|
127
|
+
async function materializeConnectorFiles(result: ToolResult): Promise<ToolResult> {
|
|
128
|
+
let parsed: unknown;
|
|
129
|
+
try { parsed = JSON.parse(result.content); } catch { return result; }
|
|
130
|
+
if (!parsed || typeof parsed !== 'object') return result;
|
|
131
|
+
const files = (parsed as { _files?: unknown })._files;
|
|
132
|
+
if (!Array.isArray(files) || files.length === 0) return result;
|
|
133
|
+
|
|
134
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
135
|
+
const { dirname, basename } = await import('node:path');
|
|
136
|
+
const out: Array<Record<string, unknown>> = [];
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
const e = (f && typeof f === 'object') ? f as Record<string, unknown> : {};
|
|
139
|
+
const b64 = typeof e.content_base64 === 'string' ? e.content_base64 : '';
|
|
140
|
+
const rawName = String(e.filename || e.name || 'download.bin');
|
|
141
|
+
// Bare, sanitized name under tmp/ — no traversal, no nesting (the cache
|
|
142
|
+
// janitor only sweeps top-level tmp/ files).
|
|
143
|
+
const safe = (basename(rawName).replace(/[\0/\\]/g, '_').replace(/^\.+/, '') || 'download.bin').slice(0, 200);
|
|
144
|
+
if (!b64) { out.push({ ...e, error: 'no content_base64' }); continue; }
|
|
145
|
+
try {
|
|
146
|
+
const buf = Buffer.from(b64, 'base64');
|
|
147
|
+
const target = await resolveTmpPath(safe);
|
|
148
|
+
await mkdir(dirname(target), { recursive: true });
|
|
149
|
+
await writeFile(target, buf);
|
|
150
|
+
out.push({
|
|
151
|
+
filename: safe,
|
|
152
|
+
path: `tmp/${safe}`,
|
|
153
|
+
local_path: target,
|
|
154
|
+
file_url: `file://${target}`,
|
|
155
|
+
size_bytes: buf.length,
|
|
156
|
+
...(e.content_type ? { content_type: e.content_type } : {}),
|
|
157
|
+
});
|
|
158
|
+
} catch (err) {
|
|
159
|
+
out.push({ filename: safe, error: (err as Error).message });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const next = { ...(parsed as Record<string, unknown>), _files: out };
|
|
163
|
+
return { ...result, content: JSON.stringify(next) };
|
|
164
|
+
}
|
|
165
|
+
|
|
119
166
|
const BUILTINS: Record<string, BuiltinHandler> = {
|
|
120
167
|
get_current_time: async () => new Date().toISOString(),
|
|
121
168
|
|
|
@@ -475,6 +522,82 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
475
522
|
});
|
|
476
523
|
},
|
|
477
524
|
|
|
525
|
+
// Extract a zip/tar/gz archive sitting in tmp/ (e.g. one a connector just
|
|
526
|
+
// downloaded via the _files channel) into tmp/<base>-extracted/, then
|
|
527
|
+
// return the file listing so the agent can read_forge_file each entry.
|
|
528
|
+
// The chat agent has no shell, so this shells out to system unzip/tar/
|
|
529
|
+
// gunzip on its behalf. Stays inside tmp/ on both ends.
|
|
530
|
+
extract_archive: async (input) => {
|
|
531
|
+
const params = (input as { filename?: string } | undefined) || {};
|
|
532
|
+
const raw = String(params.filename || '').trim().replace(/^tmp\//, '');
|
|
533
|
+
if (!raw) return JSON.stringify({ ok: false, error: 'filename is required (a tmp/ archive, e.g. "report.zip")' });
|
|
534
|
+
let archive: string;
|
|
535
|
+
try { archive = await resolveTmpPath(raw); }
|
|
536
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
537
|
+
const { existsSync } = await import('node:fs');
|
|
538
|
+
if (!existsSync(archive)) return JSON.stringify({ ok: false, error: `archive not found: tmp/${raw}` });
|
|
539
|
+
|
|
540
|
+
const lower = raw.toLowerCase();
|
|
541
|
+
const base = raw.replace(/\.(zip|tar\.gz|tgz|tar|gz)$/i, '');
|
|
542
|
+
const outRel = `${base}-extracted`;
|
|
543
|
+
let outDir: string;
|
|
544
|
+
try { outDir = await resolveTmpPath(outRel); }
|
|
545
|
+
catch (e) { return JSON.stringify({ ok: false, error: (e as Error).message }); }
|
|
546
|
+
|
|
547
|
+
const { execFile } = await import('node:child_process');
|
|
548
|
+
const { promisify } = await import('node:util');
|
|
549
|
+
const { mkdir, readdir, stat } = await import('node:fs/promises');
|
|
550
|
+
const { join, relative } = await import('node:path');
|
|
551
|
+
const run = promisify(execFile);
|
|
552
|
+
await mkdir(outDir, { recursive: true });
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
if (lower.endsWith('.zip')) {
|
|
556
|
+
await run('unzip', ['-o', '-qq', archive, '-d', outDir], { timeout: 60000 });
|
|
557
|
+
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
|
|
558
|
+
await run('tar', ['-xf', archive, '-C', outDir], { timeout: 60000 });
|
|
559
|
+
} else if (lower.endsWith('.gz')) {
|
|
560
|
+
// single-file gzip → decompress to the same basename inside outDir
|
|
561
|
+
const { createReadStream, createWriteStream } = await import('node:fs');
|
|
562
|
+
const { createGunzip } = await import('node:zlib');
|
|
563
|
+
const { pipeline } = await import('node:stream/promises');
|
|
564
|
+
const dest = join(outDir, base.split('/').pop() || 'file');
|
|
565
|
+
await pipeline(createReadStream(archive), createGunzip(), createWriteStream(dest));
|
|
566
|
+
} else {
|
|
567
|
+
return JSON.stringify({ ok: false, error: `unsupported archive type: ${raw} (supported: .zip .tar .tar.gz .tgz .gz)` });
|
|
568
|
+
}
|
|
569
|
+
} catch (e) {
|
|
570
|
+
return JSON.stringify({ ok: false, error: `extraction failed: ${(e as Error).message}` });
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Walk the extracted tree (capped) and return tmp-relative paths.
|
|
574
|
+
const files: Array<{ path: string; size_bytes: number }> = [];
|
|
575
|
+
const { getDataDir } = await import('../dirs');
|
|
576
|
+
const tmpRoot = join(getDataDir(), 'tmp');
|
|
577
|
+
async function walk(dir: string, depth: number): Promise<void> {
|
|
578
|
+
if (files.length >= 500 || depth > 8) return;
|
|
579
|
+
for (const ent of await readdir(dir, { withFileTypes: true })) {
|
|
580
|
+
if (files.length >= 500) break;
|
|
581
|
+
const full = join(dir, ent.name);
|
|
582
|
+
if (ent.isDirectory()) { await walk(full, depth + 1); continue; }
|
|
583
|
+
if (ent.isFile()) {
|
|
584
|
+
const s = await stat(full);
|
|
585
|
+
files.push({ path: `tmp/${relative(tmpRoot, full)}`, size_bytes: s.size });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
try { await walk(outDir, 0); } catch { /* partial listing is fine */ }
|
|
590
|
+
|
|
591
|
+
return JSON.stringify({
|
|
592
|
+
ok: true,
|
|
593
|
+
archive: `tmp/${raw}`,
|
|
594
|
+
extracted_dir: `tmp/${outRel}`,
|
|
595
|
+
count: files.length,
|
|
596
|
+
files,
|
|
597
|
+
note: 'Read any entry with read_forge_file (pass its `path`). Extracted dir is NOT auto-swept by the cache janitor (it only sweeps top-level tmp/ files).',
|
|
598
|
+
});
|
|
599
|
+
},
|
|
600
|
+
|
|
478
601
|
// List files anywhere under <dataDir>/ (Forge's own data dir).
|
|
479
602
|
// Replaces the "dispatch a task just to `ls`" workaround. The `dir`
|
|
480
603
|
// param is dataDir-relative — pass "tmp" / "scratch" / "flows" /
|
|
@@ -626,8 +749,11 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
626
749
|
const p = (input as any) || {};
|
|
627
750
|
const name = String(p.name || '').trim();
|
|
628
751
|
const workflow = String(p.workflow || p.body_ref || '').trim();
|
|
752
|
+
const promptText = String(p.prompt || '').trim();
|
|
629
753
|
if (!name) return JSON.stringify({ ok: false, error: 'name is required' });
|
|
630
|
-
if (!workflow
|
|
754
|
+
if (!workflow && !promptText) {
|
|
755
|
+
return JSON.stringify({ ok: false, error: 'provide either prompt (free-text instructions — dispatched as a one-shot Claude task WITH connector tools) or workflow (an existing pipeline name)' });
|
|
756
|
+
}
|
|
631
757
|
|
|
632
758
|
// Trigger normalization: prefer every_minutes; accept at (once) or cron.
|
|
633
759
|
let schedule_kind: 'period' | 'once' | 'cron' = 'period';
|
|
@@ -647,14 +773,36 @@ const BUILTINS: Record<string, BuiltinHandler> = {
|
|
|
647
773
|
return JSON.stringify({ ok: false, error: 'one of every_minutes / at / cron is required' });
|
|
648
774
|
}
|
|
649
775
|
|
|
776
|
+
// Body resolution. prompt mode (preferred for "run these instructions on
|
|
777
|
+
// a schedule") wins when both are somehow given — it dispatches a one-shot
|
|
778
|
+
// Claude task that HAS the full connector toolset (Teams/Mantis/etc.), so
|
|
779
|
+
// no pipeline YAML is needed. The prompt text is persisted to
|
|
780
|
+
// prompts/<slug>.yaml and the schedule row just references it by name.
|
|
781
|
+
let body_kind: 'pipeline' | 'prompt' = 'pipeline';
|
|
782
|
+
let body_ref = workflow;
|
|
783
|
+
const skills = Array.isArray(p.skills) ? p.skills : undefined;
|
|
784
|
+
if (promptText) {
|
|
785
|
+
const slug = (name.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'schedule').slice(0, 64);
|
|
786
|
+
try {
|
|
787
|
+
const { createPrompt, getPrompt, updatePrompt } = await import('../prompts/store');
|
|
788
|
+
const execu = skills ? { skills } : undefined;
|
|
789
|
+
if (getPrompt(slug)) updatePrompt(slug, { prompt: promptText, executor: execu });
|
|
790
|
+
else createPrompt({ name: slug, prompt: promptText, executor: execu });
|
|
791
|
+
} catch (e) {
|
|
792
|
+
return JSON.stringify({ ok: false, error: `failed to save prompt: ${(e as Error).message}` });
|
|
793
|
+
}
|
|
794
|
+
body_kind = 'prompt';
|
|
795
|
+
body_ref = slug;
|
|
796
|
+
}
|
|
797
|
+
|
|
650
798
|
const { createSchedule, seedNextRunAt } = await import('../schedules/store');
|
|
651
799
|
try {
|
|
652
800
|
const s = createSchedule({
|
|
653
801
|
name,
|
|
654
|
-
body_kind
|
|
655
|
-
body_ref
|
|
802
|
+
body_kind,
|
|
803
|
+
body_ref,
|
|
656
804
|
input: (p.input && typeof p.input === 'object') ? p.input : {},
|
|
657
|
-
skills
|
|
805
|
+
skills,
|
|
658
806
|
enabled: p.enabled !== false,
|
|
659
807
|
schedule_kind,
|
|
660
808
|
schedule_interval_minutes,
|
|
@@ -889,6 +1037,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
889
1037
|
required: ['filename'],
|
|
890
1038
|
},
|
|
891
1039
|
},
|
|
1040
|
+
{
|
|
1041
|
+
name: 'extract_archive',
|
|
1042
|
+
description: 'Unpack an archive sitting in tmp/ (e.g. one a connector just downloaded — owa.download_attachment etc.) into tmp/<base>-extracted/, and return the file listing. USE THIS when an attachment / download is a .zip / .tar / .tar.gz / .tgz / .gz and you need to read what is inside — you have no shell, this runs unzip/tar for you. Then read individual entries with read_forge_file using each returned `path`. Returns JSON {ok, extracted_dir, count, files:[{path,size_bytes}]}.',
|
|
1043
|
+
input_schema: {
|
|
1044
|
+
type: 'object',
|
|
1045
|
+
properties: {
|
|
1046
|
+
filename: { type: 'string', description: 'tmp/ archive to extract, e.g. "report.zip" or "tmp/logs.tar.gz". Must already be in tmp/ (a download lands there via the connector _files channel).' },
|
|
1047
|
+
},
|
|
1048
|
+
required: ['filename'],
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
892
1051
|
{
|
|
893
1052
|
name: 'list_forge_files',
|
|
894
1053
|
description: 'List files / subdirs anywhere under <dataDir>/ (Forge\'s own data dir). PREFER THIS over `dispatch_task` with `ls` — it\'s sync, in-process, no LLM detour. `dir` is dataDir-relative: pass "tmp" for chat-saved temp files, "scratch" for task workspaces, "flows" / "prompts" / "connectors" for configs. Omit `dir` to see the dataDir root. Each entry has `path` (dataDir-relative), `kind` (file/dir), and `file_url` (file://… opens in Chrome on localhost). Sensitive items (encrypt key, sqlite DBs, server log, *-tokens.json) are filtered out automatically.',
|
|
@@ -947,12 +1106,13 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
947
1106
|
},
|
|
948
1107
|
{
|
|
949
1108
|
name: 'create_schedule',
|
|
950
|
-
description: 'Create a recurring (or one-off) schedule
|
|
1109
|
+
description: 'Create a recurring (or one-off) schedule on a timer. NO HTTP, NO auth — runs in-process. Use when the user says "every N minutes/hours" / "watch X" / "monitor Y" / "auto-run on schedule". TWO body modes — pick ONE:\n• prompt mode (PREFERRED for "do these instructions on a schedule"): pass `prompt` with free-text instructions. Each fire dispatches a one-shot Claude task that HAS THE FULL CONNECTOR TOOLSET (Teams/Mantis/GitLab/etc.) and your skills. No pipeline YAML needed — do NOT build a throwaway pipeline just to run a prompt on a timer.\n• pipeline mode: pass `workflow` (an existing pipeline name) for a structured multi-node DAG.\nREQUIRED: name + (prompt OR workflow) + ONE of {every_minutes, at, cron}. Returns { ok, schedule_id, next_run_at }.',
|
|
951
1110
|
input_schema: {
|
|
952
1111
|
type: 'object',
|
|
953
1112
|
properties: {
|
|
954
|
-
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI.' },
|
|
955
|
-
|
|
1113
|
+
name: { type: 'string', description: 'Human-readable name shown in the Schedules UI. Also used as the prompt slug in prompt mode.' },
|
|
1114
|
+
prompt: { type: 'string', description: 'PROMPT MODE: free-text instructions run as a one-shot Claude task on each fire, with full connector tools + skills. Use this for "every hour check Teams channel X and notify me" style asks. Mutually exclusive with workflow.' },
|
|
1115
|
+
workflow: { type: 'string', description: 'PIPELINE MODE: existing pipeline workflow name (basename of flows/<name>.yaml). Run trigger_pipeline() with NO args first to list. Mutually exclusive with prompt.' },
|
|
956
1116
|
input: { type: 'object', description: 'Pipeline input fields. Same shape as trigger_pipeline.input. OMIT optional fields to use defaults.' },
|
|
957
1117
|
skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to inject into every Claude task this schedule spawns.' },
|
|
958
1118
|
every_minutes: { type: 'number', description: 'Period in minutes (e.g. 60 = hourly). Most common trigger.' },
|
|
@@ -961,7 +1121,7 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
|
|
|
961
1121
|
enabled: { type: 'boolean', description: 'Whether to start enabled. Default true.' },
|
|
962
1122
|
action: { type: 'string', enum: ['none', 'chat', 'email', 'telegram'], description: 'Post-run notification action. Default "none".' },
|
|
963
1123
|
},
|
|
964
|
-
required: ['name'
|
|
1124
|
+
required: ['name'],
|
|
965
1125
|
},
|
|
966
1126
|
},
|
|
967
1127
|
{
|
|
@@ -1374,6 +1534,14 @@ export async function dispatchTool(
|
|
|
1374
1534
|
return { content: `unknown protocol "${protocol}" on tool ${call.name}`, is_error: true };
|
|
1375
1535
|
}
|
|
1376
1536
|
|
|
1537
|
+
// Download channel: materialize any `_files` base64 payload to tmp/ and
|
|
1538
|
+
// strip the bytes before they reach the model (and before the watch
|
|
1539
|
+
// below re-parses the result). No-op when the result has no _files.
|
|
1540
|
+
if (!result.is_error) {
|
|
1541
|
+
try { result = await materializeConnectorFiles(result); }
|
|
1542
|
+
catch (e) { console.warn('[dispatch] materializeConnectorFiles failed', (e as Error).message); }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1377
1545
|
// Async (long-task watch): if the tool declared an `async` block and
|
|
1378
1546
|
// it ran without error, register a background watch that polls to
|
|
1379
1547
|
// completion and reports back to the originating chat session. The
|
package/lib/connectors/sync.ts
CHANGED
|
@@ -335,11 +335,16 @@ export async function fetchSourceFile(source: SourceMeta, relPath: string): Prom
|
|
|
335
335
|
// directly (no base64 decode needed). Works for private repos given
|
|
336
336
|
// a Fine-grained PAT with Contents: read.
|
|
337
337
|
const repo = (source.repo_url || '').replace(/^github\.com\//, '');
|
|
338
|
-
|
|
338
|
+
// &_t + no-cache: the GitHub contents API edge-caches the ?ref=main blob,
|
|
339
|
+
// so right after a push a plain fetch can return the STALE file — which
|
|
340
|
+
// made the marketplace keep showing old connector versions even after
|
|
341
|
+
// "↻ Sync all". The raw mode already busts via cacheBust(); match it here.
|
|
342
|
+
const url = `https://api.github.com/repos/${repo}/contents/${relPath}?ref=main&_t=${Date.now()}`;
|
|
339
343
|
try {
|
|
340
344
|
return await rawFetch(url, {
|
|
341
345
|
Authorization: `Bearer ${source.github_pat}`,
|
|
342
346
|
Accept: 'application/vnd.github.raw',
|
|
347
|
+
'Cache-Control': 'no-cache',
|
|
343
348
|
});
|
|
344
349
|
} catch (err) {
|
|
345
350
|
// GitHub returns 404 (not 401) for private repos when the PAT
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
# Schedules
|
|
2
2
|
|
|
3
|
-
**Schedule = a
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**Schedule = a body + trigger.** The trigger decides *when* to fire; the body
|
|
4
|
+
decides *what* runs. There are two body kinds — pick whichever fits:
|
|
5
|
+
|
|
6
|
+
- **Prompt** (simplest, most common) — free-text instructions run as a one-shot
|
|
7
|
+
Claude task on each fire. **The task has the full connector toolset
|
|
8
|
+
(Teams / Mantis / GitLab / etc.) plus any skills you attach** — so "every hour
|
|
9
|
+
check the FortiNAC Teams channel and message me anything new" is a single
|
|
10
|
+
prompt schedule. You do **not** need a pipeline for this. The prompt text is
|
|
11
|
+
saved to `prompts/<slug>.yaml` and the schedule row references it by name.
|
|
12
|
+
- **Pipeline** — fires an existing pipeline (`flows/<name>.yaml`) with input.
|
|
13
|
+
Use when you need a structured multi-node DAG, `for_each:` iteration, or
|
|
14
|
+
per-node project/worktree control.
|
|
15
|
+
|
|
16
|
+
> Don't build a throwaway pipeline just to run a prompt on a timer — use prompt
|
|
17
|
+
> mode. The chat `create_schedule` tool accepts either `prompt` or `workflow`.
|
|
6
18
|
|
|
7
19
|
**Note:** Jobs (the older subsystem) is deprecated — its UI is hidden and the
|
|
8
20
|
scheduler no longer ticks. Any existing rows in the `jobs` table are inert.
|
package/package.json
CHANGED
package/publish.sh
CHANGED
|
@@ -9,11 +9,18 @@
|
|
|
9
9
|
|
|
10
10
|
set -e
|
|
11
11
|
|
|
12
|
-
# Preflight: fail BEFORE any version bump / commit / tag / push if gh
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# Preflight: fail BEFORE any version bump / commit / tag / push if gh can't
|
|
13
|
+
# actually talk to GitHub. We hit a real endpoint (gh api user) instead of
|
|
14
|
+
# `gh auth status` — status only checks a token is STORED, not that it still
|
|
15
|
+
# works; a stale/revoked keyring token passes status but 401s on every call.
|
|
16
|
+
GH_LOGIN_HINT="gh auth login --hostname github.com --web --git-protocol ssh"
|
|
15
17
|
if command -v gh &> /dev/null; then
|
|
16
|
-
gh
|
|
18
|
+
gh api user &> /dev/null || {
|
|
19
|
+
echo "✗ gh can't authenticate to GitHub (token missing/expired/revoked)."
|
|
20
|
+
echo " Re-auth, then re-run ./publish.sh:"
|
|
21
|
+
echo " $GH_LOGIN_HINT"
|
|
22
|
+
exit 1
|
|
23
|
+
}
|
|
17
24
|
fi
|
|
18
25
|
|
|
19
26
|
VERSION_ARG=${1:-patch}
|
|
@@ -144,8 +151,18 @@ git push origin "v$NEW_VERSION"
|
|
|
144
151
|
if command -v gh &> /dev/null; then
|
|
145
152
|
echo ""
|
|
146
153
|
echo "Creating GitHub Release..."
|
|
147
|
-
gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"
|
|
148
|
-
|
|
154
|
+
if gh release create "v$NEW_VERSION" --title "v$NEW_VERSION" --notes-file "$RELEASE_NOTES_FILE"; then
|
|
155
|
+
echo "✓ GitHub Release created: https://github.com/aiwatching/forge/releases/tag/v$NEW_VERSION"
|
|
156
|
+
else
|
|
157
|
+
# The tag is already pushed at this point — re-auth and just create the
|
|
158
|
+
# release, no need to re-run the whole publish. A stale keyring token
|
|
159
|
+
# 401s here even though `gh auth status` claims you're logged in.
|
|
160
|
+
echo ""
|
|
161
|
+
echo "✗ GitHub Release failed (usually a stale gh token — 401). The tag v$NEW_VERSION is already pushed."
|
|
162
|
+
echo " Fix: re-auth, then create just the release:"
|
|
163
|
+
echo " $GH_LOGIN_HINT"
|
|
164
|
+
echo " gh release create v$NEW_VERSION --title v$NEW_VERSION --notes-file $RELEASE_NOTES_FILE"
|
|
165
|
+
fi
|
|
149
166
|
else
|
|
150
167
|
echo ""
|
|
151
168
|
echo "gh CLI not found. Create release manually:"
|