@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/src/logger.ts CHANGED
@@ -1,112 +1,164 @@
1
- import { resolve, dirname } from 'node:path';
2
- import { mkdirSync, appendFileSync, writeFileSync } from 'node:fs';
3
-
4
- /**
5
- * Dual-channel logger.
6
- *
7
- * - `info/warn/error` console AND file (brief, user-visible events)
8
- * - `debug` → file ONLY (verbose diagnostics)
9
- * - `section` file ONLY (visual separators)
10
- * - `quiet` → file ONLY (bulk payload like full stdout dumps)
11
- *
12
- * Log file path: <workDir>/.tagma/logs/<runId>/pipeline.log (one file per pipeline run,
13
- * truncated on construction).
14
- */
15
- export class Logger {
16
- private readonly filePath: string;
17
- private readonly runDir: string;
18
-
19
- constructor(workDir: string, runId: string) {
20
- this.runDir = resolve(workDir, '.tagma', 'logs', runId);
21
- this.filePath = resolve(this.runDir, 'pipeline.log');
22
- mkdirSync(dirname(this.filePath), { recursive: true });
23
- writeFileSync(
24
- this.filePath,
25
- `# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
26
- `# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
27
- `# Work dir: ${workDir}\n\n`,
28
- );
29
- }
30
-
31
- info(prefix: string, message: string): void {
32
- const line = `${timestamp()} ${prefix} ${message}`;
33
- console.log(line);
34
- this.append(line);
35
- }
36
-
37
- warn(prefix: string, message: string): void {
38
- const line = `${timestamp()} ${prefix} WARN: ${message}`;
39
- console.warn(line);
40
- this.append(line);
41
- }
42
-
43
- error(prefix: string, message: string): void {
44
- const line = `${timestamp()} ${prefix} ERROR: ${message}`;
45
- console.error(line);
46
- this.append(line);
47
- }
48
-
49
- /** File-only diagnostic log line. */
50
- debug(prefix: string, message: string): void {
51
- this.append(`${timestamp()} ${prefix} DEBUG: ${message}`);
52
- }
53
-
54
- /** File-only visual separator with title. */
55
- section(title: string): void {
56
- this.append(`\n━━━ ${title} ━━━`);
57
- }
58
-
59
- /** File-only bulk payload (e.g. full stdout / stderr dumps). */
60
- quiet(message: string): void {
61
- this.append(message);
62
- }
63
-
64
- private append(line: string): void {
65
- try {
66
- appendFileSync(this.filePath, line.endsWith('\n') ? line : line + '\n');
67
- } catch {
68
- // Swallow log write failures; engine correctness shouldn't depend on logging.
69
- }
70
- }
71
-
72
- get path(): string {
73
- return this.filePath;
74
- }
75
-
76
- /** Directory that holds all artifacts for this run (pipeline.log, *.stderr, etc.). */
77
- get dir(): string {
78
- return this.runDir;
79
- }
80
- }
81
-
82
- function timestamp(): string {
83
- const d = new Date();
84
- const hh = String(d.getHours()).padStart(2, '0');
85
- const mm = String(d.getMinutes()).padStart(2, '0');
86
- const ss = String(d.getSeconds()).padStart(2, '0');
87
- const ms = String(d.getMilliseconds()).padStart(3, '0');
88
- return `${hh}:${mm}:${ss}.${ms}`;
89
- }
90
-
91
- /** Return the last `n` non-empty lines of `text`, joined with newlines. */
92
- export function tailLines(text: string, n: number): string {
93
- if (!text) return '';
94
- const lines = text.split(/\r?\n/).filter(l => l.length > 0);
95
- return lines.slice(-n).join('\n');
96
- }
97
-
98
- /**
99
- * Truncate a blob to at most `maxBytes` UTF-8 bytes for log embedding,
100
- * appending a marker when truncation occurred.
101
- * Uses TextEncoder so CJK and emoji (multi-byte) characters are counted correctly.
102
- */
103
- export function clip(text: string, maxBytes = 16 * 1024): string {
104
- if (!text) return '';
105
- const encoder = new TextEncoder();
106
- const bytes = encoder.encode(text);
107
- if (bytes.length <= maxBytes) return text;
108
- const omittedBytes = bytes.length - maxBytes;
109
- // TextDecoder handles partial code-point boundaries safely (replacement char insertion)
110
- const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
111
- return truncated + `\n…[truncated ${omittedBytes} bytes]`;
112
- }
1
+ import { resolve, dirname } from 'node:path';
2
+ import { mkdirSync, appendFileSync, writeFileSync } 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
+
47
+ constructor(workDir: string, runId: string, onLine?: LogListener) {
48
+ this.runDir = resolve(workDir, '.tagma', 'logs', runId);
49
+ this.filePath = resolve(this.runDir, 'pipeline.log');
50
+ this.onLine = onLine ?? null;
51
+ mkdirSync(dirname(this.filePath), { recursive: true });
52
+ writeFileSync(
53
+ this.filePath,
54
+ `# Pipeline run ${runId} @ ${new Date().toISOString()}\n` +
55
+ `# Host: ${process.platform} ${process.arch} Bun: ${process.versions.bun ?? 'n/a'}\n` +
56
+ `# Work dir: ${workDir}\n\n`,
57
+ );
58
+ }
59
+
60
+ info(prefix: string, message: string): void {
61
+ const ts = timestamp();
62
+ const line = `${ts} ${prefix} ${message}`;
63
+ console.log(line);
64
+ this.emit('info', ts, line, taskIdFromPrefix(prefix));
65
+ this.append(line);
66
+ }
67
+
68
+ warn(prefix: string, message: string): void {
69
+ const ts = timestamp();
70
+ const line = `${ts} ${prefix} WARN: ${message}`;
71
+ console.warn(line);
72
+ this.emit('warn', ts, line, taskIdFromPrefix(prefix));
73
+ this.append(line);
74
+ }
75
+
76
+ error(prefix: string, message: string): void {
77
+ const ts = timestamp();
78
+ const line = `${ts} ${prefix} ERROR: ${message}`;
79
+ console.error(line);
80
+ this.emit('error', ts, line, taskIdFromPrefix(prefix));
81
+ this.append(line);
82
+ }
83
+
84
+ /** File-only diagnostic log line. */
85
+ debug(prefix: string, message: string): void {
86
+ const ts = timestamp();
87
+ const line = `${ts} ${prefix} DEBUG: ${message}`;
88
+ this.emit('debug', ts, line, taskIdFromPrefix(prefix));
89
+ this.append(line);
90
+ }
91
+
92
+ /** File-only visual separator with title. */
93
+ section(title: string, taskId?: string | null): void {
94
+ const ts = timestamp();
95
+ const text = `\n━━━ ${title} ━━━`;
96
+ this.emit('section', ts, text, taskId ?? null);
97
+ this.append(text);
98
+ }
99
+
100
+ /** File-only bulk payload (e.g. full stdout / stderr dumps). */
101
+ quiet(message: string, taskId?: string | null): void {
102
+ const ts = timestamp();
103
+ this.emit('quiet', ts, message, taskId ?? null);
104
+ this.append(message);
105
+ }
106
+
107
+ private append(line: string): void {
108
+ try {
109
+ appendFileSync(this.filePath, line.endsWith('\n') ? line : line + '\n');
110
+ } catch {
111
+ // Swallow log write failures; engine correctness shouldn't depend on logging.
112
+ }
113
+ }
114
+
115
+ private emit(level: LogLevel, ts: string, text: string, taskId: string | null): void {
116
+ if (!this.onLine) return;
117
+ try {
118
+ this.onLine({ level, taskId, timestamp: ts, text });
119
+ } catch {
120
+ // Never let a listener error derail the pipeline.
121
+ }
122
+ }
123
+
124
+ get path(): string {
125
+ return this.filePath;
126
+ }
127
+
128
+ /** Directory that holds all artifacts for this run (pipeline.log, *.stderr, etc.). */
129
+ get dir(): string {
130
+ return this.runDir;
131
+ }
132
+ }
133
+
134
+ function timestamp(): string {
135
+ const d = new Date();
136
+ const hh = String(d.getHours()).padStart(2, '0');
137
+ const mm = String(d.getMinutes()).padStart(2, '0');
138
+ const ss = String(d.getSeconds()).padStart(2, '0');
139
+ const ms = String(d.getMilliseconds()).padStart(3, '0');
140
+ return `${hh}:${mm}:${ss}.${ms}`;
141
+ }
142
+
143
+ /** Return the last `n` non-empty lines of `text`, joined with newlines. */
144
+ export function tailLines(text: string, n: number): string {
145
+ if (!text) return '';
146
+ const lines = text.split(/\r?\n/).filter(l => l.length > 0);
147
+ return lines.slice(-n).join('\n');
148
+ }
149
+
150
+ /**
151
+ * Truncate a blob to at most `maxBytes` UTF-8 bytes for log embedding,
152
+ * appending a marker when truncation occurred.
153
+ * Uses TextEncoder so CJK and emoji (multi-byte) characters are counted correctly.
154
+ */
155
+ export function clip(text: string, maxBytes = 16 * 1024): string {
156
+ if (!text) return '';
157
+ const encoder = new TextEncoder();
158
+ const bytes = encoder.encode(text);
159
+ if (bytes.length <= maxBytes) return text;
160
+ const omittedBytes = bytes.length - maxBytes;
161
+ // TextDecoder handles partial code-point boundaries safely (replacement char insertion)
162
+ const truncated = new TextDecoder().decode(bytes.slice(0, maxBytes));
163
+ return truncated + `\n…[truncated ${omittedBytes} bytes]`;
164
+ }
@@ -4,6 +4,22 @@ import { validatePath } from '../utils';
4
4
 
