@tagma/sdk 0.2.5 → 0.2.7
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 +30 -7
- package/package.json +1 -1
- package/src/adapters/stdin-approval.ts +3 -14
- package/src/adapters/websocket-approval.ts +1 -3
- package/src/approval.ts +1 -6
- package/src/config-ops.ts +74 -1
- package/src/engine.ts +36 -8
- package/src/logger.ts +177 -112
- package/src/middlewares/static-context.ts +45 -45
- package/src/runner.ts +19 -6
- package/src/schema.ts +3 -1
- package/src/sdk.ts +1 -0
- package/src/triggers/manual.ts +1 -10
- package/src/validate-raw.ts +6 -0
package/README.md
CHANGED
|
@@ -118,7 +118,6 @@ pipeline:
|
|
|
118
118
|
trigger:
|
|
119
119
|
type: manual
|
|
120
120
|
message: "Approve before running"
|
|
121
|
-
options: [approve, reject]
|
|
122
121
|
timeout: 5m
|
|
123
122
|
completion:
|
|
124
123
|
type: exit_code
|
|
@@ -224,7 +223,6 @@ Track-level `middlewares` apply to all tasks in the track. Setting task-level `m
|
|
|
224
223
|
|---|---|---|---|---|
|
|
225
224
|
| `type` | `"manual"` | Yes | — | Trigger type |
|
|
226
225
|
| `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
|
|
227
|
-
| `options` | `string[]` | No | — | Choice options (e.g. `[approve, reject]`) |
|
|
228
226
|
| `timeout` | `string` | No | — | How long to wait for a decision before timing out |
|
|
229
227
|
| `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
|
|
230
228
|
|
|
@@ -296,7 +294,9 @@ Options:
|
|
|
296
294
|
- `onEvent` -- callback for real-time `PipelineEvent` updates:
|
|
297
295
|
- `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
|
|
298
296
|
- `task_status_change` — a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
|
|
297
|
+
- `task_log` — a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
|
|
299
298
|
- `pipeline_end` — pipeline finished; includes `success: boolean`
|
|
299
|
+
- `runId` -- caller-supplied run ID. When provided the engine uses this instead of generating its own, keeping the caller and the SDK log directories aligned on the same ID
|
|
300
300
|
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
|
|
301
301
|
|
|
302
302
|
### `PipelineRunner`
|
|
@@ -436,7 +436,7 @@ const yaml = serializePipeline(config);
|
|
|
436
436
|
| `upsertTask(config, trackId, task)` | Insert or replace a task |
|
|
437
437
|
| `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
|
|
438
438
|
| `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
|
|
439
|
-
| `transferTask(config, fromTrackId, taskId, toTrackId)` | Move a task across tracks |
|
|
439
|
+
| `transferTask(config, fromTrackId, taskId, toTrackId, qualifyRefs?)` | Move a task across tracks. When `qualifyRefs` is `true` (default), bare `depends_on` / `continue_from` references to the moved task are converted to fully-qualified form (`toTrackId.taskId`) so same-track resolution stays correct |
|
|
440
440
|
|
|
441
441
|
### `parseYaml(content: string): RawPipelineConfig`
|
|
442
442
|
|
|
@@ -470,7 +470,7 @@ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `r
|
|
|
470
470
|
|
|
471
471
|
Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
|
|
472
472
|
|
|
473
|
-
Checks: required fields, `prompt`/`command` exclusivity, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
|
|
473
|
+
Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
|
|
474
474
|
|
|
475
475
|
Does **not** check plugin registration (plugins may not be loaded at edit time).
|
|
476
476
|
|
|
@@ -506,9 +506,32 @@ logger.warn('[track]', 'message'); // console + file
|
|
|
506
506
|
logger.error('[track]', 'message'); // console + file
|
|
507
507
|
logger.debug('[track]', 'message'); // file only
|
|
508
508
|
logger.section('Title'); // file only — visual separator
|
|
509
|
-
logger.quiet(bulkText);
|
|
510
|
-
logger.path;
|
|
511
|
-
logger.dir;
|
|
509
|
+
logger.quiet(bulkText); // file only — bulk payload
|
|
510
|
+
logger.path; // log file path
|
|
511
|
+
logger.dir; // run artifact directory
|
|
512
|
+
logger.close(); // close the persistent file handle (called automatically by runPipeline at run completion)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Pass an optional third argument to stream every appended line out as a
|
|
516
|
+
structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
import { Logger, type LogRecord } from '@tagma/sdk';
|
|
520
|
+
|
|
521
|
+
const logger = new Logger(workDir, runId, (record: LogRecord) => {
|
|
522
|
+
// record = { level, taskId, timestamp, text }
|
|
523
|
+
// level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
|
|
524
|
+
// taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
|
|
525
|
+
forwardToUI(record);
|
|
526
|
+
});
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
`section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
|
|
530
|
+
line logically belongs to a task — the extractor cannot infer one otherwise:
|
|
531
|
+
|
|
532
|
+
```ts
|
|
533
|
+
logger.section(`Task ${taskId}`, taskId);
|
|
534
|
+
logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
|
|
512
535
|
```
|
|
513
536
|
|
|
514
537
|
### `tailLines(text: string, n: number): string`
|
package/package.json
CHANGED
|
@@ -43,34 +43,23 @@ export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinAppro
|
|
|
43
43
|
// If the request was already resolved by another path while queued, skip it.
|
|
44
44
|
if (!gateway.pending().some((p) => p.id === req.id)) continue;
|
|
45
45
|
|
|
46
|
-
const optionsStr = req.options.join(' / ');
|
|
47
46
|
process.stdout.write(
|
|
48
47
|
`\n[APPROVAL REQUIRED] ${req.message}\n` +
|
|
49
48
|
` id: ${req.id}\n` +
|
|
50
49
|
` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
|
|
51
|
-
`
|
|
52
|
-
` > `,
|
|
50
|
+
` approve / reject > `,
|
|
53
51
|
);
|
|
54
52
|
|
|
55
53
|
const input = (await readOneLine()).trim().toLowerCase();
|
|
56
54
|
|
|
57
55
|
const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
|
|
58
56
|
const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
|
|
59
|
-
const matchedOption = req.options.find((o) => o.toLowerCase() === input);
|
|
60
57
|
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
gateway.resolve(req.id, {
|
|
64
|
-
outcome: isReject ? 'rejected' : 'approved',
|
|
65
|
-
choice: matchedOption,
|
|
66
|
-
actor: 'cli',
|
|
67
|
-
});
|
|
68
|
-
} else if (approveAliases.has(input)) {
|
|
69
|
-
gateway.resolve(req.id, { outcome: 'approved', choice: input, actor: 'cli' });
|
|
58
|
+
if (approveAliases.has(input)) {
|
|
59
|
+
gateway.resolve(req.id, { outcome: 'approved', actor: 'cli' });
|
|
70
60
|
} else if (rejectAliases.has(input)) {
|
|
71
61
|
gateway.resolve(req.id, {
|
|
72
62
|
outcome: 'rejected',
|
|
73
|
-
choice: input,
|
|
74
63
|
actor: 'cli',
|
|
75
64
|
reason: 'user rejected via CLI',
|
|
76
65
|
});
|
|
@@ -16,7 +16,7 @@ import type { ApprovalGateway, ApprovalEvent } from '../approval';
|
|
|
16
16
|
//
|
|
17
17
|
// Protocol — client → server:
|
|
18
18
|
// { type: 'resolve', approvalId: string, outcome: 'approved'|'rejected',
|
|
19
|
-
//
|
|
19
|
+
// actor?: string, reason?: string }
|
|
20
20
|
|
|
21
21
|
export interface WebSocketApprovalAdapterOptions {
|
|
22
22
|
port?: number; // default: 3000
|
|
@@ -123,7 +123,6 @@ export function attachWebSocketApprovalAdapter(
|
|
|
123
123
|
|
|
124
124
|
const ok = gateway.resolve(msg.approvalId, {
|
|
125
125
|
outcome: msg.outcome,
|
|
126
|
-
choice: msg.choice,
|
|
127
126
|
actor: msg.actor ?? 'websocket',
|
|
128
127
|
reason: msg.reason,
|
|
129
128
|
});
|
|
@@ -159,7 +158,6 @@ interface ResolveMessage {
|
|
|
159
158
|
type: 'resolve';
|
|
160
159
|
approvalId: string;
|
|
161
160
|
outcome: 'approved' | 'rejected';
|
|
162
|
-
choice?: string;
|
|
163
161
|
actor?: string;
|
|
164
162
|
reason?: string;
|
|
165
163
|
}
|
package/src/approval.ts
CHANGED
|
@@ -16,9 +16,6 @@ export type {
|
|
|
16
16
|
ApprovalListener, ApprovalGateway,
|
|
17
17
|
} from '@tagma/types';
|
|
18
18
|
|
|
19
|
-
// Default options presented to the approver when the caller does not specify any.
|
|
20
|
-
const DEFAULT_APPROVAL_OPTIONS = ['approve', 'reject'] as const;
|
|
21
|
-
|
|
22
19
|
// ═══ Default In-Memory Implementation ═══
|
|
23
20
|
|
|
24
21
|
interface PendingEntry {
|
|
@@ -32,7 +29,7 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
|
|
|
32
29
|
private readonly listeners = new Set<ApprovalListener>();
|
|
33
30
|
|
|
34
31
|
request(
|
|
35
|
-
req: Omit<ApprovalRequest, 'id' | 'createdAt'
|
|
32
|
+
req: Omit<ApprovalRequest, 'id' | 'createdAt'>,
|
|
36
33
|
): Promise<ApprovalDecision> {
|
|
37
34
|
const full: ApprovalRequest = {
|
|
38
35
|
id: randomUUID(),
|
|
@@ -40,7 +37,6 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
|
|
|
40
37
|
taskId: req.taskId,
|
|
41
38
|
trackId: req.trackId,
|
|
42
39
|
message: req.message,
|
|
43
|
-
options: req.options && req.options.length > 0 ? req.options : DEFAULT_APPROVAL_OPTIONS,
|
|
44
40
|
timeoutMs: req.timeoutMs,
|
|
45
41
|
metadata: req.metadata,
|
|
46
42
|
};
|
|
@@ -80,7 +76,6 @@ export class InMemoryApprovalGateway implements ApprovalGateway {
|
|
|
80
76
|
const full: ApprovalDecision = {
|
|
81
77
|
approvalId,
|
|
82
78
|
outcome: decision.outcome,
|
|
83
|
-
choice: decision.choice,
|
|
84
79
|
actor: decision.actor,
|
|
85
80
|
reason: decision.reason,
|
|
86
81
|
decidedAt: nowISO(),
|
package/src/config-ops.ts
CHANGED
|
@@ -226,13 +226,21 @@ export function moveTask(
|
|
|
226
226
|
/**
|
|
227
227
|
* Move a task from one track to another (appends to the target track).
|
|
228
228
|
* No-op if either trackId or taskId is not found.
|
|
229
|
+
*
|
|
230
|
+
* When `qualifyRefs` is true (the default), bare references (`depends_on`,
|
|
231
|
+
* `continue_from`) pointing to the moved task are converted to fully-qualified
|
|
232
|
+
* refs (`toTrackId.taskId`) so that same-track resolution doesn't silently
|
|
233
|
+
* break after the task changes tracks.
|
|
229
234
|
*/
|
|
230
235
|
export function transferTask(
|
|
231
236
|
config: RawPipelineConfig,
|
|
232
237
|
fromTrackId: string,
|
|
233
238
|
taskId: string,
|
|
234
239
|
toTrackId: string,
|
|
240
|
+
qualifyRefs = true,
|
|
235
241
|
): RawPipelineConfig {
|
|
242
|
+
if (fromTrackId === toTrackId) return config;
|
|
243
|
+
|
|
236
244
|
let task: RawTaskConfig | undefined;
|
|
237
245
|
const afterRemove = {
|
|
238
246
|
...config,
|
|
@@ -245,5 +253,70 @@ export function transferTask(
|
|
|
245
253
|
}),
|
|
246
254
|
};
|
|
247
255
|
if (!task) return config;
|
|
248
|
-
|
|
256
|
+
const afterInsert = upsertTask(afterRemove, toTrackId, task);
|
|
257
|
+
|
|
258
|
+
if (!qualifyRefs) return afterInsert;
|
|
259
|
+
|
|
260
|
+
// Qualify bare references to the moved task. After the move, bare ref
|
|
261
|
+
// "taskId" from the old track no longer resolves via same-track priority.
|
|
262
|
+
// Convert it to the qualified form "toTrackId.taskId" so the dependency
|
|
263
|
+
// graph stays correct.
|
|
264
|
+
const qualId = `${toTrackId}.${taskId}`;
|
|
265
|
+
const oldQualId = `${fromTrackId}.${taskId}`;
|
|
266
|
+
|
|
267
|
+
// Does any track (other than the destination) still have a task with this bare id?
|
|
268
|
+
const bareIdSurvivesElsewhere = afterInsert.tracks.some(t =>
|
|
269
|
+
t.id !== toTrackId && t.tasks.some(tk => tk.id === taskId),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
...afterInsert,
|
|
274
|
+
tracks: afterInsert.tracks.map(t => {
|
|
275
|
+
const localHasId = t.tasks.some(tk => tk.id === taskId);
|
|
276
|
+
|
|
277
|
+
const qualifyRef = (ref: string): string => {
|
|
278
|
+
// Already-qualified ref to old location → rewrite to new location
|
|
279
|
+
if (ref === oldQualId) return qualId;
|
|
280
|
+
// Bare ref: only needs qualifying if it would have resolved to the
|
|
281
|
+
// moved task before the transfer
|
|
282
|
+
if (ref === taskId) {
|
|
283
|
+
if (t.id === fromTrackId) {
|
|
284
|
+
// Was same-track in the old track — now the task is gone.
|
|
285
|
+
// If no other local task shadows it, qualify to new location.
|
|
286
|
+
if (!localHasId) return qualId;
|
|
287
|
+
}
|
|
288
|
+
// From a different track: bare ref resolved globally before.
|
|
289
|
+
// If the bare id is now ambiguous or gone from this track's
|
|
290
|
+
// perspective, qualify it.
|
|
291
|
+
if (!localHasId && !bareIdSurvivesElsewhere) return qualId;
|
|
292
|
+
}
|
|
293
|
+
return ref;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
...t,
|
|
298
|
+
tasks: t.tasks.map(tk => qualifyTaskRefs(tk, qualifyRef)),
|
|
299
|
+
};
|
|
300
|
+
}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Rewrite `depends_on` and `continue_from` refs using a mapping function. */
|
|
305
|
+
function qualifyTaskRefs(
|
|
306
|
+
task: RawTaskConfig,
|
|
307
|
+
rewrite: (ref: string) => string,
|
|
308
|
+
): RawTaskConfig {
|
|
309
|
+
const newDeps = task.depends_on?.map(rewrite);
|
|
310
|
+
const newContinue = task.continue_from !== undefined ? rewrite(task.continue_from) : undefined;
|
|
311
|
+
|
|
312
|
+
const depsChanged = newDeps !== undefined && newDeps.some((d, i) => d !== task.depends_on![i]);
|
|
313
|
+
const continueChanged = newContinue !== undefined && newContinue !== task.continue_from;
|
|
314
|
+
|
|
315
|
+
if (!depsChanged && !continueChanged) return task;
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
...task,
|
|
319
|
+
...(newDeps !== undefined ? { depends_on: newDeps } : {}),
|
|
320
|
+
...(newContinue !== undefined ? { continue_from: newContinue } : {}),
|
|
321
|
+
};
|
|
249
322
|
}
|
package/src/engine.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
buildPipelineCompleteContext, buildPipelineErrorContext,
|
|
17
17
|
type PipelineInfo, type TrackInfo, type TaskInfo,
|
|
18
18
|
} from './hooks';
|
|
19
|
-
import { Logger, tailLines, clip } from './logger';
|
|
19
|
+
import { Logger, tailLines, clip, type LogLevel } from './logger';
|
|
20
20
|
import { InMemoryApprovalGateway, type ApprovalGateway } from './approval';
|
|
21
21
|
|
|
22
22
|
// ═══ Preflight Validation ═══
|
|
@@ -115,7 +115,15 @@ export interface EngineResult {
|
|
|
115
115
|
export type PipelineEvent =
|
|
116
116
|
| { readonly type: 'task_status_change'; readonly taskId: string; readonly status: TaskStatus; readonly prevStatus: TaskStatus; readonly runId: string; readonly state: TaskState }
|
|
117
117
|
| { readonly type: 'pipeline_start'; readonly runId: string; readonly states: ReadonlyMap<string, TaskState> }
|
|
118
|
-
| { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean }
|
|
118
|
+
| { readonly type: 'pipeline_end'; readonly runId: string; readonly success: boolean }
|
|
119
|
+
/**
|
|
120
|
+
* Fine-grained log line emitted alongside every write to pipeline.log.
|
|
121
|
+
* Consumers use this to stream the full run process into UIs without
|
|
122
|
+
* tailing the log file. `taskId` is non-null for task-scoped lines and
|
|
123
|
+
* null for pipeline-wide messages (e.g. configuration dumps, DAG
|
|
124
|
+
* topology, pipeline start/end).
|
|
125
|
+
*/
|
|
126
|
+
| { readonly type: 'task_log'; readonly runId: string; readonly taskId: string | null; readonly level: LogLevel; readonly timestamp: string; readonly text: string };
|
|
119
127
|
|
|
120
128
|
export interface RunPipelineOptions {
|
|
121
129
|
readonly approvalGateway?: ApprovalGateway;
|
|
@@ -124,6 +132,12 @@ export interface RunPipelineOptions {
|
|
|
124
132
|
* Oldest directories are deleted after each run. Defaults to 20. Set to 0 to disable cleanup.
|
|
125
133
|
*/
|
|
126
134
|
readonly maxLogRuns?: number;
|
|
135
|
+
/**
|
|
136
|
+
* Caller-supplied run ID. When provided the engine uses this instead of
|
|
137
|
+
* generating its own via `generateRunId()`, keeping the editor and SDK
|
|
138
|
+
* log directories aligned on the same ID.
|
|
139
|
+
*/
|
|
140
|
+
readonly runId?: string;
|
|
127
141
|
/**
|
|
128
142
|
* External AbortSignal — aborting it cancels the pipeline immediately.
|
|
129
143
|
* Equivalent to the pipeline timeout firing, but caller-controlled.
|
|
@@ -155,12 +169,24 @@ export async function runPipeline(
|
|
|
155
169
|
}
|
|
156
170
|
|
|
157
171
|
const dag = buildDag(config);
|
|
158
|
-
const runId = generateRunId();
|
|
172
|
+
const runId = options.runId ?? generateRunId();
|
|
159
173
|
preflight(config, dag);
|
|
160
174
|
|
|
161
175
|
const startedAt = nowISO();
|
|
162
176
|
const pipelineInfo: PipelineInfo = { name: config.name, run_id: runId, started_at: startedAt };
|
|
163
|
-
|
|
177
|
+
// Forward every structured log line to subscribers as task_log events.
|
|
178
|
+
// Reading options.onEvent inside the callback (vs. capturing it once) keeps
|
|
179
|
+
// the SDK behavior correct if callers pass a fresh onEvent on each run.
|
|
180
|
+
const log = new Logger(workDir, runId, (record) => {
|
|
181
|
+
options.onEvent?.({
|
|
182
|
+
type: 'task_log',
|
|
183
|
+
runId,
|
|
184
|
+
taskId: record.taskId,
|
|
185
|
+
level: record.level,
|
|
186
|
+
timestamp: record.timestamp,
|
|
187
|
+
text: record.text,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
164
190
|
log.info('[pipeline]', `start "${config.name}" run_id=${runId}`);
|
|
165
191
|
|
|
166
192
|
// File-only: dump the resolved pipeline shape + DAG topology for post-mortem.
|
|
@@ -352,7 +378,7 @@ export async function runPipeline(
|
|
|
352
378
|
const task = node.task;
|
|
353
379
|
const track = node.track;
|
|
354
380
|
|
|
355
|
-
log.section(`Task ${taskId}
|
|
381
|
+
log.section(`Task ${taskId}`, taskId);
|
|
356
382
|
log.debug(`[task:${taskId}]`,
|
|
357
383
|
`type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`);
|
|
358
384
|
|
|
@@ -469,7 +495,7 @@ export async function runPipeline(
|
|
|
469
495
|
}
|
|
470
496
|
log.debug(`[task:${taskId}]`,
|
|
471
497
|
`prompt: ${originalLen} chars (final: ${prompt.length} chars)`);
|
|
472
|
-
log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt
|
|
498
|
+
log.quiet(`--- prompt (final) ---\n${clip(prompt)}\n--- end prompt ---`, taskId);
|
|
473
499
|
|
|
474
500
|
const enrichedTask: TaskConfig = { ...task, prompt };
|
|
475
501
|
const driverCtx: DriverContext = {
|
|
@@ -565,10 +591,10 @@ export async function runPipeline(
|
|
|
565
591
|
log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
|
|
566
592
|
}
|
|
567
593
|
if (result.stdout) {
|
|
568
|
-
log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout
|
|
594
|
+
log.quiet(`--- stdout (${taskId}) ---\n${clip(result.stdout)}\n--- end stdout ---`, taskId);
|
|
569
595
|
}
|
|
570
596
|
if (result.stderr) {
|
|
571
|
-
log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr
|
|
597
|
+
log.quiet(`--- stderr (${taskId}) ---\n${clip(result.stderr)}\n--- end stderr ---`, taskId);
|
|
572
598
|
}
|
|
573
599
|
if (task.completion) {
|
|
574
600
|
log.debug(`[task:${taskId}]`,
|
|
@@ -703,6 +729,8 @@ export async function runPipeline(
|
|
|
703
729
|
return { success: allSuccess, runId, logPath: log.path, summary, states: freezeStates(states) };
|
|
704
730
|
|
|
705
731
|
} finally {
|
|
732
|
+
// Close the persistent log file handle before pruning.
|
|
733
|
+
log.close();
|
|
706
734
|
// Prune old per-run log directories on every exit path (normal, blocked, or thrown).
|
|
707
735
|
// Exclude the current runId so a concurrent run cannot delete its own live directory.
|
|
708
736
|
if (maxLogRuns > 0) {
|
package/src/logger.ts
CHANGED
|
@@ -1,112 +1,177 @@
|
|
|
1
|
-
import { resolve, dirname } from 'node:path';
|
|
2
|
-
import { mkdirSync,
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
1
|
+
import { resolve, dirname } from 'node:path';
|
|
2
|
+
import { mkdirSync, writeFileSync, openSync, writeSync, closeSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structured record emitted for every log line. Consumers (e.g. the editor
|
|
6
|
+
* server) use this to stream process-level detail into UIs alongside the
|
|
7
|
+
* on-disk pipeline.log. `taskId` is extracted from a `[task:<id>]` prefix
|
|
8
|
+
* when the call site passes one, or overridden explicitly via the optional
|
|
9
|
+
* `taskId` argument on `section`/`quiet` (which carry no prefix).
|
|
10
|
+
*/
|
|
11
|
+
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet';
|
|
12
|
+
|
|
13
|
+
export interface LogRecord {
|
|
14
|
+
readonly level: LogLevel;
|
|
15
|
+
readonly taskId: string | null;
|
|
16
|
+
readonly timestamp: string;
|
|
17
|
+
readonly text: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LogListener = (record: LogRecord) => void;
|
|
21
|
+
|
|
22
|
+
const TASK_PREFIX_RE = /\[task:([^\]]+)\]/;
|
|
23
|
+
|
|
24
|
+
function taskIdFromPrefix(prefix: string): string | null {
|
|
25
|
+
const m = TASK_PREFIX_RE.exec(prefix);
|
|
26
|
+
return m ? m[1] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dual-channel logger.
|
|
31
|
+
*
|
|
32
|
+
* - `info/warn/error` → console AND file (brief, user-visible events)
|
|
33
|
+
* - `debug` → file ONLY (verbose diagnostics)
|
|
34
|
+
* - `section` → file ONLY (visual separators)
|
|
35
|
+
* - `quiet` → file ONLY (bulk payload like full stdout dumps)
|
|
36
|
+
*
|
|
37
|
+
* Log file path: <workDir>/.tagma/logs/<runId>/pipeline.log (one file per pipeline run,
|
|
38
|
+
* truncated on construction). Every line is also forwarded to the optional
|
|
39
|
+
* `onLine` callback as a structured `LogRecord`, so callers that want to
|
|
40
|
+
* stream the run process over IPC/SSE don't need to tail the file.
|
|
41
|
+
*/
|
|
42
|
+
export class Logger {
|
|
43
|
+
private readonly filePath: string;
|
|
44
|
+
private readonly runDir: string;
|
|
45
|
+
private readonly onLine: LogListener | null;
|
|
46
|
+
/** Persistent file descriptor for append writes (avoids open/close per line). */
|
|
47
|
+
private fd: number | null;
|
|
48
|
+
|
|
49
|
+
constructor(workDir: string, runId: string, onLine?: LogListener) {
|
|
50
|
+
this.runDir = resolve(workDir, '.tagma', 'logs', runId);
|
|
51
|
+
this.filePath = resolve(this.runDir, 'pipeline.log');
|
|
52
|
+
this.onLine = onLine ?? null;
|
|
53
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
54
|
+
const header =
|
|
55
|
+
`# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
|
|
56
|
+
`# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
|
|
57
|
+
`# Work dir: ${workDir}\n\n`;
|
|
58
|
+
writeFileSync(this.filePath, header);
|
|
59
|
+
// Open once for all subsequent appends (O_APPEND is implied by 'a' flag)
|
|
60
|
+
this.fd = openSync(this.filePath, 'a');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
info(prefix: string, message: string): void {
|
|
64
|
+
const ts = timestamp();
|
|
65
|
+
const line = `${ts} ${prefix} ${message}`;
|
|
66
|
+
console.log(line);
|
|
67
|
+
this.emit('info', ts, line, taskIdFromPrefix(prefix));
|
|
68
|
+
this.append(line);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
warn(prefix: string, message: string): void {
|
|
72
|
+
const ts = timestamp();
|
|
73
|
+
const line = `${ts} ${prefix} WARN: ${message}`;
|
|
74
|
+
console.warn(line);
|
|
75
|
+
this.emit('warn', ts, line, taskIdFromPrefix(prefix));
|
|
76
|
+
this.append(line);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
error(prefix: string, message: string): void {
|
|
80
|
+
const ts = timestamp();
|
|
81
|
+
const line = `${ts} ${prefix} ERROR: ${message}`;
|
|
82
|
+
console.error(line);
|
|
83
|
+
this.emit('error', ts, line, taskIdFromPrefix(prefix));
|
|
84
|
+
this.append(line);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** File-only diagnostic log line. */
|
|
88
|
+
debug(prefix: string, message: string): void {
|
|
89
|
+
const ts = timestamp();
|
|
90
|
+
const line = `${ts} ${prefix} DEBUG: ${message}`;
|
|
91
|
+
this.emit('debug', ts, line, taskIdFromPrefix(prefix));
|
|
92
|
+
this.append(line);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** File-only visual separator with title. */
|
|
96
|
+
section(title: string, taskId?: string | null): void {
|
|
97
|
+
const ts = timestamp();
|
|
98
|
+
const text = `\n━━━ ${title} ━━━`;
|
|
99
|
+
this.emit('section', ts, text, taskId ?? null);
|
|
100
|
+
this.append(text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** File-only bulk payload (e.g. full stdout / stderr dumps). */
|
|
104
|
+
quiet(message: string, taskId?: string | null): void {
|
|
105
|
+
const ts = timestamp();
|
|
106
|
+
this.emit('quiet', ts, message, taskId ?? null);
|
|
107
|
+
this.append(message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private append(line: string): void {
|
|
111
|
+
if (this.fd === null) return;
|
|
112
|
+
try {
|
|
113
|
+
const data = line.endsWith('\n') ? line : line + '\n';
|
|
114
|
+
writeSync(this.fd, data);
|
|
115
|
+
} catch {
|
|
116
|
+
// Swallow log write failures; engine correctness shouldn't depend on logging.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Close the persistent file handle. Called by the engine at run completion. */
|
|
121
|
+
close(): void {
|
|
122
|
+
if (this.fd !== null) {
|
|
123
|
+
try { closeSync(this.fd); } catch { /* already closed */ }
|
|
124
|
+
this.fd = null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private emit(level: LogLevel, ts: string, text: string, taskId: string | null): void {
|
|
129
|
+
if (!this.onLine) return;
|
|
130
|
+
try {
|
|
131
|
+
this.onLine({ level, taskId, timestamp: ts, text });
|
|
132
|
+
} catch {
|
|
133
|
+
// Never let a listener error derail the pipeline.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get path(): string {
|
|
138
|
+
return this.filePath;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Directory that holds all artifacts for this run (pipeline.log, *.stderr, etc.). */
|
|
142
|
+
get dir(): string {
|
|
143
|
+
return this.runDir;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function timestamp(): string {
|
|
148
|
+
const d = new Date();
|
|
149
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
150
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
151
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
152
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
153
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Return the last `n` non-empty lines of `text`, joined with newlines. */
|
|
157
|
+
export function tailLines(text: string, n: number): string {
|
|
158
|
+
if (!text) return '';
|
|
159
|
+
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
|
160
|
+
return lines.slice(-n).join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Truncate a blob to at most `maxBytes` UTF-8 bytes for log embedding,
|
|
165
|
+
* appending a marker when truncation occurred.
|
|
166
|
+
* Uses TextEncoder so CJK and emoji (multi-byte) characters are counted correctly.
|
|
167
|
+
*/
|
|
168
|
+
export function clip(text: string, maxBytes = 16 * 1024): string {
|
|
169
|
+
if (!text) return '';
|
|
170
|
+
const encoder = new TextEncoder();
|
|
171
|
+
const bytes = encoder.encode(text);
|
|
172
|
+
if (bytes.length <= maxBytes) return text;
|
|
173
|
+
const omittedBytes = bytes.length - maxBytes;
|
|
174
|
+
// TextDecoder handles partial code-point boundaries safely (replacement char insertion)
|
|
175
|
+
const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
|
|
176
|
+
return truncated + `\n…[truncated ${omittedBytes} bytes]`;
|
|
177
|
+
}
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
import { basename } from 'path';
|
|
2
|
-
import type { MiddlewarePlugin, MiddlewareContext } from '../types';
|
|
3
|
-
import { validatePath } from '../utils';
|
|
4
|
-
|
|
5
|
-
export const StaticContextMiddleware: MiddlewarePlugin = {
|
|
6
|
-
name: 'static_context',
|
|
7
|
-
schema: {
|
|
8
|
-
description: 'Prepend a reference file to the prompt as static context.',
|
|
9
|
-
fields: {
|
|
10
|
-
file: {
|
|
11
|
-
type: 'path',
|
|
12
|
-
required: true,
|
|
13
|
-
description: 'Path to the reference file (relative to workDir or absolute).',
|
|
14
|
-
placeholder: 'docs/spec.md',
|
|
15
|
-
},
|
|
16
|
-
label: {
|
|
17
|
-
type: 'string',
|
|
18
|
-
description: 'Header shown before the content. Defaults to "Reference: <basename>".',
|
|
19
|
-
placeholder: 'Reference: spec.md',
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
|
|
24
|
-
async enhance(
|
|
25
|
-
prompt: string,
|
|
26
|
-
config: Record<string, unknown>,
|
|
27
|
-
ctx: MiddlewareContext,
|
|
28
|
-
): Promise<string> {
|
|
29
|
-
const filePath = config.file as string;
|
|
30
|
-
if (!filePath) throw new Error('static_context middleware: "file" is required');
|
|
31
|
-
|
|
32
|
-
const safePath = validatePath(filePath, ctx.workDir);
|
|
33
|
-
const file = Bun.file(safePath);
|
|
34
|
-
|
|
35
|
-
if (!(await file.exists())) {
|
|
36
|
-
console.warn(`static_context: file ${filePath} not found, skipping`);
|
|
37
|
-
return prompt;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const content = await file.text();
|
|
41
|
-
const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
|
|
42
|
-
|
|
43
|
-
return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
|
|
44
|
-
},
|
|
45
|
-
};
|
|
1
|
+
import { basename } from 'path';
|
|
2
|
+
import type { MiddlewarePlugin, MiddlewareContext } from '../types';
|
|
3
|
+
import { validatePath } from '../utils';
|
|
4
|
+
|
|
5
|
+
export const StaticContextMiddleware: MiddlewarePlugin = {
|
|
6
|
+
name: 'static_context',
|
|
7
|
+
schema: {
|
|
8
|
+
description: 'Prepend a reference file to the prompt as static context.',
|
|
9
|
+
fields: {
|
|
10
|
+
file: {
|
|
11
|
+
type: 'path',
|
|
12
|
+
required: true,
|
|
13
|
+
description: 'Path to the reference file (relative to workDir or absolute).',
|
|
14
|
+
placeholder: 'docs/spec.md',
|
|
15
|
+
},
|
|
16
|
+
label: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
description: 'Header shown before the content. Defaults to "Reference: <basename>".',
|
|
19
|
+
placeholder: 'Reference: spec.md',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async enhance(
|
|
25
|
+
prompt: string,
|
|
26
|
+
config: Record<string, unknown>,
|
|
27
|
+
ctx: MiddlewareContext,
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const filePath = config.file as string;
|
|
30
|
+
if (!filePath) throw new Error('static_context middleware: "file" is required');
|
|
31
|
+
|
|
32
|
+
const safePath = validatePath(filePath, ctx.workDir);
|
|
33
|
+
const file = Bun.file(safePath);
|
|
34
|
+
|
|
35
|
+
if (!(await file.exists())) {
|
|
36
|
+
console.warn(`static_context: file ${filePath} not found, skipping`);
|
|
37
|
+
return prompt;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const content = await file.text();
|
|
41
|
+
const label = (config.label as string) ?? `Reference: ${basename(filePath)}`;
|
|
42
|
+
|
|
43
|
+
return `[${label}]\n${content}\n\n[Task]\n${prompt}`;
|
|
44
|
+
},
|
|
45
|
+
};
|
package/src/runner.ts
CHANGED
|
@@ -15,10 +15,17 @@ const SIGKILL_DELAY_MS = 3_000;
|
|
|
15
15
|
function killProcessTree(pid: number): void {
|
|
16
16
|
if (process.platform !== 'win32') return;
|
|
17
17
|
try {
|
|
18
|
-
Bun.spawnSync(['taskkill', '/F', '/T', '/PID', String(pid)], {
|
|
19
|
-
stdout: '
|
|
20
|
-
stderr: '
|
|
18
|
+
const result = Bun.spawnSync(['taskkill', '/F', '/T', '/PID', String(pid)], {
|
|
19
|
+
stdout: 'pipe',
|
|
20
|
+
stderr: 'pipe',
|
|
21
21
|
});
|
|
22
|
+
if (result.exitCode !== 0) {
|
|
23
|
+
const stderr = new TextDecoder().decode(result.stderr);
|
|
24
|
+
// Exit code 128 = process not found (already exited) — not worth warning about
|
|
25
|
+
if (result.exitCode !== 128) {
|
|
26
|
+
console.error(`[killProcessTree] taskkill exited ${result.exitCode} for PID ${pid}: ${stderr.trim()}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
22
29
|
} catch {
|
|
23
30
|
/* best-effort — process may have already exited */
|
|
24
31
|
}
|
|
@@ -140,6 +147,7 @@ export async function runSpawn(
|
|
|
140
147
|
|
|
141
148
|
// ── 3. Timeout & abort handling ────────────────────────────────────────
|
|
142
149
|
let killedByUs = false;
|
|
150
|
+
let timedOut = false;
|
|
143
151
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
144
152
|
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
145
153
|
|
|
@@ -165,7 +173,10 @@ export async function runSpawn(
|
|
|
165
173
|
};
|
|
166
174
|
|
|
167
175
|
if (timeoutMs && timeoutMs > 0) {
|
|
168
|
-
timer = setTimeout(
|
|
176
|
+
timer = setTimeout(() => {
|
|
177
|
+
timedOut = true;
|
|
178
|
+
killGracefully();
|
|
179
|
+
}, timeoutMs);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
const onAbort = () => killGracefully();
|
|
@@ -197,8 +208,10 @@ export async function runSpawn(
|
|
|
197
208
|
// We initiated the kill (timeout or abort) — always treat as non-success
|
|
198
209
|
// regardless of exit code. A process that catches SIGTERM and exits 0 still
|
|
199
210
|
// hit the timeout; letting it pass as success would unblock downstream tasks
|
|
200
|
-
// incorrectly.
|
|
201
|
-
|
|
211
|
+
// incorrectly. The `timedOut` flag guards against the narrow race where the
|
|
212
|
+
// process exits naturally at the exact moment the timeout fires — even if
|
|
213
|
+
// killedByUs wasn't set in time, the timeout intention still applies.
|
|
214
|
+
if (killedByUs || timedOut) {
|
|
202
215
|
return {
|
|
203
216
|
exitCode: -1,
|
|
204
217
|
stdout,
|
package/src/schema.ts
CHANGED
|
@@ -101,7 +101,9 @@ async function loadTemplate(ref: string): Promise<TemplateConfig> {
|
|
|
101
101
|
// Expect the module to export a template.yaml content or parsed object
|
|
102
102
|
if (mod.template) return mod.template as TemplateConfig;
|
|
103
103
|
|
|
104
|
-
// Try loading template.yaml from the package
|
|
104
|
+
// Try loading template.yaml from the package.
|
|
105
|
+
// NOTE: require.resolve is a CommonJS API. Bun supports it natively, but
|
|
106
|
+
// this would need import.meta.resolve() for pure ESM runtimes (e.g. Deno).
|
|
105
107
|
const pkgPath = require.resolve(`${moduleName}/template.yaml`);
|
|
106
108
|
const content = await Bun.file(pkgPath).text();
|
|
107
109
|
const doc = yaml.load(content) as { template: TemplateConfig };
|
package/src/sdk.ts
CHANGED
|
@@ -63,6 +63,7 @@ export type { WebSocketApprovalAdapter, WebSocketApprovalAdapterOptions } from '
|
|
|
63
63
|
|
|
64
64
|
// ── Logger ──
|
|
65
65
|
export { Logger, tailLines, clip } from './logger';
|
|
66
|
+
export type { LogRecord, LogLevel, LogListener } from './logger';
|
|
66
67
|
|
|
67
68
|
// ── Hook context types (useful for frontend display) ──
|
|
68
69
|
export type { HookResult, PipelineInfo, TrackInfo, TaskInfo } from './hooks';
|
package/src/triggers/manual.ts
CHANGED
|
@@ -16,11 +16,6 @@ export const ManualTrigger: TriggerPlugin = {
|
|
|
16
16
|
description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
|
|
17
17
|
placeholder: '10m',
|
|
18
18
|
},
|
|
19
|
-
options: {
|
|
20
|
-
type: 'string',
|
|
21
|
-
description: 'Comma-separated list of choices offered to the approver (e.g. "yes,no,defer").',
|
|
22
|
-
placeholder: 'yes,no',
|
|
23
|
-
},
|
|
24
19
|
},
|
|
25
20
|
},
|
|
26
21
|
|
|
@@ -28,9 +23,6 @@ export const ManualTrigger: TriggerPlugin = {
|
|
|
28
23
|
const message =
|
|
29
24
|
(config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
|
|
30
25
|
const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
|
|
31
|
-
const options = Array.isArray(config.options)
|
|
32
|
-
? (config.options as unknown[]).map(String)
|
|
33
|
-
: undefined;
|
|
34
26
|
const metadata =
|
|
35
27
|
config.metadata && typeof config.metadata === 'object'
|
|
36
28
|
? (config.metadata as Record<string, unknown>)
|
|
@@ -40,7 +32,6 @@ export const ManualTrigger: TriggerPlugin = {
|
|
|
40
32
|
taskId: ctx.taskId,
|
|
41
33
|
trackId: ctx.trackId,
|
|
42
34
|
message,
|
|
43
|
-
options,
|
|
44
35
|
timeoutMs,
|
|
45
36
|
metadata,
|
|
46
37
|
});
|
|
@@ -66,7 +57,7 @@ export const ManualTrigger: TriggerPlugin = {
|
|
|
66
57
|
|
|
67
58
|
switch (decision.outcome) {
|
|
68
59
|
case 'approved':
|
|
69
|
-
return { confirmed: true, approvalId: decision.approvalId,
|
|
60
|
+
return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
|
|
70
61
|
case 'rejected':
|
|
71
62
|
throw new Error(
|
|
72
63
|
`Manual trigger rejected by ${decision.actor ?? 'user'}` +
|
package/src/validate-raw.ts
CHANGED
|
@@ -74,6 +74,7 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
// ── Per-task validation ──
|
|
77
|
+
const seenTaskIds = new Set<string>();
|
|
77
78
|
for (let ki = 0; ki < track.tasks.length; ki++) {
|
|
78
79
|
const task = track.tasks[ki];
|
|
79
80
|
const taskPath = `${trackPath}.tasks[${ki}]`;
|
|
@@ -83,6 +84,11 @@ export function validateRaw(config: RawPipelineConfig): ValidationError[] {
|
|
|
83
84
|
continue; // Can't check further without an id
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
if (seenTaskIds.has(task.id)) {
|
|
88
|
+
errors.push({ path: taskPath, message: `Duplicate task id "${task.id}" in track "${track.id}"` });
|
|
89
|
+
}
|
|
90
|
+
seenTaskIds.add(task.id);
|
|
91
|
+
|
|
86
92
|
// Template-based tasks: skip prompt/command checks (params validated at runtime)
|
|
87
93
|
if (task.use) continue;
|
|
88
94
|
|