taskode 0.4.0
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 +398 -0
- package/WORKFLOW.md +53 -0
- package/bin/taskode.js +7 -0
- package/package.json +30 -0
- package/public/app.js +1110 -0
- package/public/index.html +101 -0
- package/public/styles.css +821 -0
- package/src/app-server.js +555 -0
- package/src/auth.js +13 -0
- package/src/cli.js +176 -0
- package/src/orchestrator.js +655 -0
- package/src/path-safety.js +80 -0
- package/src/policy.js +23 -0
- package/src/review.js +197 -0
- package/src/runner.js +133 -0
- package/src/server.js +168 -0
- package/src/ssh.js +82 -0
- package/src/store.js +355 -0
- package/src/tracker/github.js +143 -0
- package/src/tracker/index.js +71 -0
- package/src/tracker/linear.js +229 -0
- package/src/tracker/local.js +75 -0
- package/src/workflow-store.js +77 -0
- package/src/workflow.js +339 -0
- package/src/workspace.js +291 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { assertPathInsideRoot, assertRemotePathSafe } from './path-safety.js';
|
|
4
|
+
import { resolveTurnSandboxPolicy } from './workflow.js';
|
|
5
|
+
import { shellEscape, startSSHProcess } from './ssh.js';
|
|
6
|
+
|
|
7
|
+
const NON_INTERACTIVE_ANSWER = 'This is a non-interactive session. Operator input is unavailable.';
|
|
8
|
+
const TOOL_NAME_LINEAR_GRAPHQL = 'linear_graphql';
|
|
9
|
+
|
|
10
|
+
export async function runAppServerSession({
|
|
11
|
+
command,
|
|
12
|
+
cwd,
|
|
13
|
+
issue,
|
|
14
|
+
tracker,
|
|
15
|
+
config,
|
|
16
|
+
prompt,
|
|
17
|
+
maxTurns,
|
|
18
|
+
runId,
|
|
19
|
+
store,
|
|
20
|
+
workerHost = null
|
|
21
|
+
}) {
|
|
22
|
+
const safeCwd = workerHost ? assertRemotePathSafe(cwd) : assertPathInsideRoot(cwd, config.workspaceRoot);
|
|
23
|
+
const child = startProcess(command, safeCwd, workerHost);
|
|
24
|
+
const stream = new RpcStream({ child, runId, store, workerHost });
|
|
25
|
+
const startedAt = Date.now();
|
|
26
|
+
const telemetry = {
|
|
27
|
+
sessionId: null,
|
|
28
|
+
threadId: null,
|
|
29
|
+
turnId: null,
|
|
30
|
+
turnCount: 0,
|
|
31
|
+
inputTokens: 0,
|
|
32
|
+
outputTokens: 0,
|
|
33
|
+
totalTokens: 0,
|
|
34
|
+
lastEvent: null,
|
|
35
|
+
workerHost,
|
|
36
|
+
rateLimits: null
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await initializeSession(stream, safeCwd, tracker, config, telemetry);
|
|
41
|
+
|
|
42
|
+
let currentIssue = issue;
|
|
43
|
+
let currentPrompt = prompt;
|
|
44
|
+
let completedTurns = 0;
|
|
45
|
+
|
|
46
|
+
while (completedTurns < maxTurns) {
|
|
47
|
+
completedTurns += 1;
|
|
48
|
+
const turn = await startTurn(stream, safeCwd, currentIssue, currentPrompt, config, telemetry);
|
|
49
|
+
telemetry.turnCount = completedTurns;
|
|
50
|
+
telemetry.sessionId = `${telemetry.threadId}-${turn.id}`;
|
|
51
|
+
telemetry.turnId = turn.id;
|
|
52
|
+
|
|
53
|
+
const turnResult = await awaitTurnCompletion({
|
|
54
|
+
stream,
|
|
55
|
+
tracker,
|
|
56
|
+
config,
|
|
57
|
+
telemetry
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (turnResult.status !== 'succeeded') {
|
|
61
|
+
return {
|
|
62
|
+
status: 'failed',
|
|
63
|
+
code: turnResult.code,
|
|
64
|
+
output: stream.stdoutRaw,
|
|
65
|
+
error: turnResult.error || stream.stderrRaw,
|
|
66
|
+
durationMs: Date.now() - startedAt,
|
|
67
|
+
telemetry
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const continuation = await maybeContinueIssue({
|
|
72
|
+
issue: currentIssue,
|
|
73
|
+
tracker,
|
|
74
|
+
config,
|
|
75
|
+
turnNumber: completedTurns,
|
|
76
|
+
maxTurns
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!continuation.shouldContinue) {
|
|
80
|
+
return {
|
|
81
|
+
status: 'succeeded',
|
|
82
|
+
code: 0,
|
|
83
|
+
output: stream.stdoutRaw,
|
|
84
|
+
error: stream.stderrRaw,
|
|
85
|
+
durationMs: Date.now() - startedAt,
|
|
86
|
+
telemetry
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
currentIssue = continuation.issue;
|
|
91
|
+
currentPrompt = buildContinuationPrompt(completedTurns + 1, maxTurns);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
status: 'succeeded',
|
|
96
|
+
code: 0,
|
|
97
|
+
output: stream.stdoutRaw,
|
|
98
|
+
error: stream.stderrRaw,
|
|
99
|
+
durationMs: Date.now() - startedAt,
|
|
100
|
+
telemetry
|
|
101
|
+
};
|
|
102
|
+
} finally {
|
|
103
|
+
stream.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function startProcess(command, cwd, workerHost) {
|
|
108
|
+
if (workerHost) {
|
|
109
|
+
return startSSHProcess(workerHost, `cd ${shellEscape(cwd)} && exec ${command}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return spawn(command, { cwd, shell: '/bin/bash', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function initializeSession(stream, cwd, tracker, config, telemetry) {
|
|
116
|
+
await stream.request(1, {
|
|
117
|
+
method: 'initialize',
|
|
118
|
+
params: {
|
|
119
|
+
capabilities: { experimentalApi: true },
|
|
120
|
+
clientInfo: {
|
|
121
|
+
name: 'taskode-orchestrator',
|
|
122
|
+
title: 'Taskode Orchestrator',
|
|
123
|
+
version: '0.4.0'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}, config.codex.readTimeoutMs);
|
|
127
|
+
|
|
128
|
+
stream.notify({ method: 'initialized', params: {} });
|
|
129
|
+
|
|
130
|
+
const response = await stream.request(2, {
|
|
131
|
+
method: 'thread/start',
|
|
132
|
+
params: {
|
|
133
|
+
approvalPolicy: config.codex.approvalPolicy,
|
|
134
|
+
sandbox: config.codex.threadSandbox,
|
|
135
|
+
cwd,
|
|
136
|
+
dynamicTools: dynamicToolsForTracker(config, tracker)
|
|
137
|
+
}
|
|
138
|
+
}, config.codex.readTimeoutMs);
|
|
139
|
+
|
|
140
|
+
const threadId = response?.thread?.id;
|
|
141
|
+
if (!threadId) {
|
|
142
|
+
throw new Error(`Invalid thread/start response: ${JSON.stringify(response)}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
telemetry.threadId = threadId;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function startTurn(stream, cwd, issue, prompt, config, telemetry) {
|
|
149
|
+
const requestId = stream.nextRequestId();
|
|
150
|
+
const response = await stream.request(requestId, {
|
|
151
|
+
method: 'turn/start',
|
|
152
|
+
params: {
|
|
153
|
+
threadId: telemetry.threadId,
|
|
154
|
+
input: [{ type: 'text', text: prompt }],
|
|
155
|
+
cwd,
|
|
156
|
+
title: `${issue.identifier}: ${issue.title}`,
|
|
157
|
+
approvalPolicy: config.codex.approvalPolicy,
|
|
158
|
+
sandboxPolicy: resolveTurnSandboxPolicy(config, cwd, { remote: Boolean(telemetry.workerHost) })
|
|
159
|
+
}
|
|
160
|
+
}, config.codex.readTimeoutMs);
|
|
161
|
+
|
|
162
|
+
const turnId = response?.turn?.id;
|
|
163
|
+
if (!turnId) {
|
|
164
|
+
throw new Error(`Invalid turn/start response: ${JSON.stringify(response)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { id: turnId };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function awaitTurnCompletion({ stream, tracker, config, telemetry }) {
|
|
171
|
+
const autoApprove = config.codex.approvalPolicy === 'never';
|
|
172
|
+
const deadlineMs = Date.now() + config.codex.turnTimeoutMs;
|
|
173
|
+
|
|
174
|
+
while (Date.now() < deadlineMs) {
|
|
175
|
+
const remaining = Math.max(config.codex.readTimeoutMs, deadlineMs - Date.now());
|
|
176
|
+
const message = await stream.nextEvent(remaining);
|
|
177
|
+
|
|
178
|
+
if (!message) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
integrateTelemetry(telemetry, message);
|
|
183
|
+
|
|
184
|
+
if (message.method === 'turn/completed') {
|
|
185
|
+
telemetry.lastEvent = 'turn/completed';
|
|
186
|
+
return { status: 'succeeded', code: 0 };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (message.method === 'turn/failed' || message.method === 'turn/cancelled') {
|
|
190
|
+
telemetry.lastEvent = message.method;
|
|
191
|
+
return {
|
|
192
|
+
status: 'failed',
|
|
193
|
+
code: 1,
|
|
194
|
+
error: JSON.stringify(message.params || message)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (handleTurnInputRequirement(message)) {
|
|
199
|
+
telemetry.lastEvent = message.method;
|
|
200
|
+
return {
|
|
201
|
+
status: 'failed',
|
|
202
|
+
code: 2,
|
|
203
|
+
error: `Turn requires operator input: ${message.method}`
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (await maybeHandleToolCall({ stream, message, tracker })) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (await maybeHandleApprovalRequest({ stream, message, autoApprove })) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { status: 'failed', code: 124, error: 'app-server turn timeout' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function maybeHandleToolCall({ stream, message, tracker }) {
|
|
220
|
+
if (message.method !== 'item/tool/call') return false;
|
|
221
|
+
|
|
222
|
+
const params = message.params || {};
|
|
223
|
+
const toolName = params.tool || params.name || null;
|
|
224
|
+
const result = await tracker.executeDynamicTool(toolName, params.arguments ?? {});
|
|
225
|
+
stream.respond(message.id, normalizeDynamicToolResult(result));
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function maybeHandleApprovalRequest({ stream, message, autoApprove }) {
|
|
230
|
+
const decisionMethods = new Map([
|
|
231
|
+
['item/commandExecution/requestApproval', 'acceptForSession'],
|
|
232
|
+
['execCommandApproval', 'approved_for_session'],
|
|
233
|
+
['applyPatchApproval', 'approved_for_session'],
|
|
234
|
+
['item/fileChange/requestApproval', 'acceptForSession']
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
if (decisionMethods.has(message.method)) {
|
|
238
|
+
if (!autoApprove) return false;
|
|
239
|
+
stream.respond(message.id, { decision: decisionMethods.get(message.method) });
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (message.method === 'item/tool/requestUserInput') {
|
|
244
|
+
const answers = autoApprove
|
|
245
|
+
? approvalAnswers(message.params)
|
|
246
|
+
: unavailableAnswers(message.params);
|
|
247
|
+
|
|
248
|
+
if (!answers) return false;
|
|
249
|
+
stream.respond(message.id, { answers });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function maybeContinueIssue({ issue, tracker, config, turnNumber, maxTurns }) {
|
|
257
|
+
if (config.tracker.kind === 'local') {
|
|
258
|
+
return { shouldContinue: false, issue };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (turnNumber >= maxTurns) {
|
|
262
|
+
return { shouldContinue: false, issue };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const refreshed = await tracker.fetchByIds([issue.id]);
|
|
266
|
+
const nextIssue = refreshed[0] || issue;
|
|
267
|
+
const normalizedState = String(nextIssue.state || '').trim().toLowerCase();
|
|
268
|
+
const activeStates = new Set(config.tracker.activeStates.map((entry) => String(entry).trim().toLowerCase()));
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
shouldContinue: activeStates.has(normalizedState),
|
|
272
|
+
issue: nextIssue
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildContinuationPrompt(turnNumber, maxTurns) {
|
|
277
|
+
return [
|
|
278
|
+
'Continuation guidance:',
|
|
279
|
+
'',
|
|
280
|
+
'- The previous app-server turn completed normally, but the issue is still active.',
|
|
281
|
+
`- This is continuation turn ${turnNumber} of ${maxTurns}.`,
|
|
282
|
+
'- Resume from the current workspace and thread state instead of restarting.',
|
|
283
|
+
'- Focus only on the remaining work.'
|
|
284
|
+
].join('\n');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function dynamicToolsForTracker(config, tracker) {
|
|
288
|
+
if (config.tracker.kind !== 'linear') {
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (typeof tracker.executeDynamicTool !== 'function') {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
name: TOOL_NAME_LINEAR_GRAPHQL,
|
|
299
|
+
description: 'Execute a raw GraphQL query or mutation against Linear using Taskode credentials.',
|
|
300
|
+
inputSchema: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
additionalProperties: false,
|
|
303
|
+
required: ['query'],
|
|
304
|
+
properties: {
|
|
305
|
+
query: {
|
|
306
|
+
type: 'string',
|
|
307
|
+
description: 'GraphQL query or mutation document to execute against Linear.'
|
|
308
|
+
},
|
|
309
|
+
variables: {
|
|
310
|
+
type: ['object', 'null'],
|
|
311
|
+
description: 'Optional GraphQL variables object.',
|
|
312
|
+
additionalProperties: true
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function normalizeDynamicToolResult(result) {
|
|
321
|
+
if (result && typeof result === 'object' && typeof result.success === 'boolean') {
|
|
322
|
+
return {
|
|
323
|
+
...result,
|
|
324
|
+
output: typeof result.output === 'string' ? result.output : JSON.stringify(result, null, 2),
|
|
325
|
+
contentItems: Array.isArray(result.contentItems)
|
|
326
|
+
? result.contentItems
|
|
327
|
+
: [{ type: 'inputText', text: typeof result.output === 'string' ? result.output : JSON.stringify(result, null, 2) }]
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const output = JSON.stringify(result, null, 2);
|
|
332
|
+
return {
|
|
333
|
+
success: false,
|
|
334
|
+
output,
|
|
335
|
+
contentItems: [{ type: 'inputText', text: output }]
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function integrateTelemetry(telemetry, message) {
|
|
340
|
+
telemetry.lastEvent = message.method || telemetry.lastEvent;
|
|
341
|
+
const usage = message.usage || message.params?.usage || null;
|
|
342
|
+
if (usage && typeof usage === 'object') {
|
|
343
|
+
telemetry.inputTokens += Number(usage.input_tokens || usage.inputTokens || 0);
|
|
344
|
+
telemetry.outputTokens += Number(usage.output_tokens || usage.outputTokens || 0);
|
|
345
|
+
telemetry.totalTokens += Number(usage.total_tokens || usage.totalTokens || 0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const rateLimits = message.rate_limits || message.params?.rate_limits || message.params?.rateLimits || null;
|
|
349
|
+
if (rateLimits && typeof rateLimits === 'object') {
|
|
350
|
+
telemetry.rateLimits = rateLimits;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handleTurnInputRequirement(message) {
|
|
355
|
+
const method = String(message.method || '');
|
|
356
|
+
if (method.startsWith('turn/') && [
|
|
357
|
+
'turn/input_required',
|
|
358
|
+
'turn/needs_input',
|
|
359
|
+
'turn/need_input',
|
|
360
|
+
'turn/request_input',
|
|
361
|
+
'turn/request_response',
|
|
362
|
+
'turn/provide_input',
|
|
363
|
+
'turn/approval_required'
|
|
364
|
+
].includes(method)) {
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return Boolean(message.params?.requiresInput || message.params?.needs_input);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function approvalAnswers(params) {
|
|
372
|
+
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
373
|
+
const answers = {};
|
|
374
|
+
|
|
375
|
+
for (const question of questions) {
|
|
376
|
+
const questionId = question?.id;
|
|
377
|
+
if (!questionId) return null;
|
|
378
|
+
|
|
379
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
380
|
+
const option = options.find((entry) => entry.label === 'Approve this Session')
|
|
381
|
+
|| options.find((entry) => entry.label === 'Approve Once')
|
|
382
|
+
|| options.find((entry) => /^approve|^allow/i.test(String(entry.label || '').trim()));
|
|
383
|
+
|
|
384
|
+
if (!option?.label) return unavailableAnswers(params);
|
|
385
|
+
answers[questionId] = { answers: [option.label] };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return Object.keys(answers).length ? answers : null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function unavailableAnswers(params) {
|
|
392
|
+
const questions = Array.isArray(params?.questions) ? params.questions : [];
|
|
393
|
+
const answers = {};
|
|
394
|
+
|
|
395
|
+
for (const question of questions) {
|
|
396
|
+
if (!question?.id) return null;
|
|
397
|
+
answers[question.id] = { answers: [NON_INTERACTIVE_ANSWER] };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return Object.keys(answers).length ? answers : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
class RpcStream {
|
|
404
|
+
constructor({ child, runId, store, workerHost }) {
|
|
405
|
+
this.child = child;
|
|
406
|
+
this.runId = runId;
|
|
407
|
+
this.store = store;
|
|
408
|
+
this.workerHost = workerHost;
|
|
409
|
+
this.stdoutRaw = '';
|
|
410
|
+
this.stderrRaw = '';
|
|
411
|
+
this.nextId = 4;
|
|
412
|
+
this.pendingResponses = new Map();
|
|
413
|
+
this.eventQueue = [];
|
|
414
|
+
this.eventResolvers = [];
|
|
415
|
+
this.closed = false;
|
|
416
|
+
|
|
417
|
+
this.stdout = readline.createInterface({ input: child.stdout });
|
|
418
|
+
this.stdout.on('line', (line) => this.handleStdoutLine(line));
|
|
419
|
+
child.stderr.on('data', (chunk) => {
|
|
420
|
+
const text = String(chunk);
|
|
421
|
+
this.stderrRaw += text;
|
|
422
|
+
this.store.appendRunLog(this.runId, `[app-server/stderr] ${text.trimEnd()}`);
|
|
423
|
+
});
|
|
424
|
+
child.on('close', (code) => this.handleExit(code));
|
|
425
|
+
child.on('error', (error) => this.handleFatal(error));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
nextRequestId() {
|
|
429
|
+
const value = this.nextId;
|
|
430
|
+
this.nextId += 1;
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async request(id, payload, timeoutMs) {
|
|
435
|
+
const message = { id, ...payload };
|
|
436
|
+
const responsePromise = new Promise((resolve, reject) => {
|
|
437
|
+
const timer = setTimeout(() => {
|
|
438
|
+
this.pendingResponses.delete(id);
|
|
439
|
+
reject(new Error(`Response timeout for request ${id}`));
|
|
440
|
+
}, timeoutMs);
|
|
441
|
+
|
|
442
|
+
this.pendingResponses.set(id, {
|
|
443
|
+
resolve: (value) => {
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
resolve(value);
|
|
446
|
+
},
|
|
447
|
+
reject: (error) => {
|
|
448
|
+
clearTimeout(timer);
|
|
449
|
+
reject(error);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
this.send(message);
|
|
455
|
+
return responsePromise;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
notify(payload) {
|
|
459
|
+
this.send(payload);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
respond(id, result) {
|
|
463
|
+
this.send({ id, result });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async nextEvent(timeoutMs) {
|
|
467
|
+
if (this.eventQueue.length) {
|
|
468
|
+
return this.eventQueue.shift();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (this.closed) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return new Promise((resolve) => {
|
|
476
|
+
const timer = setTimeout(() => {
|
|
477
|
+
this.eventResolvers = this.eventResolvers.filter((entry) => entry.resolve !== resolve);
|
|
478
|
+
resolve(null);
|
|
479
|
+
}, timeoutMs);
|
|
480
|
+
|
|
481
|
+
this.eventResolvers.push({
|
|
482
|
+
resolve: (message) => {
|
|
483
|
+
clearTimeout(timer);
|
|
484
|
+
resolve(message);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
close() {
|
|
491
|
+
if (this.closed) return;
|
|
492
|
+
this.closed = true;
|
|
493
|
+
this.stdout.close();
|
|
494
|
+
this.child.kill('SIGTERM');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
send(message) {
|
|
498
|
+
if (this.child.stdin.destroyed) {
|
|
499
|
+
throw new Error('App-server stdin is closed');
|
|
500
|
+
}
|
|
501
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
502
|
+
this.child.stdin.write(line);
|
|
503
|
+
this.store.appendRunLog(this.runId, `[app-server/out] ${JSON.stringify(message)}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
handleStdoutLine(line) {
|
|
507
|
+
this.stdoutRaw += `${line}\n`;
|
|
508
|
+
|
|
509
|
+
let payload;
|
|
510
|
+
try {
|
|
511
|
+
payload = JSON.parse(line);
|
|
512
|
+
} catch {
|
|
513
|
+
this.store.appendRunLog(this.runId, `[app-server/raw] ${line}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.store.appendRunLog(this.runId, `[app-server/in] ${line}`);
|
|
518
|
+
|
|
519
|
+
if (payload.id && this.pendingResponses.has(payload.id) && ('result' in payload || 'error' in payload)) {
|
|
520
|
+
const waiter = this.pendingResponses.get(payload.id);
|
|
521
|
+
this.pendingResponses.delete(payload.id);
|
|
522
|
+
if (payload.error) {
|
|
523
|
+
waiter.reject(new Error(JSON.stringify(payload.error)));
|
|
524
|
+
} else {
|
|
525
|
+
waiter.resolve(payload.result);
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (this.eventResolvers.length) {
|
|
531
|
+
const waiter = this.eventResolvers.shift();
|
|
532
|
+
waiter.resolve(payload);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
this.eventQueue.push(payload);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
handleExit(code) {
|
|
540
|
+
this.closed = true;
|
|
541
|
+
for (const [, waiter] of this.pendingResponses) {
|
|
542
|
+
waiter.reject(new Error(`App-server exited with code ${code}`));
|
|
543
|
+
}
|
|
544
|
+
this.pendingResponses.clear();
|
|
545
|
+
while (this.eventResolvers.length) {
|
|
546
|
+
const waiter = this.eventResolvers.shift();
|
|
547
|
+
waiter.resolve(null);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
handleFatal(error) {
|
|
552
|
+
this.stderrRaw += `${error.message}\n`;
|
|
553
|
+
this.handleExit(1);
|
|
554
|
+
}
|
|
555
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function authMiddleware(config, store) {
|
|
2
|
+
return (req, res, next) => {
|
|
3
|
+
const token = config.auth.token;
|
|
4
|
+
if (!token) return next();
|
|
5
|
+
|
|
6
|
+
const provided = req.header('x-taskode-token') || req.header('authorization')?.replace(/^Bearer\s+/i, '');
|
|
7
|
+
if (provided !== token) {
|
|
8
|
+
store.appendAudit({ action: 'auth_failed', actor: req.ip, metadata: { path: req.path } });
|
|
9
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
10
|
+
}
|
|
11
|
+
return next();
|
|
12
|
+
};
|
|
13
|
+
}
|