5
5
  export const StaticContextMiddleware: MiddlewarePlugin = {
6
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
+ },
7
23
 
8
24
  async enhance(
9
25
  prompt: string,
package/src/sdk.ts CHANGED
@@ -32,6 +32,10 @@ export type { ValidationError } from './validate-raw';
32
32
  // ── Schema: parse / resolve / load / serialize / validate ──
33
33
  export { parseYaml, resolveConfig, expandTemplates, loadPipeline, serializePipeline, deresolvePipeline, validateConfig } from './schema';
34
34
 
35
+ // ── Templates: discovery + manifest loading (F1) ──
36
+ export { discoverTemplates, loadTemplateManifest } from './templates';
37
+ export type { TemplateManifest } from './templates';
38
+
35
39
  // ── DAG ──
36
40
  export { buildDag, buildRawDag } from './dag';
37
41
  export type { DagNode, Dag, RawDagNode, RawDag } from './dag';
@@ -59,6 +63,7 @@ export type { WebSocketApprovalAdapter, WebSocketApprovalAdapterOptions } from '
59
63
 
60
64
  // ── Logger ──
61
65
  export { Logger, tailLines, clip } from './logger';
66
+ export type { LogRecord, LogLevel, LogListener } from './logger';
62
67
 
63
68
  // ── Hook context types (useful for frontend display) ──
64
69
  export type { HookResult, PipelineInfo, TrackInfo, TaskInfo } from './hooks';
@@ -0,0 +1,97 @@
1
+ // ═══ Template Discovery (F1) ═══
2
+ //
3
+ // Public helpers so editors / UIs can enumerate installed `@tagma/template-*`
4
+ // packages in a workspace and read each template's declarative metadata
5
+ // (name, description, params) without actually expanding the template.
6
+ //
7
+ // The legacy private `loadTemplate` in schema.ts uses Bun-specific APIs
8
+ // (Bun.file, require.resolve). These helpers are Node-compatible because
9
+ // the editor server runs on Node, not Bun.
10
+
11
+ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
12
+ import { join } from 'path';
13
+ import yaml from 'js-yaml';
14
+ import type { TemplateConfig } from './types';
15
+
16
+ export interface TemplateManifest extends TemplateConfig {
17
+ /** The package ref as it would appear in `task.use`, e.g. `@tagma/template-review`. */
18
+ readonly ref: string;
19
+ }
20
+
21
+ /**
22
+ * Scan the workspace's `node_modules/@tagma/*` for packages whose name starts
23
+ * with `template-` and load each one's manifest. Packages without a valid
24
+ * `template.yaml` (or that fail to parse) are silently skipped.
25
+ *
26
+ * Returns an empty array when `workDir` doesn't exist or has no such packages.
27
+ */
28
+ export function discoverTemplates(workDir: string): TemplateManifest[] {
29
+ const out: TemplateManifest[] = [];
30
+ const scopeDir = join(workDir, 'node_modules', '@tagma');
31
+ if (!existsSync(scopeDir)) return out;
32
+
33
+ let entries: string[] = [];
34
+ try {
35
+ entries = readdirSync(scopeDir);
36
+ } catch {
37
+ return out;
38
+ }
39
+
40
+ for (const entry of entries) {
41
+ if (!entry.startsWith('template-')) continue;
42
+ const pkgDir = join(scopeDir, entry);
43
+ try {
44
+ const st = statSync(pkgDir);
45
+ if (!st.isDirectory()) continue;
46
+ } catch {
47
+ continue;
48
+ }
49
+
50
+ const ref = `@tagma/${entry}`;
51
+ const manifest = loadTemplateManifestFromDir(pkgDir, ref);
52
+ if (manifest) out.push(manifest);
53
+ }
54
+
55
+ // Sort alphabetically for deterministic UI rendering.
56
+ out.sort((a, b) => a.ref.localeCompare(b.ref));
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Load a single template's manifest by its ref (e.g. `@tagma/template-review`)
62
+ * from the given workspace's `node_modules`. Returns `null` if the package
63
+ * isn't installed or its manifest can't be parsed.
64
+ */
65
+ export function loadTemplateManifest(ref: string, workDir: string): TemplateManifest | null {
66
+ // Only @tagma/template-* refs are supported (matches SDK validateTemplateRef).
67
+ const stripped = ref.replace(/@v\d+$/, '');
68
+ if (!stripped.startsWith('@tagma/template-')) return null;
69
+ const pkgDir = join(workDir, 'node_modules', stripped);
70
+ if (!existsSync(pkgDir)) return null;
71
+ return loadTemplateManifestFromDir(pkgDir, stripped);
72
+ }
73
+
74
+ /**
75
+ * Resolve a template manifest from an absolute package directory. Tries
76
+ * `template.yaml` first (the documented convention), then a `template` export
77
+ * from `package.json`'s `main`. Returns `null` on any failure so discovery
78
+ * stays robust against malformed packages.
79
+ */
80
+ function loadTemplateManifestFromDir(pkgDir: string, ref: string): TemplateManifest | null {
81
+ const yamlPath = join(pkgDir, 'template.yaml');
82
+ if (existsSync(yamlPath)) {
83
+ try {
84
+ const content = readFileSync(yamlPath, 'utf-8');
85
+ const doc = yaml.load(content) as { template?: TemplateConfig } | TemplateConfig;
86
+ const tpl = (doc && typeof doc === 'object' && 'template' in doc
87
+ ? (doc as { template?: TemplateConfig }).template
88
+ : (doc as TemplateConfig)) as TemplateConfig | undefined;
89
+ if (tpl && typeof tpl === 'object' && tpl.name && Array.isArray(tpl.tasks)) {
90
+ return { ...tpl, ref };
91
+ }
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ return null;
97
+ }
@@ -11,6 +11,22 @@ function pathsEqual(a: string, b: string): boolean {
11
11
 
12
12
  export const FileTrigger: TriggerPlugin = {
13
13
  name: 'file',
14
+ schema: {
15
+ description: 'Wait for a file to appear or be modified before the task runs.',
16
+ fields: {
17
+ path: {
18
+ type: 'path',
19
+ required: true,
20
+ description: 'Path to the file to watch (relative to workDir or absolute).',
21
+ placeholder: 'e.g. build/output.json',
22
+ },
23
+ timeout: {
24
+ type: 'duration',
25
+ description: 'Maximum wait time (e.g. 30s, 5m). Omit or 0 to wait indefinitely.',
26
+ placeholder: '30s',
27
+ },
28
+ },
29
+ },
14
30
 
15
31
  watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
16
32
  const filePath = config.path as string;
@@ -1,61 +1,72 @@
1
- import type { TriggerPlugin, TriggerContext } from '../types';
2
- import { parseDuration } from '../utils';
3
-
4
- export const ManualTrigger: TriggerPlugin = {
5
- name: 'manual',
6
-
7
- async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
8
- const message =
9
- (config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
10
- const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
11
- const options = Array.isArray(config.options)
12
- ? (config.options as unknown[]).map(String)
13
- : undefined;
14
- const metadata =
15
- config.metadata && typeof config.metadata === 'object'
16
- ? (config.metadata as Record<string, unknown>)
17
- : undefined;
18
-
19
- const decisionPromise = ctx.approvalGateway.request({
20
- taskId: ctx.taskId,
21
- trackId: ctx.trackId,
22
- message,
23
- options,
24
- timeoutMs,
25
- metadata,
26
- });
27
-
28
- // Wire AbortSignal try to resolve this specific request as aborted.
29
- // We can't directly cancel via the gateway (no id yet at .request() call site),
30
- // so instead we race against an abort promise and let engine status logic
31
- // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
32
- // from engine shutdown path to clean up any truly-pending entries.
33
- const abortPromise = new Promise<never>((_, reject) => {
34
- if (ctx.signal.aborted) {
35
- reject(new Error('Pipeline aborted'));
36
- return;
37
- }
38
- ctx.signal.addEventListener(
39
- 'abort',
40
- () => reject(new Error('Pipeline aborted')),
41
- { once: true },
42
- );
43
- });
44
-
45
- const decision = await Promise.race([decisionPromise, abortPromise]);
46
-
47
- switch (decision.outcome) {
48
- case 'approved':
49
- return { confirmed: true, approvalId: decision.approvalId, choice: decision.choice, actor: decision.actor };
50
- case 'rejected':
51
- throw new Error(
52
- `Manual trigger rejected by ${decision.actor ?? 'user'}` +
53
- (decision.reason ? `: ${decision.reason}` : ''),
54
- );
55
- case 'timeout':
56
- throw new Error(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
57
- case 'aborted':
58
- throw new Error(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
59
- }
60
- },
61
- };
1
+ import type { TriggerPlugin, TriggerContext } from '../types';
2
+ import { parseDuration } from '../utils';
3
+
4
+ export const ManualTrigger: TriggerPlugin = {
5
+ name: 'manual',
6
+ schema: {
7
+ description: 'Pause the task until a user approves via the approval gateway.',
8
+ fields: {
9
+ message: {
10
+ type: 'string',
11
+ description: 'Prompt shown to the approver. Defaults to a generic message if empty.',
12
+ placeholder: 'Confirm deployment to production?',
13
+ },
14
+ timeout: {
15
+ type: 'duration',
16
+ description: 'Maximum wait time (e.g. 10m). Omit or 0 to wait indefinitely.',
17
+ placeholder: '10m',
18
+ },
19
+ },
20
+ },
21
+
22
+ async watch(config: Record<string, unknown>, ctx: TriggerContext): Promise<unknown> {
23
+ const message =
24
+ (config.message as string | undefined) ?? `Manual confirmation required for task "${ctx.taskId}"`;
25
+ const timeoutMs = config.timeout ? parseDuration(config.timeout as string) : 0;
26
+ const metadata =
27
+ config.metadata && typeof config.metadata === 'object'
28
+ ? (config.metadata as Record<string, unknown>)
29
+ : undefined;
30
+
31
+ const decisionPromise = ctx.approvalGateway.request({
32
+ taskId: ctx.taskId,
33
+ trackId: ctx.trackId,
34
+ message,
35
+ timeoutMs,
36
+ metadata,
37
+ });
38
+
39
+ // Wire AbortSignal → try to resolve this specific request as aborted.
40
+ // We can't directly cancel via the gateway (no id yet at .request() call site),
41
+ // so instead we race against an abort promise and let engine status logic
42
+ // fall back to pipelineAborted → skipped. abortAll() on gateway still runs
43
+ // from engine shutdown path to clean up any truly-pending entries.
44
+ const abortPromise = new Promise<never>((_, reject) => {
45
+ if (ctx.signal.aborted) {
46
+ reject(new Error('Pipeline aborted'));
47
+ return;
48
+ }
49
+ ctx.signal.addEventListener(
50
+ 'abort',
51
+ () => reject(new Error('Pipeline aborted')),
52
+ { once: true },
53
+ );
54
+ });
55
+
56
+ const decision = await Promise.race([decisionPromise, abortPromise]);
57
+
58
+ switch (decision.outcome) {
59
+ case 'approved':
60
+ return { confirmed: true, approvalId: decision.approvalId, actor: decision.actor };
61
+ case 'rejected':
62
+ throw new Error(
63
+ `Manual trigger rejected by ${decision.actor ?? 'user'}` +
64
+ (decision.reason ? `: ${decision.reason}` : ''),
65
+ );
66
+ case 'timeout':
67
+ throw new Error(`Manual trigger timeout: ${decision.reason ?? 'no decision made'}`);
68
+ case 'aborted':
69
+ throw new Error(`Manual trigger aborted: ${decision.reason ?? 'pipeline aborted'}`);
70
+ }
71
+ },
72
+ };