@tagma/sdk 0.2.4 → 0.2.6
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 +68 -6
- package/package.json +1 -1
- package/src/adapters/stdin-approval.ts +106 -117
- package/src/adapters/websocket-approval.ts +1 -3
- package/src/approval.ts +1 -6
- package/src/completions/exit-code.ts +30 -19
- package/src/completions/file-exists.ts +60 -39
- package/src/completions/output-check.ts +17 -0
- package/src/dag.ts +222 -222
- package/src/engine.ts +27 -7
- package/src/logger.ts +164 -112
- package/src/middlewares/static-context.ts +16 -0
- package/src/sdk.ts +5 -0
- package/src/templates.ts +97 -0
- package/src/triggers/file.ts +16 -0
- package/src/triggers/manual.ts +72 -61
package/README.md
CHANGED
|
@@ -60,7 +60,8 @@ console.log(result.success ? 'Done' : 'Failed');
|
|
|
60
60
|
- **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
|
|
61
61
|
- **Middleware** -- enrich prompts before execution (e.g. inject static context)
|
|
62
62
|
- **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
|
|
63
|
-
- **Template expansion** -- reusable task templates with parameterized `use` / `with`
|
|
63
|
+
- **Template expansion** -- reusable task templates with parameterized `use` / `with`; `discoverTemplates()` enumerates installed `@tagma/template-*` packages for editor integrations
|
|
64
|
+
- **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
|
|
64
65
|
|
|
65
66
|
## Pipeline YAML Reference
|
|
66
67
|
|
|
@@ -117,7 +118,6 @@ pipeline:
|
|
|
117
118
|
trigger:
|
|
118
119
|
type: manual
|
|
119
120
|
message: "Approve before running"
|
|
120
|
-
options: [approve, reject]
|
|
121
121
|
timeout: 5m
|
|
122
122
|
completion:
|
|
123
123
|
type: exit_code
|
|
@@ -223,7 +223,6 @@ Track-level `middlewares` apply to all tasks in the track. Setting task-level `m
|
|
|
223
223
|
|---|---|---|---|---|
|
|
224
224
|
| `type` | `"manual"` | Yes | — | Trigger type |
|
|
225
225
|
| `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
|
|
226
|
-
| `options` | `string[]` | No | — | Choice options (e.g. `[approve, reject]`) |
|
|
227
226
|
| `timeout` | `string` | No | — | How long to wait for a decision before timing out |
|
|
228
227
|
| `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
|
|
229
228
|
|
|
@@ -295,6 +294,7 @@ Options:
|
|
|
295
294
|
- `onEvent` -- callback for real-time `PipelineEvent` updates:
|
|
296
295
|
- `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
|
|
297
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.
|
|
298
298
|
- `pipeline_end` — pipeline finished; includes `success: boolean`
|
|
299
299
|
- `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
|
|
300
300
|
|
|
@@ -333,6 +333,27 @@ Dynamically loads and registers external plugin packages.
|
|
|
333
333
|
|
|
334
334
|
Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
|
|
335
335
|
|
|
336
|
+
Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import type { TriggerPlugin } from '@tagma/types';
|
|
340
|
+
|
|
341
|
+
export const HttpTrigger: TriggerPlugin = {
|
|
342
|
+
name: 'http',
|
|
343
|
+
schema: {
|
|
344
|
+
description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
|
|
345
|
+
fields: {
|
|
346
|
+
url: { type: 'string', required: true, placeholder: 'https://...' },
|
|
347
|
+
method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
|
|
348
|
+
timeout:{ type: 'duration', description: 'Give up after this long.' },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
async watch(config, ctx) { /* ... */ },
|
|
352
|
+
};
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
|
|
356
|
+
|
|
336
357
|
### `getHandler(category, type): PluginType`
|
|
337
358
|
|
|
338
359
|
Retrieves a registered plugin handler. Throws if the plugin is not registered.
|
|
@@ -355,6 +376,25 @@ Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` di
|
|
|
355
376
|
|
|
356
377
|
Expands `use:` template references in a task list. Loads template packages (`@tagma/template-*`), resolves parameters, and namespaces task IDs and dependencies. Called internally by `loadPipeline`.
|
|
357
378
|
|
|
379
|
+
### `discoverTemplates(workDir: string): TemplateManifest[]`
|
|
380
|
+
|
|
381
|
+
Scans `<workDir>/node_modules/@tagma/` for installed `template-*` packages and returns their manifests (name, description, params, ref). Intended for editors/UIs that want to render a "pick a template" browser without actually expanding any templates. Silently skips packages whose `template.yaml` is missing or invalid.
|
|
382
|
+
|
|
383
|
+
```ts
|
|
384
|
+
import { discoverTemplates } from '@tagma/sdk';
|
|
385
|
+
|
|
386
|
+
const templates = discoverTemplates(process.cwd());
|
|
387
|
+
// [{ ref: '@tagma/template-review', name: 'Code Review', description: '...', params: {...}, tasks: [...] }, ...]
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Each `TemplateManifest` is a `TemplateConfig` with an extra `ref` field — the value users drop into `task.use`.
|
|
391
|
+
|
|
392
|
+
### `loadTemplateManifest(ref: string, workDir: string): TemplateManifest | null`
|
|
393
|
+
|
|
394
|
+
Loads a single template's manifest by ref (e.g. `@tagma/template-review`). Returns `null` when the package isn't installed or its manifest fails to parse. Complements `discoverTemplates` when the caller only needs one template.
|
|
395
|
+
|
|
396
|
+
Both functions use Node's `fs` APIs and are safe to call from Node runtimes (unlike the legacy Bun-only `loadTemplate` used internally by `expandTemplates`).
|
|
397
|
+
|
|
358
398
|
### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
|
|
359
399
|
|
|
360
400
|
Attaches an interactive stdin-based approval handler.
|
|
@@ -465,9 +505,31 @@ logger.warn('[track]', 'message'); // console + file
|
|
|
465
505
|
logger.error('[track]', 'message'); // console + file
|
|
466
506
|
logger.debug('[track]', 'message'); // file only
|
|
467
507
|
logger.section('Title'); // file only — visual separator
|
|
468
|
-
logger.quiet(bulkText);
|
|
469
|
-
logger.path;
|
|
470
|
-
logger.dir;
|
|
508
|
+
logger.quiet(bulkText); // file only — bulk payload
|
|
509
|
+
logger.path; // log file path
|
|
510
|
+
logger.dir; // run artifact directory
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Pass an optional third argument to stream every appended line out as a
|
|
514
|
+
structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
import { Logger, type LogRecord } from '@tagma/sdk';
|
|
518
|
+
|
|
519
|
+
const logger = new Logger(workDir, runId, (record: LogRecord) => {
|
|
520
|
+
// record = { level, taskId, timestamp, text }
|
|
521
|
+
// level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
|
|
522
|
+
// taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
|
|
523
|
+
forwardToUI(record);
|
|
524
|
+
});
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
`section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
|
|
528
|
+
line logically belongs to a task — the extractor cannot infer one otherwise:
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
logger.section(`Task ${taskId}`, taskId);
|
|
532
|
+
logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
|
|
471
533
|
```
|
|
472
534
|
|
|
473
535
|
### `tailLines(text: string, n: number): string`
|
package/package.json
CHANGED
|
@@ -1,117 +1,106 @@
|
|
|
1
|
-
import * as readline from 'readline';
|
|
2
|
-
import type { ApprovalGateway, ApprovalRequest } from '../approval';
|
|
3
|
-
|
|
4
|
-
// ═══ CLI Stdin Adapter ═══
|
|
5
|
-
//
|
|
6
|
-
// Subscribes to the gateway's 'requested' events, prompts the user on stdout,
|
|
7
|
-
// reads a line from stdin, and calls gateway.resolve(). Handles at most one
|
|
8
|
-
// prompt at a time — additional requests queue up.
|
|
9
|
-
|
|
10
|
-
export interface StdinApprovalAdapter {
|
|
11
|
-
readonly detach: () => void;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
|
|
15
|
-
const queue: ApprovalRequest[] = [];
|
|
16
|
-
let processing = false;
|
|
17
|
-
let rl: readline.Interface | null = null;
|
|
18
|
-
|
|
19
|
-
function ensureReadline(): readline.Interface {
|
|
20
|
-
if (!rl) {
|
|
21
|
-
rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
22
|
-
}
|
|
23
|
-
return rl;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function readOneLine(): Promise<string> {
|
|
27
|
-
return new Promise((resolvePromise) => {
|
|
28
|
-
const reader = ensureReadline();
|
|
29
|
-
const handler = (line: string): void => {
|
|
30
|
-
reader.off('line', handler);
|
|
31
|
-
resolvePromise(line);
|
|
32
|
-
};
|
|
33
|
-
reader.on('line', handler);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function processNext(): Promise<void> {
|
|
38
|
-
if (processing) return;
|
|
39
|
-
processing = true;
|
|
40
|
-
try {
|
|
41
|
-
while (queue.length > 0) {
|
|
42
|
-
const req = queue.shift()!;
|
|
43
|
-
// If the request was already resolved by another path while queued, skip it.
|
|
44
|
-
if (!gateway.pending().some((p) => p.id === req.id)) continue;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
`
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
const
|
|
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
|
-
return {
|
|
109
|
-
detach: () => {
|
|
110
|
-
unsubscribe();
|
|
111
|
-
if (rl) {
|
|
112
|
-
rl.close();
|
|
113
|
-
rl = null;
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
}
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
import type { ApprovalGateway, ApprovalRequest } from '../approval';
|
|
3
|
+
|
|
4
|
+
// ═══ CLI Stdin Adapter ═══
|
|
5
|
+
//
|
|
6
|
+
// Subscribes to the gateway's 'requested' events, prompts the user on stdout,
|
|
7
|
+
// reads a line from stdin, and calls gateway.resolve(). Handles at most one
|
|
8
|
+
// prompt at a time — additional requests queue up.
|
|
9
|
+
|
|
10
|
+
export interface StdinApprovalAdapter {
|
|
11
|
+
readonly detach: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function attachStdinApprovalAdapter(gateway: ApprovalGateway): StdinApprovalAdapter {
|
|
15
|
+
const queue: ApprovalRequest[] = [];
|
|
16
|
+
let processing = false;
|
|
17
|
+
let rl: readline.Interface | null = null;
|
|
18
|
+
|
|
19
|
+
function ensureReadline(): readline.Interface {
|
|
20
|
+
if (!rl) {
|
|
21
|
+
rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
22
|
+
}
|
|
23
|
+
return rl;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readOneLine(): Promise<string> {
|
|
27
|
+
return new Promise((resolvePromise) => {
|
|
28
|
+
const reader = ensureReadline();
|
|
29
|
+
const handler = (line: string): void => {
|
|
30
|
+
reader.off('line', handler);
|
|
31
|
+
resolvePromise(line);
|
|
32
|
+
};
|
|
33
|
+
reader.on('line', handler);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function processNext(): Promise<void> {
|
|
38
|
+
if (processing) return;
|
|
39
|
+
processing = true;
|
|
40
|
+
try {
|
|
41
|
+
while (queue.length > 0) {
|
|
42
|
+
const req = queue.shift()!;
|
|
43
|
+
// If the request was already resolved by another path while queued, skip it.
|
|
44
|
+
if (!gateway.pending().some((p) => p.id === req.id)) continue;
|
|
45
|
+
|
|
46
|
+
process.stdout.write(
|
|
47
|
+
`\n[APPROVAL REQUIRED] ${req.message}\n` +
|
|
48
|
+
` id: ${req.id}\n` +
|
|
49
|
+
` task: ${req.taskId}${req.trackId ? ` (track: ${req.trackId})` : ''}\n` +
|
|
50
|
+
` approve / reject > `,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const input = (await readOneLine()).trim().toLowerCase();
|
|
54
|
+
|
|
55
|
+
const approveAliases = new Set(['approve', 'yes', 'y', 'ok', 'true', '1']);
|
|
56
|
+
const rejectAliases = new Set(['reject', 'no', 'n', 'deny', 'false', '0']);
|
|
57
|
+
|
|
58
|
+
if (approveAliases.has(input)) {
|
|
59
|
+
gateway.resolve(req.id, { outcome: 'approved', actor: 'cli' });
|
|
60
|
+
} else if (rejectAliases.has(input)) {
|
|
61
|
+
gateway.resolve(req.id, {
|
|
62
|
+
outcome: 'rejected',
|
|
63
|
+
actor: 'cli',
|
|
64
|
+
reason: 'user rejected via CLI',
|
|
65
|
+
});
|
|
66
|
+
} else {
|
|
67
|
+
process.stdout.write(` unrecognized input "${input}" — treating as rejection\n`);
|
|
68
|
+
gateway.resolve(req.id, {
|
|
69
|
+
outcome: 'rejected',
|
|
70
|
+
actor: 'cli',
|
|
71
|
+
reason: `unrecognized CLI input: ${input}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
processing = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const unsubscribe = gateway.subscribe((event) => {
|
|
81
|
+
switch (event.type) {
|
|
82
|
+
case 'requested':
|
|
83
|
+
queue.push(event.request);
|
|
84
|
+
void processNext();
|
|
85
|
+
return;
|
|
86
|
+
case 'resolved':
|
|
87
|
+
case 'expired':
|
|
88
|
+
case 'aborted': {
|
|
89
|
+
// Drop from queue if it's still waiting its turn.
|
|
90
|
+
const idx = queue.findIndex((r) => r.id === event.request.id);
|
|
91
|
+
if (idx >= 0) queue.splice(idx, 1);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
detach: () => {
|
|
99
|
+
unsubscribe();
|
|
100
|
+
if (rl) {
|
|
101
|
+
rl.close();
|
|
102
|
+
rl = null;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -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(),
|
|
@@ -1,19 +1,30 @@
|
|
|
1
|
-
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
2
|
-
|
|
3
|
-
export const ExitCodeCompletion: CompletionPlugin = {
|
|
4
|
-
name: 'exit_code',
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
2
|
+
|
|
3
|
+
export const ExitCodeCompletion: CompletionPlugin = {
|
|
4
|
+
name: 'exit_code',
|
|
5
|
+
schema: {
|
|
6
|
+
description: 'Mark the task successful when the exit code matches.',
|
|
7
|
+
fields: {
|
|
8
|
+
expect: {
|
|
9
|
+
type: 'number-or-list',
|
|
10
|
+
default: 0,
|
|
11
|
+
description: 'Expected exit code, or list of acceptable codes (e.g. 0 or [0, 2]).',
|
|
12
|
+
placeholder: '0',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async check(config: Record<string, unknown>, result: TaskResult, _ctx: CompletionContext): Promise<boolean> {
|
|
18
|
+
const expected = config.expect ?? 0;
|
|
19
|
+
|
|
20
|
+
if (typeof expected === 'number') {
|
|
21
|
+
return result.exitCode === expected;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(expected) && expected.every((v) => typeof v === 'number')) {
|
|
24
|
+
return expected.includes(result.exitCode);
|
|
25
|
+
}
|
|
26
|
+
throw new Error(
|
|
27
|
+
`exit_code completion: "expect" must be a number or number[], got ${typeof expected}`
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -1,39 +1,60 @@
|
|
|
1
|
-
import { stat } from 'node:fs/promises';
|
|
2
|
-
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
3
|
-
import { validatePath } from '../utils';
|
|
4
|
-
|
|
5
|
-
type Kind = 'file' | 'dir' | 'any';
|
|
6
|
-
|
|
7
|
-
export const FileExistsCompletion: CompletionPlugin = {
|
|
8
|
-
name: 'file_exists',
|
|
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
|
-
};
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import type { CompletionPlugin, CompletionContext, TaskResult } from '../types';
|
|
3
|
+
import { validatePath } from '../utils';
|
|
4
|
+
|
|
5
|
+
type Kind = 'file' | 'dir' | 'any';
|
|
6
|
+
|
|
7
|
+
export const FileExistsCompletion: CompletionPlugin = {
|
|
8
|
+
name: 'file_exists',
|
|
9
|
+
schema: {
|
|
10
|
+
description: 'Mark the task successful when a target file or directory exists.',
|
|
11
|
+
fields: {
|
|
12
|
+
path: {
|
|
13
|
+
type: 'path',
|
|
14
|
+
required: true,
|
|
15
|
+
description: 'Path to check (relative to workDir or absolute).',
|
|
16
|
+
},
|
|
17
|
+
kind: {
|
|
18
|
+
type: 'enum',
|
|
19
|
+
enum: ['file', 'dir', 'any'],
|
|
20
|
+
default: 'any',
|
|
21
|
+
description: 'Restrict to a file, directory, or accept either.',
|
|
22
|
+
},
|
|
23
|
+
min_size: {
|
|
24
|
+
type: 'number',
|
|
25
|
+
min: 0,
|
|
26
|
+
description: 'Optional minimum size in bytes (files only).',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async check(config: Record<string, unknown>, _result: TaskResult, ctx: CompletionContext): Promise<boolean> {
|
|
32
|
+
const filePath = config.path as string;
|
|
33
|
+
if (!filePath) throw new Error('file_exists completion: "path" is required');
|
|
34
|
+
|
|
35
|
+
const safePath = validatePath(filePath, ctx.workDir);
|
|
36
|
+
|
|
37
|
+
const kind = (config.kind as Kind | undefined) ?? 'any';
|
|
38
|
+
if (kind !== 'file' && kind !== 'dir' && kind !== 'any') {
|
|
39
|
+
throw new Error(`file_exists completion: "kind" must be "file" | "dir" | "any", got "${kind}"`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const minSize = config.min_size;
|
|
43
|
+
if (minSize != null && (typeof minSize !== 'number' || minSize < 0)) {
|
|
44
|
+
throw new Error(`file_exists completion: "min_size" must be a non-negative number`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const st = await stat(safePath);
|
|
49
|
+
if (kind === 'file' && !st.isFile()) return false;
|
|
50
|
+
if (kind === 'dir' && !st.isDirectory()) return false;
|
|
51
|
+
if (typeof minSize === 'number' && st.isFile() && st.size < minSize) return false;
|
|
52
|
+
return true;
|
|
53
|
+
} catch (err: unknown) {
|
|
54
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
55
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return false;
|
|
56
|
+
// Permission / IO errors should surface, not silently mean "missing"
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -5,6 +5,23 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
5
5
|
|
|
6
6
|
export const OutputCheckCompletion: CompletionPlugin = {
|
|
7
7
|
name: 'output_check',
|
|
8
|
+
schema: {
|
|
9
|
+
description: 'Pipe task stdout into a shell command; mark success when that command exits 0.',
|
|
10
|
+
fields: {
|
|
11
|
+
check: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
required: true,
|
|
14
|
+
description: 'Shell command to run. Task stdout is piped to its stdin.',
|
|
15
|
+
placeholder: "grep -q 'PASS'",
|
|
16
|
+
},
|
|
17
|
+
timeout: {
|
|
18
|
+
type: 'duration',
|
|
19
|
+
default: '30s',
|
|
20
|
+
description: 'Maximum time to wait for the check command.',
|
|
21
|
+
placeholder: '30s',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
8
25
|
|
|
9
26
|
async check(config: Record<string, unknown>, result: TaskResult, ctx: CompletionContext): Promise<boolean> {
|
|
10
27
|
const checkCmd = config.check as string;
|