@intentius/chant 0.1.6 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -101,6 +101,23 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
101
101
  }
102
102
  }
103
103
 
104
+ // Empty-output guard: source files were discovered but no lexicon produced
105
+ // any output. Almost always indicates broken imports resolving to undefined
106
+ // (e.g. missing root re-exports from a lexicon) or modules that exported no
107
+ // Declarables. Without this guard, chant writes "{}" and exits 0.
108
+ if (
109
+ result.sourceFileCount > 0 &&
110
+ result.outputs.size === 0 &&
111
+ result.errors.length === 0 &&
112
+ errors.length === 0
113
+ ) {
114
+ errors.push(
115
+ formatError({
116
+ message: `Discovered ${result.sourceFileCount} source file(s) but produced no output. Likely causes: imports resolving to undefined (missing exports from a lexicon root), or no Declarables/Composites exported. Check that imported names exist in the target package.`,
117
+ }),
118
+ );
119
+ }
120
+
104
121
  // Handle output
105
122
  if (result.errors.length === 0 && errors.length === 0) {
106
123
  // Extract primary content and collect additional files from SerializerResult
@@ -0,0 +1,23 @@
1
+ import { discoverOps } from "../../op/discover";
2
+ import { formatError } from "../format";
3
+ import type { CommandContext } from "../registry";
4
+
5
+ export async function runGraph(_ctx: CommandContext): Promise<number> {
6
+ const { ops, errors } = await discoverOps();
7
+ for (const err of errors) console.error(formatError({ message: err }));
8
+
9
+ if (ops.size === 0) {
10
+ console.log("No Ops found");
11
+ return 0;
12
+ }
13
+
14
+ let hasEdges = false;
15
+ for (const [name, { config }] of ops) {
16
+ for (const dep of config.depends ?? []) {
17
+ console.log(`${dep} → ${name}`);
18
+ hasEdges = true;
19
+ }
20
+ }
21
+ if (!hasEdges) console.log("No Op dependencies");
22
+ return 0;
23
+ }
@@ -0,0 +1,134 @@
1
+ /** Subset of WorkerProfile fields used by `chant run`. */
2
+ export interface WorkerProfile {
3
+ address: string;
4
+ namespace: string;
5
+ taskQueue: string;
6
+ tls?: boolean | { serverNameOverride?: string };
7
+ apiKey?: string | { env: string };
8
+ autoStart?: boolean;
9
+ }
10
+
11
+ export interface TemporalClientModule {
12
+ Connection: {
13
+ connect(opts: Record<string, unknown>): Promise<unknown>;
14
+ };
15
+ Client: new (opts: Record<string, unknown>) => TemporalClientHandle;
16
+ }
17
+
18
+ export interface TemporalClientHandle {
19
+ workflow: {
20
+ start(workflowFn: unknown, opts: Record<string, unknown>): Promise<WorkflowHandleRaw>;
21
+ getHandle(workflowId: string): WorkflowHandleRaw;
22
+ list(opts?: Record<string, unknown>): AsyncIterable<WorkflowExecutionInfo>;
23
+ };
24
+ }
25
+
26
+ export interface WorkflowHandleRaw {
27
+ workflowId: string;
28
+ firstExecutionRunId?: string;
29
+ result(): Promise<unknown>;
30
+ describe(): Promise<WorkflowExecutionDescription>;
31
+ fetchHistory(): Promise<WorkflowHistoryRaw>;
32
+ signal(signalName: string): Promise<void>;
33
+ cancel(): Promise<void>;
34
+ }
35
+
36
+ export interface WorkflowExecutionDescription {
37
+ workflowId: string;
38
+ runId: string;
39
+ status: { name: string };
40
+ startTime: Date;
41
+ closeTime?: Date;
42
+ taskQueue: string;
43
+ type: { name: string };
44
+ }
45
+
46
+ export interface WorkflowExecutionInfo {
47
+ workflowId: string;
48
+ runId: string;
49
+ type: { name: string };
50
+ status: { name: string };
51
+ startTime: Date;
52
+ closeTime?: Date;
53
+ }
54
+
55
+ export interface WorkflowHistoryRaw {
56
+ events?: HistoryEvent[];
57
+ }
58
+
59
+ export interface HistoryEvent {
60
+ eventType?: string;
61
+ eventTime?: Date;
62
+ activityTaskCompletedEventAttributes?: { scheduledEventId?: string | number };
63
+ activityTaskScheduledEventAttributes?: { activityId?: string; activityType?: { name?: string } };
64
+ activityTaskFailedEventAttributes?: { failure?: { message?: string } };
65
+ workflowExecutionCompletedEventAttributes?: unknown;
66
+ workflowExecutionFailedEventAttributes?: { failure?: { message?: string } };
67
+ }
68
+
69
+ /**
70
+ * Dynamically import @temporalio/client from the user's project node_modules.
71
+ * Fails with a helpful message if not installed.
72
+ */
73
+ export async function loadTemporalClient(): Promise<TemporalClientModule> {
74
+ try {
75
+ // Use variable to prevent tsc from statically resolving the optional dep
76
+ const mod = "@temporalio/client";
77
+ return await import(mod) as unknown as TemporalClientModule;
78
+ } catch {
79
+ throw new Error(
80
+ '@temporalio/client is not installed. Run: npm install @temporalio/client',
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Build a Temporal Connection.connect() options object from a worker profile.
87
+ */
88
+ export function connectionOptions(profile: WorkerProfile): Record<string, unknown> {
89
+ const apiKey =
90
+ typeof profile.apiKey === "object" && profile.apiKey !== null
91
+ ? process.env[(profile.apiKey as { env: string }).env]
92
+ : (profile.apiKey as string | undefined);
93
+
94
+ return {
95
+ address: profile.address,
96
+ ...(profile.tls && {
97
+ tls: typeof profile.tls === "object" ? profile.tls : {},
98
+ metadata: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
99
+ }),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Deterministic workflow ID for an Op — allows status/signal/cancel/log
105
+ * without storing run IDs locally.
106
+ */
107
+ export function resolveWorkflowId(opName: string): string {
108
+ return `chant-op-${opName}`;
109
+ }
110
+
111
+ /**
112
+ * Resolve a named profile from the chant config.
113
+ * Falls back to defaultProfile then "local".
114
+ */
115
+ export function resolveProfile(
116
+ config: Record<string, unknown>,
117
+ profileName?: string,
118
+ ): WorkerProfile {
119
+ const temporal = config.temporal as Record<string, unknown> | undefined;
120
+ if (!temporal?.profiles) {
121
+ throw new Error(
122
+ 'No temporal.profiles found in chant.config.ts. Add a profile to use `chant run`.',
123
+ );
124
+ }
125
+ const profiles = temporal.profiles as Record<string, WorkerProfile>;
126
+ const name = profileName ?? (temporal.defaultProfile as string | undefined) ?? "local";
127
+ const profile = profiles[name];
128
+ if (!profile) {
129
+ throw new Error(
130
+ `Temporal profile "${name}" not found. Available: ${Object.keys(profiles).join(", ")}`,
131
+ );
132
+ }
133
+ return profile;
134
+ }
@@ -0,0 +1,160 @@
1
+ import { writeFileSync, mkdirSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import type { OpConfig } from "../../op/types";
4
+ import type { WorkflowExecutionDescription, WorkflowHistoryRaw, HistoryEvent } from "./run-client";
5
+
6
+ interface ActivityRecord {
7
+ name: string;
8
+ startTime?: Date;
9
+ endTime?: Date;
10
+ durationMs?: number;
11
+ status: "completed" | "failed" | "running";
12
+ error?: string;
13
+ }
14
+
15
+ interface PhaseRecord {
16
+ name: string;
17
+ activities: ActivityRecord[];
18
+ }
19
+
20
+ function formatDuration(ms: number): string {
21
+ if (ms < 1000) return `${ms}ms`;
22
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
23
+ const mins = Math.floor(ms / 60_000);
24
+ const secs = Math.round((ms % 60_000) / 1000);
25
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
26
+ }
27
+
28
+ function extractPhaseRecords(config: OpConfig, history: WorkflowHistoryRaw): PhaseRecord[] {
29
+ const events = history.events ?? [];
30
+
31
+ // Build map: scheduled event ID → activity name
32
+ const scheduledActivities = new Map<string, string>();
33
+ const scheduledTimes = new Map<string, Date>();
34
+ const completedTimes = new Map<string, Date>();
35
+ const failedActivities = new Map<string, string>();
36
+
37
+ for (const event of events) {
38
+ if (event.eventType === "ActivityTaskScheduled" && event.activityTaskScheduledEventAttributes) {
39
+ const attrs = event.activityTaskScheduledEventAttributes;
40
+ const id = String(attrs.activityId ?? "");
41
+ const name = attrs.activityType?.name ?? "unknown";
42
+ scheduledActivities.set(id, name);
43
+ if (event.eventTime) scheduledTimes.set(id, new Date(event.eventTime));
44
+ }
45
+ if (event.eventType === "ActivityTaskCompleted" && event.activityTaskCompletedEventAttributes) {
46
+ const scheduledId = String(event.activityTaskCompletedEventAttributes.scheduledEventId ?? "");
47
+ if (event.eventTime) completedTimes.set(scheduledId, new Date(event.eventTime));
48
+ }
49
+ if (event.eventType === "ActivityTaskFailed" && event.activityTaskFailedEventAttributes) {
50
+ const msg = event.activityTaskFailedEventAttributes.failure?.message ?? "unknown error";
51
+ failedActivities.set(String(events.indexOf(event)), msg);
52
+ }
53
+ }
54
+
55
+ return config.phases.map((phase) => {
56
+ const activities: ActivityRecord[] = phase.steps
57
+ .filter((s) => s.kind === "activity")
58
+ .map((step) => {
59
+ if (step.kind !== "activity") return null;
60
+ const fn = step.fn;
61
+ // Find this activity by name in the history
62
+ let record: ActivityRecord | null = null;
63
+ for (const [id, name] of scheduledActivities) {
64
+ if (name === fn) {
65
+ const start = scheduledTimes.get(id);
66
+ const end = completedTimes.get(id);
67
+ const durationMs = start && end ? end.getTime() - start.getTime() : undefined;
68
+ record = {
69
+ name: fn,
70
+ startTime: start,
71
+ endTime: end,
72
+ durationMs,
73
+ status: end ? "completed" : "running",
74
+ };
75
+ }
76
+ }
77
+ return record ?? { name: fn, status: "running" };
78
+ })
79
+ .filter((r): r is ActivityRecord => r !== null);
80
+
81
+ return { name: phase.name, activities };
82
+ });
83
+ }
84
+
85
+ export function generateReport(
86
+ opName: string,
87
+ config: OpConfig,
88
+ description: WorkflowExecutionDescription,
89
+ history: WorkflowHistoryRaw,
90
+ ): string {
91
+ const lines: string[] = [];
92
+
93
+ const status = description.status.name;
94
+ const startTime = description.startTime;
95
+ const closeTime = description.closeTime;
96
+ const durationMs = closeTime ? closeTime.getTime() - startTime.getTime() : undefined;
97
+
98
+ lines.push(`# Deployment Report: ${opName}`);
99
+ lines.push("");
100
+ lines.push("## Overview");
101
+ lines.push("");
102
+ lines.push(`| Field | Value |`);
103
+ lines.push(`|---|---|`);
104
+ lines.push(`| Op | ${opName} |`);
105
+ lines.push(`| Overview | ${config.overview} |`);
106
+ lines.push(`| Status | **${status}** |`);
107
+ lines.push(`| Workflow ID | ${description.workflowId} |`);
108
+ lines.push(`| Run ID | ${description.runId} |`);
109
+ lines.push(`| Start | ${startTime.toISOString()} |`);
110
+ if (closeTime) lines.push(`| End | ${closeTime.toISOString()} |`);
111
+ if (durationMs !== undefined) lines.push(`| Duration | ${formatDuration(durationMs)} |`);
112
+ lines.push("");
113
+
114
+ lines.push("## Timeline");
115
+ lines.push("");
116
+ lines.push("| Phase | Activity | Duration | Status |");
117
+ lines.push("|---|---|---|---|");
118
+
119
+ const phases = extractPhaseRecords(config, history);
120
+ for (const phase of phases) {
121
+ if (phase.activities.length === 0) {
122
+ lines.push(`| ${phase.name} | — | — | — |`);
123
+ }
124
+ for (const act of phase.activities) {
125
+ const dur = act.durationMs !== undefined ? formatDuration(act.durationMs) : "—";
126
+ const statusEmoji = act.status === "completed" ? "✓" : act.status === "failed" ? "✗" : "…";
127
+ lines.push(`| ${phase.name} | ${act.name} | ${dur} | ${statusEmoji} ${act.status} |`);
128
+ }
129
+ }
130
+ lines.push("");
131
+
132
+ // Errors section
133
+ const failedEvents = (history.events ?? []).filter(
134
+ (e): e is HistoryEvent & Required<Pick<HistoryEvent, "activityTaskFailedEventAttributes">> =>
135
+ e.eventType === "ActivityTaskFailed" && !!e.activityTaskFailedEventAttributes,
136
+ );
137
+
138
+ if (failedEvents.length > 0) {
139
+ lines.push("## Errors");
140
+ lines.push("");
141
+ for (const event of failedEvents) {
142
+ const msg = event.activityTaskFailedEventAttributes?.failure?.message ?? "unknown";
143
+ lines.push(`- ${msg}`);
144
+ }
145
+ lines.push("");
146
+ }
147
+
148
+ lines.push(`---`);
149
+ lines.push(`*Generated by chant at ${new Date().toISOString()}*`);
150
+ lines.push("");
151
+
152
+ return lines.join("\n");
153
+ }
154
+
155
+ export function writeReport(opName: string, markdown: string): string {
156
+ const outPath = join("dist", `${opName}-report.md`);
157
+ mkdirSync(dirname(outPath), { recursive: true });
158
+ writeFileSync(outPath, markdown, "utf-8");
159
+ return outPath;
160
+ }