amalgm 0.0.0 → 0.0.32
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/README.md +41 -1
- package/bin/amalgm.js +8 -2
- package/lib/auth-store.js +223 -0
- package/lib/cli.js +1132 -0
- package/lib/paths.js +30 -0
- package/lib/supervisor.js +476 -0
- package/lib/tunnel-chat.js +328 -0
- package/lib/tunnel-events.js +499 -0
- package/package.json +30 -3
- package/runtime/README.md +4 -0
- package/runtime/lib/chatInput.js +315 -0
- package/runtime/lib/harnesses.js +988 -0
- package/runtime/lib/local/amalgmStore.js +136 -0
- package/runtime/lib/local/credentialResolver.js +425 -0
- package/runtime/lib/mcpApps/registry.js +619 -0
- package/runtime/package.json +5 -0
- package/runtime/scripts/amalgm-mcp/agents/rest.js +144 -0
- package/runtime/scripts/amalgm-mcp/agents/store.js +684 -0
- package/runtime/scripts/amalgm-mcp/agents/talk.js +1162 -0
- package/runtime/scripts/amalgm-mcp/agents/tools.js +221 -0
- package/runtime/scripts/amalgm-mcp/artifacts/advertise.js +132 -0
- package/runtime/scripts/amalgm-mcp/artifacts/rest.js +103 -0
- package/runtime/scripts/amalgm-mcp/artifacts/store.js +157 -0
- package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +402 -0
- package/runtime/scripts/amalgm-mcp/artifacts/tools.js +176 -0
- package/runtime/scripts/amalgm-mcp/browser/page.js +637 -0
- package/runtime/scripts/amalgm-mcp/browser/tools.js +688 -0
- package/runtime/scripts/amalgm-mcp/config.js +140 -0
- package/runtime/scripts/amalgm-mcp/credentials/rest.js +45 -0
- package/runtime/scripts/amalgm-mcp/deps.js +40 -0
- package/runtime/scripts/amalgm-mcp/email/inbound.js +215 -0
- package/runtime/scripts/amalgm-mcp/events/executor.js +179 -0
- package/runtime/scripts/amalgm-mcp/events/ingress.js +113 -0
- package/runtime/scripts/amalgm-mcp/events/matcher.js +125 -0
- package/runtime/scripts/amalgm-mcp/events/rest.js +200 -0
- package/runtime/scripts/amalgm-mcp/events/ring-buffer.js +19 -0
- package/runtime/scripts/amalgm-mcp/events/store.js +113 -0
- package/runtime/scripts/amalgm-mcp/events/tools.js +306 -0
- package/runtime/scripts/amalgm-mcp/events/webhook-url.js +28 -0
- package/runtime/scripts/amalgm-mcp/fs/rest.js +293 -0
- package/runtime/scripts/amalgm-mcp/index.js +100 -0
- package/runtime/scripts/amalgm-mcp/lib/chat-runner.js +167 -0
- package/runtime/scripts/amalgm-mcp/lib/email-md.js +288 -0
- package/runtime/scripts/amalgm-mcp/lib/mcp-resolver.js +63 -0
- package/runtime/scripts/amalgm-mcp/lib/prefs.js +441 -0
- package/runtime/scripts/amalgm-mcp/lib/storage.js +92 -0
- package/runtime/scripts/amalgm-mcp/lib/supabase.js +118 -0
- package/runtime/scripts/amalgm-mcp/lib/tool-result.js +177 -0
- package/runtime/scripts/amalgm-mcp/local/rest.js +87 -0
- package/runtime/scripts/amalgm-mcp/mcp-connections/rest.js +234 -0
- package/runtime/scripts/amalgm-mcp/notify/index.js +107 -0
- package/runtime/scripts/amalgm-mcp/server/core-tools.js +20 -0
- package/runtime/scripts/amalgm-mcp/server/http.js +377 -0
- package/runtime/scripts/amalgm-mcp/server/mcp.js +127 -0
- package/runtime/scripts/amalgm-mcp/slack/inbound.js +200 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +194 -0
- package/runtime/scripts/amalgm-mcp/state/events.js +113 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +64 -0
- package/runtime/scripts/amalgm-mcp/state/snapshot.js +76 -0
- package/runtime/scripts/amalgm-mcp/tasks/executor.js +225 -0
- package/runtime/scripts/amalgm-mcp/tasks/rest.js +110 -0
- package/runtime/scripts/amalgm-mcp/tasks/schedule-normalization.js +85 -0
- package/runtime/scripts/amalgm-mcp/tasks/scheduler.js +105 -0
- package/runtime/scripts/amalgm-mcp/tasks/store.js +154 -0
- package/runtime/scripts/amalgm-mcp/tasks/tools.js +391 -0
- package/runtime/scripts/amalgm-mcp/toolbox/rest.js +75 -0
- package/runtime/scripts/amalgm-mcp/toolbox/runner.js +257 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +933 -0
- package/runtime/scripts/amalgm-mcp/toolbox/tools.js +269 -0
- package/runtime/scripts/amalgm-mcp/user-api-keys/rest.js +105 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +497 -0
- package/runtime/scripts/chat-core/adapters/claude.js +165 -0
- package/runtime/scripts/chat-core/adapters/codex.js +313 -0
- package/runtime/scripts/chat-core/adapters/opencode.js +412 -0
- package/runtime/scripts/chat-core/auth.js +177 -0
- package/runtime/scripts/chat-core/contract.js +328 -0
- package/runtime/scripts/chat-core/credentials/store.js +212 -0
- package/runtime/scripts/chat-core/egress.js +87 -0
- package/runtime/scripts/chat-core/engine.js +253 -0
- package/runtime/scripts/chat-core/event-schema.js +231 -0
- package/runtime/scripts/chat-core/events.js +190 -0
- package/runtime/scripts/chat-core/index.js +11 -0
- package/runtime/scripts/chat-core/input.js +50 -0
- package/runtime/scripts/chat-core/normalizers/claude.js +450 -0
- package/runtime/scripts/chat-core/normalizers/codex.js +380 -0
- package/runtime/scripts/chat-core/normalizers/normalizer_spec.md +259 -0
- package/runtime/scripts/chat-core/normalizers/opencode.js +552 -0
- package/runtime/scripts/chat-core/normalizers/tool_contract.md +123 -0
- package/runtime/scripts/chat-core/normalizers/usage_contract.md +304 -0
- package/runtime/scripts/chat-core/parts.js +253 -0
- package/runtime/scripts/chat-core/recorder.js +65 -0
- package/runtime/scripts/chat-core/runtime.js +86 -0
- package/runtime/scripts/chat-core/server.js +163 -0
- package/runtime/scripts/chat-core/sse.js +196 -0
- package/runtime/scripts/chat-core/stores.js +100 -0
- package/runtime/scripts/chat-core/tool-display.js +149 -0
- package/runtime/scripts/chat-core/tool-shape.js +143 -0
- package/runtime/scripts/chat-core/tooling/mcp-bundle.js +161 -0
- package/runtime/scripts/chat-core/tooling/mcp-relay.js +97 -0
- package/runtime/scripts/chat-core/tooling/native-binaries.js +608 -0
- package/runtime/scripts/chat-core/tooling/system-prompt.js +20 -0
- package/runtime/scripts/chat-core/usage.js +343 -0
- package/runtime/scripts/chat-server/config.js +110 -0
- package/runtime/scripts/chat-server/db.js +529 -0
- package/runtime/scripts/chat-server/index.js +33 -0
- package/runtime/scripts/chat-server/model-catalog.js +327 -0
- package/runtime/scripts/chat-server.js +75 -0
- package/runtime/scripts/credential-adapter.js +131 -0
- package/runtime/scripts/fs-watcher.js +888 -0
- package/runtime/scripts/local-gateway.js +854 -0
- package/runtime/scripts/platform-context.txt +246 -0
- package/runtime/scripts/port-monitor.js +175 -0
- package/runtime/scripts/proxy-token-store.js +162 -0
- package/runtime/scripts/runtime-auth.js +163 -0
- package/runtime/scripts/test-claude-code-models.js +87 -0
- package/runtime/tsconfig.json +15 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task executor — runs a task through the local chat-server.
|
|
3
|
+
*
|
|
4
|
+
* On each run:
|
|
5
|
+
* 1. Allocate a fresh runId + codeSessionId + message IDs.
|
|
6
|
+
* 2. (Optional) Create a Supabase session row so the run shows up in the UI.
|
|
7
|
+
* 3. POST to chat-server /chat and consume the SSE stream.
|
|
8
|
+
* 4. Append a run-log JSONL entry and update task metadata.
|
|
9
|
+
*
|
|
10
|
+
* Held state (runningTasks) is purely in-process — it tracks abort controllers
|
|
11
|
+
* so MCP's scheduled_tasks_cancel can abort a live run.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
const { AMALGM_COMPUTER_ID, AMALGM_USER_ID, DEFAULT_CWD } = require('../config');
|
|
18
|
+
const { runThroughChatServer } = require('../lib/chat-runner');
|
|
19
|
+
const { hasSupabase, supabaseInsert, supabasePatch } = require('../lib/supabase');
|
|
20
|
+
const { appendRunLog, updateTaskMeta } = require('./store');
|
|
21
|
+
const {
|
|
22
|
+
chatInputToLegacyFields,
|
|
23
|
+
normalizeChatInput,
|
|
24
|
+
} = require('../../../lib/chatInput');
|
|
25
|
+
const { DEFAULT_SELECTED_MODELS, resolveModelSelection } = require('../lib/prefs');
|
|
26
|
+
const credentialAdapter = require('../../credential-adapter');
|
|
27
|
+
const { buildLocalMcpServerConfigs } = require('../lib/mcp-resolver');
|
|
28
|
+
|
|
29
|
+
const runningTasks = new Map(); // taskId → { runId, abortController, codeSessionId }
|
|
30
|
+
|
|
31
|
+
function harnessToAgent(harness) {
|
|
32
|
+
const map = { claude_code: 'claude', codex: 'codex', opencode: 'opencode', pi: 'pi', amp: 'amp', cursor: 'cursor' };
|
|
33
|
+
return map[harness] || 'claude';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isRunning(taskId) {
|
|
37
|
+
return runningTasks.has(taskId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getRunning(taskId) {
|
|
41
|
+
return runningTasks.get(taskId) || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function abortRunning(taskId) {
|
|
45
|
+
const running = runningTasks.get(taskId);
|
|
46
|
+
if (running) {
|
|
47
|
+
running.abortController.abort();
|
|
48
|
+
return running;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function disableTerminalScheduleIfNeeded(task) {
|
|
54
|
+
if (task.schedule.kind === 'once') {
|
|
55
|
+
updateTaskMeta(task.id, { enabled: false });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (task.endsAt && new Date(task.endsAt) <= new Date()) {
|
|
59
|
+
updateTaskMeta(task.id, { enabled: false });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveTaskRequest(task) {
|
|
64
|
+
const harness =
|
|
65
|
+
(task.chatInput && task.chatInput.agent && task.chatInput.agent.harness)
|
|
66
|
+
|| task.harness
|
|
67
|
+
|| 'claude_code';
|
|
68
|
+
const model =
|
|
69
|
+
(task.chatInput && task.chatInput.agent && task.chatInput.agent.model)
|
|
70
|
+
|| task.model
|
|
71
|
+
|| DEFAULT_SELECTED_MODELS[harness]
|
|
72
|
+
|| null;
|
|
73
|
+
const authMethod =
|
|
74
|
+
(task.chatInput && task.chatInput.agent && task.chatInput.agent.authMethod)
|
|
75
|
+
|| task.authMethod
|
|
76
|
+
|| (credentialAdapter.VALID_HARNESS_IDS.includes(harness)
|
|
77
|
+
? credentialAdapter.getPersistedAuthMode(harness)
|
|
78
|
+
: 'amalgm');
|
|
79
|
+
const chatInput = normalizeChatInput(task.chatInput, {
|
|
80
|
+
prompt: task.prompt,
|
|
81
|
+
harness,
|
|
82
|
+
modelId: model,
|
|
83
|
+
authMethod,
|
|
84
|
+
cwd: task.projectPath,
|
|
85
|
+
projectPath: task.projectPath,
|
|
86
|
+
});
|
|
87
|
+
const legacy = chatInputToLegacyFields(chatInput, {
|
|
88
|
+
prompt: task.prompt,
|
|
89
|
+
harness,
|
|
90
|
+
modelId: model,
|
|
91
|
+
authMethod,
|
|
92
|
+
cwd: task.projectPath,
|
|
93
|
+
projectPath: task.projectPath,
|
|
94
|
+
});
|
|
95
|
+
const modelSelection = resolveModelSelection(harness, legacy.modelId || model);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
harness,
|
|
99
|
+
chatInput,
|
|
100
|
+
legacy,
|
|
101
|
+
modelSelection,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function executeTask(task) {
|
|
106
|
+
const runId = crypto.randomUUID();
|
|
107
|
+
const startedAt = new Date().toISOString();
|
|
108
|
+
const { harness, chatInput, legacy, modelSelection } = resolveTaskRequest(task);
|
|
109
|
+
const mcpServers = await buildLocalMcpServerConfigs(legacy.mcpAppIds || []);
|
|
110
|
+
|
|
111
|
+
console.log(`[AmalgmMCP:Exec] Starting task ${task.id} (${task.name}), run ${runId}`);
|
|
112
|
+
appendRunLog(task.id, { runId, startedAt, status: 'running', prompt: task.prompt });
|
|
113
|
+
updateTaskMeta(task.id, { lastRunAt: startedAt, lastStatus: 'running' });
|
|
114
|
+
|
|
115
|
+
const codeSessionId = crypto.randomUUID();
|
|
116
|
+
const userMessageId = crypto.randomUUID();
|
|
117
|
+
const assistantMessageId = crypto.randomUUID();
|
|
118
|
+
|
|
119
|
+
if (hasSupabase()) {
|
|
120
|
+
try {
|
|
121
|
+
await supabaseInsert('sessions', {
|
|
122
|
+
id: codeSessionId,
|
|
123
|
+
user_id: AMALGM_USER_ID,
|
|
124
|
+
computer_id: AMALGM_COMPUTER_ID,
|
|
125
|
+
container_id: os.hostname() || 'task-execution',
|
|
126
|
+
harness,
|
|
127
|
+
title: 'New Chat',
|
|
128
|
+
origin: 'task',
|
|
129
|
+
origin_id: task.id,
|
|
130
|
+
project_path: legacy.cwd || task.projectPath || null,
|
|
131
|
+
status: 'in-progress',
|
|
132
|
+
metadata: { taskRunId: runId, automated: true, originName: task.name },
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('[AmalgmMCP:Exec] DB session creation failed:', err.message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
appendRunLog(task.id, { runId, sessionId: codeSessionId, type: 'session_created' });
|
|
140
|
+
|
|
141
|
+
const abortController = new AbortController();
|
|
142
|
+
runningTasks.set(task.id, { runId, abortController, codeSessionId });
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const { outputText, stopReason } = await runThroughChatServer(
|
|
146
|
+
{
|
|
147
|
+
chatInput,
|
|
148
|
+
prompt: legacy.prompt,
|
|
149
|
+
userId: AMALGM_USER_ID,
|
|
150
|
+
codeSessionId,
|
|
151
|
+
computerId: AMALGM_COMPUTER_ID,
|
|
152
|
+
assistantMessageId,
|
|
153
|
+
userMessageId,
|
|
154
|
+
agentId: harnessToAgent(harness),
|
|
155
|
+
userParts: legacy.userParts,
|
|
156
|
+
modelId: modelSelection.modelId || legacy.modelId || null,
|
|
157
|
+
cliModel: modelSelection.cliModel || null,
|
|
158
|
+
...(modelSelection.reasoningEffort ? { reasoningEffort: modelSelection.reasoningEffort } : {}),
|
|
159
|
+
cwd: legacy.cwd || DEFAULT_CWD,
|
|
160
|
+
authMethod: legacy.authMethod || null,
|
|
161
|
+
mcpServers,
|
|
162
|
+
taskId: task.id,
|
|
163
|
+
automated: true,
|
|
164
|
+
},
|
|
165
|
+
abortController.signal,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const finishedAt = new Date().toISOString();
|
|
169
|
+
const durationMs = Date.now() - new Date(startedAt).getTime();
|
|
170
|
+
const status = 'completed';
|
|
171
|
+
|
|
172
|
+
appendRunLog(task.id, {
|
|
173
|
+
runId,
|
|
174
|
+
finishedAt,
|
|
175
|
+
status,
|
|
176
|
+
durationMs,
|
|
177
|
+
sessionId: codeSessionId,
|
|
178
|
+
stopReason: stopReason || null,
|
|
179
|
+
outputLength: outputText.length,
|
|
180
|
+
});
|
|
181
|
+
updateTaskMeta(task.id, { lastStatus: status });
|
|
182
|
+
disableTerminalScheduleIfNeeded(task);
|
|
183
|
+
|
|
184
|
+
console.log(
|
|
185
|
+
`[AmalgmMCP:Exec] Task ${task.id} ${status} in ${durationMs}ms (session: ${codeSessionId})`,
|
|
186
|
+
);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err.name === 'AbortError') {
|
|
189
|
+
appendRunLog(task.id, {
|
|
190
|
+
runId,
|
|
191
|
+
finishedAt: new Date().toISOString(),
|
|
192
|
+
status: 'cancelled',
|
|
193
|
+
});
|
|
194
|
+
updateTaskMeta(task.id, { lastStatus: 'cancelled' });
|
|
195
|
+
disableTerminalScheduleIfNeeded(task);
|
|
196
|
+
} else {
|
|
197
|
+
appendRunLog(task.id, {
|
|
198
|
+
runId,
|
|
199
|
+
finishedAt: new Date().toISOString(),
|
|
200
|
+
status: 'failed',
|
|
201
|
+
error: err.message,
|
|
202
|
+
});
|
|
203
|
+
updateTaskMeta(task.id, { lastStatus: 'failed' });
|
|
204
|
+
disableTerminalScheduleIfNeeded(task);
|
|
205
|
+
console.error(`[AmalgmMCP:Exec] Task ${task.id} failed:`, err.message);
|
|
206
|
+
}
|
|
207
|
+
if (hasSupabase()) {
|
|
208
|
+
supabasePatch('sessions', 'id', codeSessionId, {
|
|
209
|
+
last_message_at: new Date().toISOString(),
|
|
210
|
+
status: 'error',
|
|
211
|
+
new_messages: true,
|
|
212
|
+
metadata: {
|
|
213
|
+
taskRunId: runId,
|
|
214
|
+
automated: true,
|
|
215
|
+
originName: task.name,
|
|
216
|
+
error: err.message || 'cancelled',
|
|
217
|
+
},
|
|
218
|
+
}).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
runningTasks.delete(task.id);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { executeTask, isRunning, getRunning, abortRunning };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /tasks/* REST routes for the Next.js API to call.
|
|
3
|
+
* Not part of the MCP tool surface — used by the internal UI.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { loadTasks } = require('./store');
|
|
7
|
+
const taskTools = require('./tools');
|
|
8
|
+
|
|
9
|
+
const taskToolHandlers = new Map(taskTools.map((tool) => [tool.name, tool.handler]));
|
|
10
|
+
|
|
11
|
+
function extractText(result) {
|
|
12
|
+
const entry = Array.isArray(result?.content)
|
|
13
|
+
? result.content.find((item) => item && item.type === 'text' && typeof item.text === 'string')
|
|
14
|
+
: null;
|
|
15
|
+
return entry?.text || '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseTaskFromText(text) {
|
|
19
|
+
const jsonStart = typeof text === 'string' ? text.indexOf('{') : -1;
|
|
20
|
+
if (jsonStart < 0) return null;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(text.slice(jsonStart));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function callTaskTool(name, args) {
|
|
30
|
+
const handler = taskToolHandlers.get(name);
|
|
31
|
+
if (!handler) {
|
|
32
|
+
throw new Error(`Unknown task tool: ${name}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = await handler(args);
|
|
36
|
+
const text = extractText(result);
|
|
37
|
+
return {
|
|
38
|
+
result,
|
|
39
|
+
text,
|
|
40
|
+
isError: Boolean(result?.isError),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function handleList(sendJson) {
|
|
45
|
+
const data = loadTasks();
|
|
46
|
+
sendJson(200, { tasks: data?.tasks || [] });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function handleCreate(body, sendJson) {
|
|
50
|
+
const response = await callTaskTool('scheduled_tasks_create', body);
|
|
51
|
+
if (response.isError) {
|
|
52
|
+
return sendJson(400, { error: response.text || 'Failed to create task' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
sendJson(200, {
|
|
56
|
+
ok: true,
|
|
57
|
+
task: parseTaskFromText(response.text),
|
|
58
|
+
output: response.result,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleUpdate(body, sendJson) {
|
|
63
|
+
const response = await callTaskTool('scheduled_tasks_update', body);
|
|
64
|
+
if (response.isError) {
|
|
65
|
+
const status = response.text.startsWith('Task not found:') ? 404 : 400;
|
|
66
|
+
return sendJson(status, { error: response.text || 'Failed to update task' });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
sendJson(200, {
|
|
70
|
+
ok: true,
|
|
71
|
+
task: parseTaskFromText(response.text),
|
|
72
|
+
output: response.result,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function handleDelete(body, sendJson) {
|
|
77
|
+
const response = await callTaskTool('scheduled_tasks_delete', body);
|
|
78
|
+
if (response.isError) {
|
|
79
|
+
const status = response.text.startsWith('Task not found:') ? 404 : 400;
|
|
80
|
+
return sendJson(status, { error: response.text || 'Failed to delete task' });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
sendJson(200, {
|
|
84
|
+
ok: true,
|
|
85
|
+
message: response.text,
|
|
86
|
+
output: response.result,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function handleRunNow(body, sendJson) {
|
|
91
|
+
const response = await callTaskTool('scheduled_tasks_run_now', body);
|
|
92
|
+
if (response.isError) {
|
|
93
|
+
const status = response.text.startsWith('Task not found:') ? 404 : 400;
|
|
94
|
+
return sendJson(status, { error: response.text || 'Failed to run task' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sendJson(200, {
|
|
98
|
+
ok: true,
|
|
99
|
+
message: response.text,
|
|
100
|
+
output: response.result,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
handleList,
|
|
106
|
+
handleCreate,
|
|
107
|
+
handleUpdate,
|
|
108
|
+
handleDelete,
|
|
109
|
+
handleRunNow,
|
|
110
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task schedule normalization helpers.
|
|
3
|
+
*
|
|
4
|
+
* - One-shot tasks are stored as UTC timestamps.
|
|
5
|
+
* - Recurring cron tasks always carry an explicit IANA timezone.
|
|
6
|
+
* - Interval tasks are stored as validated millisecond values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function getDefaultScheduleTimeZone() {
|
|
10
|
+
try {
|
|
11
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
12
|
+
} catch {
|
|
13
|
+
return 'UTC';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeOnceAt(at) {
|
|
18
|
+
if (typeof at !== 'string' || !at.trim()) return null;
|
|
19
|
+
const parsed = new Date(at.trim());
|
|
20
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
21
|
+
return parsed.toISOString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeTaskSchedule(schedule, { fallbackTimeZone = getDefaultScheduleTimeZone() } = {}) {
|
|
25
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
26
|
+
return { schedule: null, error: 'schedule is required' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (schedule.kind === 'cron') {
|
|
30
|
+
if (!schedule.expr || typeof schedule.expr !== 'string') {
|
|
31
|
+
return { schedule: null, error: 'cron schedule requires "expr" field' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
schedule: {
|
|
36
|
+
kind: 'cron',
|
|
37
|
+
expr: schedule.expr.trim(),
|
|
38
|
+
tz: typeof schedule.tz === 'string' && schedule.tz.trim()
|
|
39
|
+
? schedule.tz.trim()
|
|
40
|
+
: fallbackTimeZone,
|
|
41
|
+
},
|
|
42
|
+
error: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (schedule.kind === 'once') {
|
|
47
|
+
const at = normalizeOnceAt(schedule.at);
|
|
48
|
+
if (!at) {
|
|
49
|
+
return { schedule: null, error: 'once schedule requires a valid ISO datetime in "at"' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
schedule: {
|
|
54
|
+
kind: 'once',
|
|
55
|
+
at,
|
|
56
|
+
},
|
|
57
|
+
error: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (schedule.kind === 'interval') {
|
|
62
|
+
const ms = Number(schedule.ms);
|
|
63
|
+
if (!Number.isFinite(ms) || ms < 60_000) {
|
|
64
|
+
return { schedule: null, error: 'interval requires "ms" field (min 60000)' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
schedule: {
|
|
69
|
+
kind: 'interval',
|
|
70
|
+
ms,
|
|
71
|
+
},
|
|
72
|
+
error: null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
schedule: null,
|
|
78
|
+
error: `Unsupported schedule kind: ${String(schedule.kind)}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
getDefaultScheduleTimeZone,
|
|
84
|
+
normalizeTaskSchedule,
|
|
85
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task scheduler — 30s poll loop.
|
|
3
|
+
*
|
|
4
|
+
* Checks every enabled task each tick; if it's due and not already running,
|
|
5
|
+
* kicks off an execution via tasks/executor.js. Scheduler is stateless;
|
|
6
|
+
* currently-running tasks live in executor.js's `runningTasks` map.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { SCHEDULER_INTERVAL_MS } = require('../config');
|
|
10
|
+
const { loadTasks } = require('./store');
|
|
11
|
+
const { executeTask, isRunning } = require('./executor');
|
|
12
|
+
const { loadCronParser } = require('../deps');
|
|
13
|
+
|
|
14
|
+
let schedulerTimer = null;
|
|
15
|
+
let _cronParser = null;
|
|
16
|
+
|
|
17
|
+
function cron() {
|
|
18
|
+
if (!_cronParser) _cronParser = loadCronParser();
|
|
19
|
+
return _cronParser;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a cron expression + timezone. Returns null on success, error
|
|
24
|
+
* message on failure.
|
|
25
|
+
*/
|
|
26
|
+
function validateCronExpr(expr, tz) {
|
|
27
|
+
try {
|
|
28
|
+
cron().CronExpressionParser.parse(expr, { tz: tz || 'UTC' });
|
|
29
|
+
return null;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return e.message;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validDate(value) {
|
|
36
|
+
if (!value) return null;
|
|
37
|
+
const date = new Date(value);
|
|
38
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is the given task due to run right now?
|
|
43
|
+
*/
|
|
44
|
+
function isTaskDue(task, now) {
|
|
45
|
+
if (!task.enabled) return false;
|
|
46
|
+
if (task.endsAt && new Date(task.endsAt) < now) return false;
|
|
47
|
+
|
|
48
|
+
const schedule = task.schedule;
|
|
49
|
+
if (schedule.kind === 'once') {
|
|
50
|
+
const runAt = new Date(schedule.at);
|
|
51
|
+
if (Number.isNaN(runAt.getTime())) return false;
|
|
52
|
+
return runAt <= now && (!task.lastRunAt || new Date(task.lastRunAt) < runAt);
|
|
53
|
+
}
|
|
54
|
+
if (schedule.kind === 'cron') {
|
|
55
|
+
try {
|
|
56
|
+
const interval = cron().CronExpressionParser.parse(schedule.expr, {
|
|
57
|
+
currentDate: now,
|
|
58
|
+
tz: schedule.tz || 'UTC',
|
|
59
|
+
});
|
|
60
|
+
const prev = interval.prev().toDate();
|
|
61
|
+
const createdAt = validDate(task.createdAt);
|
|
62
|
+
const lastRunAt = validDate(task.lastRunAt);
|
|
63
|
+
|
|
64
|
+
if (createdAt && prev <= createdAt) return false;
|
|
65
|
+
return !lastRunAt || lastRunAt < prev;
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (schedule.kind === 'interval') {
|
|
71
|
+
if (!task.lastRunAt) return true;
|
|
72
|
+
return now - new Date(task.lastRunAt) >= schedule.ms;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function startScheduler() {
|
|
78
|
+
console.log(`[AmalgmMCP:Scheduler] Starting (interval: ${SCHEDULER_INTERVAL_MS}ms)`);
|
|
79
|
+
schedulerTimer = setInterval(() => {
|
|
80
|
+
try {
|
|
81
|
+
const data = loadTasks();
|
|
82
|
+
const now = new Date();
|
|
83
|
+
for (const task of data.tasks) {
|
|
84
|
+
if (!task.enabled) continue;
|
|
85
|
+
if (isRunning(task.id)) continue;
|
|
86
|
+
if (isTaskDue(task, now)) {
|
|
87
|
+
executeTask(task).catch((err) => {
|
|
88
|
+
console.error(`[AmalgmMCP:Scheduler] Task ${task.id} error:`, err.message);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error('[AmalgmMCP:Scheduler] Poll error:', err.message);
|
|
94
|
+
}
|
|
95
|
+
}, SCHEDULER_INTERVAL_MS);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stopScheduler() {
|
|
99
|
+
if (schedulerTimer) {
|
|
100
|
+
clearInterval(schedulerTimer);
|
|
101
|
+
schedulerTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { validateCronExpr, isTaskDue, startScheduler, stopScheduler };
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tasks storage — ~/.amalgm/tasks.json + ~/.amalgm/task-runs/{id}.jsonl
|
|
3
|
+
*
|
|
4
|
+
* Single writer, atomic rename. No dual-write to /persist (local-first).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { TASKS_FILE, TASK_RUNS_DIR, STORAGE_DIR } = require('../config');
|
|
9
|
+
const {
|
|
10
|
+
ensureDir,
|
|
11
|
+
readJson,
|
|
12
|
+
writeJsonAtomic,
|
|
13
|
+
cleanupStaleTmp,
|
|
14
|
+
appendJsonl,
|
|
15
|
+
tailJsonl,
|
|
16
|
+
} = require('../lib/storage');
|
|
17
|
+
const {
|
|
18
|
+
chatInputToLegacyFields,
|
|
19
|
+
getChatInputText,
|
|
20
|
+
normalizeChatInput,
|
|
21
|
+
} = require('../../../lib/chatInput');
|
|
22
|
+
const { normalizeTaskSchedule } = require('./schedule-normalization');
|
|
23
|
+
const { DEFAULT_SELECTED_MODELS, getSelectedModel } = require('../lib/prefs');
|
|
24
|
+
const credentialAdapter = require('../../credential-adapter');
|
|
25
|
+
const { appendStateEvent } = require('../state/events');
|
|
26
|
+
|
|
27
|
+
function normalizeStoredTask(task) {
|
|
28
|
+
const harness =
|
|
29
|
+
(task.chatInput && task.chatInput.agent && task.chatInput.agent.harness)
|
|
30
|
+
|| task.harness
|
|
31
|
+
|| 'claude_code';
|
|
32
|
+
const model =
|
|
33
|
+
(task.chatInput && task.chatInput.agent && task.chatInput.agent.model)
|
|
34
|
+
|| task.model
|
|
35
|
+
|| getSelectedModel(harness)
|
|
36
|
+
|| DEFAULT_SELECTED_MODELS[harness]
|
|
37
|
+
|| null;
|
|
38
|
+
const authMethod =
|
|
39
|
+
(task.chatInput && task.chatInput.agent && task.chatInput.agent.authMethod)
|
|
40
|
+
|| task.authMethod
|
|
41
|
+
|| (credentialAdapter.VALID_HARNESS_IDS.includes(harness)
|
|
42
|
+
? credentialAdapter.getPersistedAuthMode(harness)
|
|
43
|
+
: 'amalgm');
|
|
44
|
+
const chatInput = normalizeChatInput(task.chatInput, {
|
|
45
|
+
prompt: task.prompt,
|
|
46
|
+
harness,
|
|
47
|
+
modelId: model,
|
|
48
|
+
authMethod,
|
|
49
|
+
cwd: task.projectPath,
|
|
50
|
+
projectPath: task.projectPath,
|
|
51
|
+
});
|
|
52
|
+
const legacy = chatInputToLegacyFields(chatInput, {
|
|
53
|
+
prompt: task.prompt,
|
|
54
|
+
harness,
|
|
55
|
+
modelId: model,
|
|
56
|
+
authMethod,
|
|
57
|
+
cwd: task.projectPath,
|
|
58
|
+
projectPath: task.projectPath,
|
|
59
|
+
});
|
|
60
|
+
const normalizedScheduleResult = normalizeTaskSchedule(task.schedule);
|
|
61
|
+
const schedule = normalizedScheduleResult.error ? task.schedule : normalizedScheduleResult.schedule;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...task,
|
|
65
|
+
schedule,
|
|
66
|
+
chatInput,
|
|
67
|
+
prompt: getChatInputText(chatInput, task.prompt) || legacy.prompt,
|
|
68
|
+
harness: legacy.harness || harness,
|
|
69
|
+
model: legacy.modelId || model,
|
|
70
|
+
authMethod: legacy.authMethod || authMethod,
|
|
71
|
+
projectPath: legacy.cwd || task.projectPath || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function migrateTasksData(data) {
|
|
76
|
+
let changed = false;
|
|
77
|
+
const tasks = Array.isArray(data.tasks)
|
|
78
|
+
? data.tasks.map((task) => {
|
|
79
|
+
const normalizedTask = normalizeStoredTask(task);
|
|
80
|
+
if (JSON.stringify(normalizedTask) !== JSON.stringify(task)) {
|
|
81
|
+
changed = true;
|
|
82
|
+
}
|
|
83
|
+
return normalizedTask;
|
|
84
|
+
})
|
|
85
|
+
: [];
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
changed,
|
|
89
|
+
data: {
|
|
90
|
+
version: typeof data.version === 'number' ? data.version : 1,
|
|
91
|
+
tasks,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ensureTasksDirs() {
|
|
97
|
+
ensureDir(STORAGE_DIR);
|
|
98
|
+
ensureDir(TASK_RUNS_DIR);
|
|
99
|
+
cleanupStaleTmp(STORAGE_DIR, '.tasks.json');
|
|
100
|
+
if (!readJson(TASKS_FILE, null)) writeJsonAtomic(TASKS_FILE, { version: 1, tasks: [] });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function loadTasks() {
|
|
104
|
+
const raw = readJson(TASKS_FILE, { version: 1, tasks: [] });
|
|
105
|
+
const migrated = migrateTasksData(raw);
|
|
106
|
+
if (migrated.changed) {
|
|
107
|
+
writeJsonAtomic(TASKS_FILE, migrated.data);
|
|
108
|
+
}
|
|
109
|
+
return migrated.data;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function publishTasksChange(data, source = 'tasks') {
|
|
113
|
+
try {
|
|
114
|
+
appendStateEvent({
|
|
115
|
+
resource: 'tasks',
|
|
116
|
+
op: 'replace',
|
|
117
|
+
value: Array.isArray(data?.tasks) ? data.tasks : [],
|
|
118
|
+
source,
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.warn('[Tasks] Local Live Store publish failed:', error.message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function saveTasks(data, options = {}) {
|
|
126
|
+
writeJsonAtomic(TASKS_FILE, data);
|
|
127
|
+
publishTasksChange(data, options.source || 'tasks:save');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function updateTaskMeta(taskId, updates) {
|
|
131
|
+
const data = loadTasks();
|
|
132
|
+
const task = data.tasks.find((t) => t.id === taskId);
|
|
133
|
+
if (task) {
|
|
134
|
+
Object.assign(task, updates);
|
|
135
|
+
saveTasks(data);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function appendRunLog(taskId, entry) {
|
|
140
|
+
appendJsonl(path.join(TASK_RUNS_DIR, `${taskId}.jsonl`), entry);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readRunLog(taskId, limit = 20) {
|
|
144
|
+
return tailJsonl(path.join(TASK_RUNS_DIR, `${taskId}.jsonl`), limit);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
ensureTasksDirs,
|
|
149
|
+
loadTasks,
|
|
150
|
+
saveTasks,
|
|
151
|
+
updateTaskMeta,
|
|
152
|
+
appendRunLog,
|
|
153
|
+
readRunLog,
|
|
154
|
+
};
|