@aion0/forge 0.9.1 → 0.9.3
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 +5 -5
- package/app/api/agents/[id]/test/route.ts +150 -0
- package/app/api/connectors/[id]/sync-cli/route.ts +73 -0
- package/app/api/connectors/tool-test/route.ts +70 -0
- package/app/api/jobs/[id]/cancel/route.ts +50 -0
- package/app/api/jobs/[id]/dispatched-pipelines/route.ts +24 -0
- package/app/api/jobs/[id]/run/route.ts +22 -2
- package/app/api/jobs/route.ts +11 -1
- package/app/api/pipelines/[id]/schema/route.ts +53 -0
- package/app/api/pipelines/bulk-delete/route.ts +39 -0
- package/app/api/pipelines/gc/route.ts +27 -0
- package/app/api/schedules/[id]/cancel/route.ts +27 -0
- package/app/api/schedules/[id]/route.ts +173 -0
- package/app/api/schedules/[id]/run/route.ts +45 -0
- package/app/api/schedules/[id]/runs/route.ts +22 -0
- package/app/api/schedules/[id]/stop/route.ts +33 -0
- package/app/api/schedules/route.ts +175 -0
- package/app/api/tasks/bulk-delete/route.ts +47 -0
- package/bin/forge-server.mjs +22 -1
- package/cli/mw.mjs +186 -7657
- package/cli/mw.ts +26 -0
- package/components/ConnectorsPanel.tsx +46 -0
- package/components/Dashboard.tsx +23 -10
- package/components/JobsView.tsx +245 -6
- package/components/PipelineEditor.tsx +38 -1
- package/components/PipelineView.tsx +325 -4
- package/components/ScheduleCreateModal.tsx +1507 -0
- package/components/SchedulesView.tsx +605 -0
- package/components/SettingsModal.tsx +106 -0
- package/docs/Team-Workflow-Integration.md +487 -0
- package/docs/UI-Design-Brief-SidePanel.md +278 -0
- package/lib/__tests__/foreach-batch-yaml.test.ts +33 -0
- package/lib/__tests__/foreach-before.test.ts +201 -0
- package/lib/__tests__/foreach-parse.test.ts +114 -0
- package/lib/__tests__/foreach-snapshot.test.ts +112 -0
- package/lib/__tests__/foreach-source.test.ts +105 -0
- package/lib/__tests__/foreach-template.test.ts +112 -0
- package/lib/chat/agent-loop.ts +3 -3
- package/lib/chat-standalone.ts +26 -1
- package/lib/claude-process.ts +8 -5
- package/lib/connectors/sync.ts +8 -2
- package/lib/crypto.ts +1 -1
- package/lib/dirs.ts +22 -7
- package/lib/help-docs/05-pipelines.md +171 -0
- package/lib/help-docs/13-schedules.md +165 -0
- package/lib/help-docs/23-automation-states.md +148 -0
- package/lib/help-docs/CLAUDE.md +6 -6
- package/lib/init.ts +25 -6
- package/lib/jobs/recipes.ts +3 -2
- package/lib/jobs/scheduler.ts +215 -11
- package/lib/jobs/store.ts +79 -3
- package/lib/jobs/types.ts +31 -0
- package/lib/logger.ts +1 -1
- package/lib/notify.ts +13 -6
- package/lib/pipeline-gc.ts +105 -0
- package/lib/pipeline-scheduler.ts +29 -0
- package/lib/pipeline.ts +811 -330
- package/lib/schedules/action-runner.ts +257 -0
- package/lib/schedules/scheduler.ts +422 -0
- package/lib/schedules/state.ts +41 -0
- package/lib/schedules/store.ts +618 -0
- package/lib/schedules/types.ts +117 -0
- package/lib/settings.ts +35 -0
- package/lib/task-manager.ts +56 -13
- package/lib/telegram-bot.ts +9 -3
- package/lib/workflow-marketplace.ts +7 -1
- package/lib/workspace/skill-installer.ts +7 -6
- package/package.json +3 -1
- package/lib/help-docs/19-jobs.md +0 -145
- package/lib/help-docs/20-mantis-bug-fix.md +0 -115
- package/lib/help-docs/22-recipes.md +0 -124
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-5 verification: snapshotIteration captures node states + correctly
|
|
3
|
+
* advances the iterations[] history. (Full checkPipelineCompletion has
|
|
4
|
+
* filesystem + scheduling side effects — exercised end-to-end in Step 7.)
|
|
5
|
+
*
|
|
6
|
+
* Run: npx tsx lib/__tests__/foreach-snapshot.test.ts
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { snapshotIteration } from '../pipeline';
|
|
10
|
+
|
|
11
|
+
let passed = 0, failed = 0;
|
|
12
|
+
function check(name: string, fn: () => void) {
|
|
13
|
+
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
|
14
|
+
catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makePipeline(items: unknown[], currentIndex = 0): any {
|
|
18
|
+
return {
|
|
19
|
+
id: 'test123',
|
|
20
|
+
workflowName: 'test',
|
|
21
|
+
status: 'running',
|
|
22
|
+
input: {},
|
|
23
|
+
vars: {},
|
|
24
|
+
nodes: {
|
|
25
|
+
a: { status: 'done', outputs: { x: '1' } },
|
|
26
|
+
b: { status: 'done', outputs: { y: '2' }, error: undefined },
|
|
27
|
+
},
|
|
28
|
+
nodeOrder: ['a', 'b'],
|
|
29
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
30
|
+
forEach: {
|
|
31
|
+
items,
|
|
32
|
+
currentIndex,
|
|
33
|
+
total: items.length,
|
|
34
|
+
asName: 'item',
|
|
35
|
+
onFailure: 'continue',
|
|
36
|
+
iterations: [],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log('snapshotIteration tests');
|
|
42
|
+
|
|
43
|
+
check('snapshot captures all-done nodes with status="done"', () => {
|
|
44
|
+
const p = makePipeline([1, 2, 3]);
|
|
45
|
+
snapshotIteration(p, false);
|
|
46
|
+
if (p.forEach.iterations.length !== 1) throw new Error('iterations length wrong');
|
|
47
|
+
const iter = p.forEach.iterations[0];
|
|
48
|
+
if (iter.status !== 'done') throw new Error(`status wrong: ${iter.status}`);
|
|
49
|
+
if (iter.index !== 0) throw new Error('index wrong');
|
|
50
|
+
if (iter.nodes.a.status !== 'done') throw new Error('node a status wrong');
|
|
51
|
+
if (iter.nodes.a.outputs.x !== '1') throw new Error('node a output not preserved');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
check('snapshot captures failed iter with status="failed"', () => {
|
|
55
|
+
const p = makePipeline([1, 2]);
|
|
56
|
+
p.nodes.b.status = 'failed';
|
|
57
|
+
p.nodes.b.error = 'boom';
|
|
58
|
+
snapshotIteration(p, true);
|
|
59
|
+
const iter = p.forEach.iterations[0];
|
|
60
|
+
if (iter.status !== 'failed') throw new Error(`status wrong: ${iter.status}`);
|
|
61
|
+
if (iter.nodes.b.error !== 'boom') throw new Error('error not preserved');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
check('startedAt = createdAt for first iter', () => {
|
|
65
|
+
const p = makePipeline([1, 2]);
|
|
66
|
+
snapshotIteration(p, false);
|
|
67
|
+
if (p.forEach.iterations[0].startedAt !== p.createdAt) throw new Error('first startedAt should = createdAt');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
check('startedAt = previous completedAt for subsequent iters', () => {
|
|
71
|
+
const p = makePipeline([1, 2]);
|
|
72
|
+
snapshotIteration(p, false);
|
|
73
|
+
const firstCompleted = p.forEach.iterations[0].completedAt;
|
|
74
|
+
p.forEach.currentIndex = 1;
|
|
75
|
+
snapshotIteration(p, false);
|
|
76
|
+
if (p.forEach.iterations[1].startedAt !== firstCompleted) {
|
|
77
|
+
throw new Error('second iter startedAt should chain from prev completedAt');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
check('outputs are deep-copied (mutation safety)', () => {
|
|
82
|
+
const p = makePipeline([1]);
|
|
83
|
+
snapshotIteration(p, false);
|
|
84
|
+
const iter = p.forEach.iterations[0];
|
|
85
|
+
// Mutate live node — snapshot should NOT change
|
|
86
|
+
p.nodes.a.outputs.x = 'MUTATED';
|
|
87
|
+
if (iter.nodes.a.outputs.x !== '1') throw new Error('snapshot was mutated; need defensive copy');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
check('multiple iterations accumulate', () => {
|
|
91
|
+
const p = makePipeline([1, 2, 3]);
|
|
92
|
+
snapshotIteration(p, false);
|
|
93
|
+
p.forEach.currentIndex = 1;
|
|
94
|
+
snapshotIteration(p, false);
|
|
95
|
+
p.forEach.currentIndex = 2;
|
|
96
|
+
snapshotIteration(p, true);
|
|
97
|
+
if (p.forEach.iterations.length !== 3) throw new Error('expected 3 iters');
|
|
98
|
+
if (p.forEach.iterations[2].status !== 'failed') throw new Error('iter 3 should be failed');
|
|
99
|
+
if (p.forEach.iterations[0].index !== 0) throw new Error('iter 0 index wrong');
|
|
100
|
+
if (p.forEach.iterations[1].index !== 1) throw new Error('iter 1 index wrong');
|
|
101
|
+
if (p.forEach.iterations[2].index !== 2) throw new Error('iter 2 index wrong');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
check('no-op when pipeline.forEach undefined', () => {
|
|
105
|
+
const p: any = { id: 'x', nodes: {}, createdAt: '2026-01-01' };
|
|
106
|
+
snapshotIteration(p, false);
|
|
107
|
+
// Should not throw, should not add anything
|
|
108
|
+
if (p.forEach !== undefined) throw new Error('should leave forEach undefined');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
112
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-4 verification: resolveForEachSource resolves + splits items.
|
|
3
|
+
* Run: npx tsx lib/__tests__/foreach-source.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveForEachSource } from '../pipeline';
|
|
7
|
+
|
|
8
|
+
let passed = 0, failed = 0;
|
|
9
|
+
function check(name: string, fn: () => void) {
|
|
10
|
+
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
|
11
|
+
catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
|
|
12
|
+
}
|
|
13
|
+
function eqArr(a: unknown[], b: unknown[], label = '') {
|
|
14
|
+
if (a.length !== b.length || a.some((v, i) => v !== b[i])) {
|
|
15
|
+
throw new Error(`${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log('resolveForEachSource tests');
|
|
20
|
+
|
|
21
|
+
check('string source via {{input.x}} template, default split=","', () => {
|
|
22
|
+
const items = resolveForEachSource(
|
|
23
|
+
{ source: '{{input.ids}}' },
|
|
24
|
+
{ ids: '111,222,333' },
|
|
25
|
+
{},
|
|
26
|
+
);
|
|
27
|
+
eqArr(items, ['111', '222', '333']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
check('string source with whitespace around commas — trimmed', () => {
|
|
31
|
+
const items = resolveForEachSource(
|
|
32
|
+
{ source: '{{input.ids}}' },
|
|
33
|
+
{ ids: ' 111 ,222 , 333 ' },
|
|
34
|
+
{},
|
|
35
|
+
);
|
|
36
|
+
eqArr(items, ['111', '222', '333']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
check('string source with empty entries dropped', () => {
|
|
40
|
+
const items = resolveForEachSource(
|
|
41
|
+
{ source: '{{input.ids}}' },
|
|
42
|
+
{ ids: '111,,222, ,333,' },
|
|
43
|
+
{},
|
|
44
|
+
);
|
|
45
|
+
eqArr(items, ['111', '222', '333']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
check('empty input.ids → empty array', () => {
|
|
49
|
+
const items = resolveForEachSource({ source: '{{input.ids}}' }, { ids: '' }, {});
|
|
50
|
+
eqArr(items, []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
check('input missing → empty string template → empty array', () => {
|
|
54
|
+
const items = resolveForEachSource({ source: '{{input.missing}}' }, {}, {});
|
|
55
|
+
eqArr(items, []);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
check('custom split: ";"', () => {
|
|
59
|
+
const items = resolveForEachSource(
|
|
60
|
+
{ source: '{{input.ids}}', split: ';' },
|
|
61
|
+
{ ids: '111;222;333' },
|
|
62
|
+
{},
|
|
63
|
+
);
|
|
64
|
+
eqArr(items, ['111', '222', '333']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
check('array literal source — used as-is', () => {
|
|
68
|
+
const items = resolveForEachSource(
|
|
69
|
+
{ source: ['a', 'b', 'c'] },
|
|
70
|
+
{},
|
|
71
|
+
{},
|
|
72
|
+
);
|
|
73
|
+
eqArr(items, ['a', 'b', 'c']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
check('array literal with objects — preserved', () => {
|
|
77
|
+
const items = resolveForEachSource(
|
|
78
|
+
{ source: [{ iid: 1 }, { iid: 2 }] },
|
|
79
|
+
{},
|
|
80
|
+
{},
|
|
81
|
+
);
|
|
82
|
+
if (items.length !== 2) throw new Error('length wrong');
|
|
83
|
+
if ((items[0] as any).iid !== 1) throw new Error('first item wrong');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
check('vars source resolves', () => {
|
|
87
|
+
const items = resolveForEachSource(
|
|
88
|
+
{ source: '{{vars.list}}' },
|
|
89
|
+
{},
|
|
90
|
+
{ list: 'a,b,c' },
|
|
91
|
+
);
|
|
92
|
+
eqArr(items, ['a', 'b', 'c']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
check('literal string with no template, just split', () => {
|
|
96
|
+
const items = resolveForEachSource(
|
|
97
|
+
{ source: 'foo,bar' },
|
|
98
|
+
{},
|
|
99
|
+
{},
|
|
100
|
+
);
|
|
101
|
+
eqArr(items, ['foo', 'bar']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
105
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step-3 verification: resolveTemplate handles for_each context.
|
|
3
|
+
* Run: npx tsx lib/__tests__/foreach-template.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { resolveTemplate } from '../pipeline';
|
|
7
|
+
|
|
8
|
+
let passed = 0;
|
|
9
|
+
let failed = 0;
|
|
10
|
+
function check(name: string, fn: () => void) {
|
|
11
|
+
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
|
12
|
+
catch (e) { console.log(` ✗ ${name}\n ${(e as Error).message}`); failed++; }
|
|
13
|
+
}
|
|
14
|
+
function eq(actual: string, expected: string, label = '') {
|
|
15
|
+
if (actual !== expected) {
|
|
16
|
+
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const baseCtx = { input: {}, vars: {}, nodes: {} };
|
|
21
|
+
|
|
22
|
+
console.log('resolveTemplate + for_each tests');
|
|
23
|
+
|
|
24
|
+
check('no forEach in ctx — {{item}} is literal passthrough', () => {
|
|
25
|
+
eq(resolveTemplate('hello {{item}}', baseCtx), 'hello {{item}}');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
check('scalar item: {{<as>}} substitutes', () => {
|
|
29
|
+
const ctx = { ...baseCtx, forEach: { asName: 'bug_id', item: 1213844, index: 0, total: 3 } };
|
|
30
|
+
eq(resolveTemplate('Fix bug {{bug_id}} now', ctx), 'Fix bug 1213844 now');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
check('string item: {{<as>}} preserves string', () => {
|
|
34
|
+
const ctx = { ...baseCtx, forEach: { asName: 'name', item: 'alice', index: 0, total: 1 } };
|
|
35
|
+
eq(resolveTemplate('hi {{name}}', ctx), 'hi alice');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
check('object item: {{<as>.field}} dotted access', () => {
|
|
39
|
+
const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42, title: 'fix x' }, index: 0, total: 1 } };
|
|
40
|
+
eq(resolveTemplate('MR !{{mr.iid}}: {{mr.title}}', ctx), 'MR !42: fix x');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
check('object item: missing field → empty string', () => {
|
|
44
|
+
const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42 }, index: 0, total: 1 } };
|
|
45
|
+
eq(resolveTemplate('author=@{{mr.author}}', ctx), 'author=@');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
check('object item: {{<as>}} alone → JSON', () => {
|
|
49
|
+
const ctx = { ...baseCtx, forEach: { asName: 'mr', item: { iid: 42, t: 'x' }, index: 0, total: 1 } };
|
|
50
|
+
eq(resolveTemplate('raw={{mr}}', ctx), 'raw={"iid":42,"t":"x"}');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
check('{{loop.index}} + {{loop.total}}', () => {
|
|
54
|
+
const ctx = { ...baseCtx, forEach: { asName: 'item', item: 'x', index: 2, total: 5 } };
|
|
55
|
+
eq(resolveTemplate('Step {{loop.index}} of {{loop.total}}', ctx), 'Step 2 of 5');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
check('shellMode escapes scalar item', () => {
|
|
59
|
+
const ctx = { ...baseCtx, forEach: { asName: 'name', item: "o'brien", index: 0, total: 1 } };
|
|
60
|
+
// shellEscapeAnsiC: ' → \'
|
|
61
|
+
const out = resolveTemplate("echo '{{name}}'", ctx, true);
|
|
62
|
+
if (!out.includes("o\\'brien")) throw new Error(`expected escaped apostrophe, got: ${out}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
check('{{raw:<as>}} bypasses shell escape', () => {
|
|
66
|
+
const ctx = { ...baseCtx, forEach: { asName: 'name', item: "o'brien", index: 0, total: 1 } };
|
|
67
|
+
// raw: keeps verbatim
|
|
68
|
+
eq(resolveTemplate('{{raw:name}}', ctx, true), "o'brien");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
check('input.x still works alongside forEach', () => {
|
|
72
|
+
const ctx = { input: { proj: 'FortiNAC' }, vars: {}, nodes: {}, forEach: { asName: 'bug_id', item: 1, index: 0, total: 1 } };
|
|
73
|
+
eq(resolveTemplate('{{input.proj}}/mantis-{{bug_id}}', ctx), 'FortiNAC/mantis-1');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
check('nodes.X.outputs.Y still works alongside forEach', () => {
|
|
77
|
+
const ctx: any = {
|
|
78
|
+
input: {},
|
|
79
|
+
vars: {},
|
|
80
|
+
nodes: { ingest: { status: 'done', outputs: { ctx: 'hello' }, iterations: 1 } },
|
|
81
|
+
forEach: { asName: 'item', item: 'x', index: 0, total: 1 },
|
|
82
|
+
};
|
|
83
|
+
eq(resolveTemplate('{{nodes.ingest.outputs.ctx}}/{{item}}', ctx), 'hello/x');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
check('run.tmp_dir still works alongside forEach', () => {
|
|
87
|
+
const ctx = {
|
|
88
|
+
...baseCtx,
|
|
89
|
+
tmpDir: '/path/.forge/worktrees/pipeline-abc',
|
|
90
|
+
forEach: { asName: 'bug_id', item: 42, index: 0, total: 1 },
|
|
91
|
+
};
|
|
92
|
+
eq(resolveTemplate('{{run.tmp_dir}}/mantis-{{bug_id}}', ctx),
|
|
93
|
+
'/path/.forge/worktrees/pipeline-abc/mantis-42');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
check('forEach.item = null → empty string', () => {
|
|
97
|
+
const ctx = { ...baseCtx, forEach: { asName: 'x', item: null, index: 0, total: 1 } };
|
|
98
|
+
eq(resolveTemplate('[{{x}}]', ctx), '[]');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
check('forEach.item = false → "false"', () => {
|
|
102
|
+
const ctx = { ...baseCtx, forEach: { asName: 'x', item: false, index: 0, total: 1 } };
|
|
103
|
+
eq(resolveTemplate('[{{x}}]', ctx), '[false]');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
check('loop.index works even when item is undefined-but-asName-set (defensive)', () => {
|
|
107
|
+
const ctx = { ...baseCtx, forEach: { asName: 'item', item: undefined, index: 7, total: 9 } };
|
|
108
|
+
eq(resolveTemplate('{{loop.index}}/{{loop.total}}', ctx), '7/9');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
112
|
+
process.exit(failed === 0 ? 0 : 1);
|
package/lib/chat/agent-loop.ts
CHANGED
|
@@ -72,7 +72,7 @@ interface ProviderResolution {
|
|
|
72
72
|
* Anthropic's API is the only first-class non-OpenAI shape we ship; LiteLLM /
|
|
73
73
|
* Azure / OpenAI proxies / Grok / Google-via-LiteLLM all speak OpenAI.
|
|
74
74
|
*/
|
|
75
|
-
function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
|
|
75
|
+
export function inferAdapter(provider: string | undefined): 'anthropic' | 'openai' {
|
|
76
76
|
const p = (provider || '').toLowerCase();
|
|
77
77
|
if (p === 'anthropic' || p === 'claude') return 'anthropic';
|
|
78
78
|
return 'openai';
|
|
@@ -106,7 +106,7 @@ export function defaultBaseUrl(provider: string | undefined): string {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function pickBaseUrl(
|
|
109
|
+
export function pickBaseUrl(
|
|
110
110
|
profile: { baseUrl?: string; env?: Record<string, string>; provider?: string },
|
|
111
111
|
adapter: 'anthropic' | 'openai',
|
|
112
112
|
): string {
|
|
@@ -119,7 +119,7 @@ function pickBaseUrl(
|
|
|
119
119
|
return defaultBaseUrl(profile.provider);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
|
|
122
|
+
export function pickApiKey(profile: { apiKey?: string; env?: Record<string, string> }, adapter: 'anthropic' | 'openai'): string {
|
|
123
123
|
if (profile.apiKey) return profile.apiKey;
|
|
124
124
|
const env = profile.env || {};
|
|
125
125
|
if (adapter === 'anthropic') return env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || '';
|
package/lib/chat-standalone.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
34
34
|
import {
|
|
35
35
|
createSession, getSession, listSessions, updateSession, deleteSession, listMessages,
|
|
36
|
-
clearSessionMessages, ensureMainSession, forkSession,
|
|
36
|
+
clearSessionMessages, ensureMainSession, forkSession, appendMessage,
|
|
37
37
|
} from './chat/session-store';
|
|
38
38
|
import { runTurn, type AgentEvent } from './chat/agent-loop';
|
|
39
39
|
import { bridgePush } from './chat/bridge-client';
|
|
@@ -182,6 +182,28 @@ async function handleMessagePost(req: IncomingMessage, res: ServerResponse, id:
|
|
|
182
182
|
sendJson(res, 202, { accepted: true, topic: `chat:${id}` });
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Inject a pre-formed message into a session WITHOUT running a turn —
|
|
187
|
+
* used by schedule actions (and similar server-side surfaces) to push
|
|
188
|
+
* a notification into an open chat. Going via this endpoint (instead
|
|
189
|
+
* of calling appendMessage in the caller's process) is what gives the
|
|
190
|
+
* UI a real-time `message_saved` SSE push; otherwise the message lands
|
|
191
|
+
* in SQLite but the open tab only sees it after a reload.
|
|
192
|
+
*/
|
|
193
|
+
async function handleInjectMessage(req: IncomingMessage, res: ServerResponse, id: string): Promise<void> {
|
|
194
|
+
const session = getSession(id);
|
|
195
|
+
if (!session) return sendJson(res, 404, { error: 'session not found' });
|
|
196
|
+
const body = await readJson(req);
|
|
197
|
+
const role: 'user' | 'assistant' = body?.role === 'user' ? 'user' : 'assistant';
|
|
198
|
+
const blocks = Array.isArray(body?.blocks) ? body.blocks : null;
|
|
199
|
+
if (!blocks || blocks.length === 0) return sendJson(res, 400, { error: 'blocks[] required' });
|
|
200
|
+
const saved = appendMessage({ session_id: id, role, blocks });
|
|
201
|
+
// Payload shape must match what agent-loop emits — extension's
|
|
202
|
+
// messageFromServer(event.data) reads .id/.role/.blocks/.ts directly.
|
|
203
|
+
fanoutEvent(id, { type: 'message_saved', message_id: saved.id, data: saved });
|
|
204
|
+
sendJson(res, 200, { ok: true, message_id: saved.id });
|
|
205
|
+
}
|
|
206
|
+
|
|
185
207
|
function handleEventsSse(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
|
186
208
|
// Verify the session exists before opening the stream.
|
|
187
209
|
const session = getSession(id);
|
|
@@ -244,6 +266,9 @@ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
|
244
266
|
if (messages && m === 'POST') return handleMessagePost(req, res, messages[1]!);
|
|
245
267
|
if (messages && m === 'DELETE') return handleSessionClearMessages(req, res, messages[1]!);
|
|
246
268
|
|
|
269
|
+
const inject = /^\/api\/sessions\/([^/]+)\/inject$/.exec(url.pathname);
|
|
270
|
+
if (inject && m === 'POST') return handleInjectMessage(req, res, inject[1]!);
|
|
271
|
+
|
|
247
272
|
const fork = /^\/api\/sessions\/([^/]+)\/fork$/.exec(url.pathname);
|
|
248
273
|
if (fork && m === 'POST') return handleSessionFork(req, res, fork[1]!);
|
|
249
274
|
|
package/lib/claude-process.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Runs on your Claude Code subscription, not API key.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { spawn, type ChildProcess } from 'node:child_process';
|
|
8
|
+
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
9
9
|
import { loadSettings } from './settings';
|
|
10
10
|
|
|
11
11
|
export interface ClaudeMessage {
|
|
@@ -93,13 +93,16 @@ export function sendToClaudeSession(
|
|
|
93
93
|
const env = { ...process.env };
|
|
94
94
|
delete env.CLAUDECODE;
|
|
95
95
|
|
|
96
|
-
// Resolve full path so spawn works without shell for PATH lookup
|
|
96
|
+
// Resolve full path so spawn works without shell for PATH lookup.
|
|
97
|
+
// Was a lazy require() — fired ReferenceError on Claude task spawn
|
|
98
|
+
// under ESM concurrent loads. Now top-level imported.
|
|
97
99
|
let resolvedClaude = claudePath;
|
|
98
100
|
if (!claudePath.startsWith('/')) {
|
|
99
101
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
resolvedClaude = execSync(`which ${claudePath}`, { encoding: 'utf-8', env }).trim() || claudePath;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.warn(`[claude] which ${claudePath} failed: ${(e as Error).message}`);
|
|
105
|
+
}
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
const child = spawn(resolvedClaude, args, {
|
package/lib/connectors/sync.ts
CHANGED
|
@@ -139,7 +139,9 @@ export interface SyncResult {
|
|
|
139
139
|
* cache the registry row — the manifest is pulled on demand at install
|
|
140
140
|
* time (see installFromRegistry).
|
|
141
141
|
*/
|
|
142
|
-
export async function syncRegistry(
|
|
142
|
+
export async function syncRegistry(
|
|
143
|
+
opts: { refreshInstalled?: boolean; force?: boolean } = {},
|
|
144
|
+
): Promise<SyncResult> {
|
|
143
145
|
const base = baseUrl();
|
|
144
146
|
console.log(`[connectors] Syncing registry from ${base}`);
|
|
145
147
|
let registry: RegistryFile;
|
|
@@ -171,7 +173,11 @@ export async function syncRegistry(opts: { refreshInstalled?: boolean } = {}): P
|
|
|
171
173
|
const local = getConnector(e.id);
|
|
172
174
|
const isConfigOnly = configOnly.has(e.id);
|
|
173
175
|
if (!local && !isConfigOnly) continue;
|
|
174
|
-
|
|
176
|
+
// Normally skip when local matches registry — saves bandwidth.
|
|
177
|
+
// `force` overrides so the user can pull a fresh manifest even
|
|
178
|
+
// when the version string hasn't changed (e.g. param-description
|
|
179
|
+
// tweaks shipped under the same patch version).
|
|
180
|
+
if (!opts.force && local && local.version === e.version) continue;
|
|
175
181
|
try {
|
|
176
182
|
const yamlText = await fetchManifest(e.id, e.manifest);
|
|
177
183
|
installConnector(e.id, yamlText);
|
package/lib/crypto.ts
CHANGED
|
@@ -63,5 +63,5 @@ export function hashSecret(value: string): string {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Secret field names in settings */
|
|
66
|
-
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey'] as const;
|
|
66
|
+
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword', 'temperKey', 'smtpPassword'] as const;
|
|
67
67
|
export type SecretField = typeof SECRET_FIELDS[number];
|
package/lib/dirs.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
-
import { existsSync, mkdirSync, renameSync, copyFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, mkdirSync, renameSync, copyFileSync, readFileSync } from 'node:fs';
|
|
13
13
|
|
|
14
14
|
/** Shared config directory — only binaries, fixed at ~/.forge/ */
|
|
15
15
|
export function getConfigDir(): string {
|
|
@@ -85,15 +85,30 @@ export function migrateDataDir(): void {
|
|
|
85
85
|
console.log('[forge] Migration complete. Old files kept as backup.');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** Claude Code home directory — skills, commands, sessions
|
|
88
|
+
/** Claude Code home directory — skills, commands, sessions.
|
|
89
|
+
*
|
|
90
|
+
* Circular-dep note: dirs ↔ settings (settings.ts imports getDataDir).
|
|
91
|
+
* Used to do `require('./settings')` here as a lazy hack — but that
|
|
92
|
+
* fires ReferenceError under ESM concurrent loads, and dirs is on
|
|
93
|
+
* the hot path for every task / connector / pipeline node.
|
|
94
|
+
*
|
|
95
|
+
* Cut the cycle by reading the YAML directly here. No parser needed:
|
|
96
|
+
* we only need one field (`claudeHome`), which is plain "key: value".
|
|
97
|
+
* Falls back to ~/.claude if anything goes sideways.
|
|
98
|
+
*/
|
|
89
99
|
export function getClaudeDir(): string {
|
|
90
|
-
// Env var takes precedence
|
|
91
100
|
if (process.env.CLAUDE_HOME) return process.env.CLAUDE_HOME;
|
|
92
|
-
// Try to read from settings (lazy require to avoid circular dependency)
|
|
93
101
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
const settingsFile = join(getDataDir(), 'settings.yaml');
|
|
103
|
+
if (existsSync(settingsFile)) {
|
|
104
|
+
const raw = readFileSync(settingsFile, 'utf-8');
|
|
105
|
+
// Match a top-level `claudeHome: <value>` line. Tolerates quoted /
|
|
106
|
+
// unquoted strings and stray whitespace. Not a full YAML parser
|
|
107
|
+
// because we explicitly want to avoid loading the YAML module
|
|
108
|
+
// (and settings.ts) from this hot path.
|
|
109
|
+
const m = raw.match(/^\s*claudeHome:\s*["']?(.+?)["']?\s*$/m);
|
|
110
|
+
if (m?.[1]) return m[1].replace(/^~/, homedir());
|
|
111
|
+
}
|
|
97
112
|
} catch {}
|
|
98
113
|
return join(homedir(), '.claude');
|
|
99
114
|
}
|