stageflow 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/agent/claudeAdapter.js +6 -0
  2. package/dist/agent/piAdapter.js +9 -0
  3. package/dist/agent/port.d.ts +2 -0
  4. package/dist/channels/config.d.ts +11 -0
  5. package/dist/channels/config.js +95 -0
  6. package/dist/channels/configWriter.d.ts +40 -0
  7. package/dist/channels/configWriter.js +148 -0
  8. package/dist/channels/connectionEdits.d.ts +34 -0
  9. package/dist/channels/connectionEdits.js +53 -0
  10. package/dist/channels/errorInfo.d.ts +18 -0
  11. package/dist/channels/errorInfo.js +71 -0
  12. package/dist/channels/errors.d.ts +12 -0
  13. package/dist/channels/errors.js +13 -0
  14. package/dist/channels/health.d.ts +41 -0
  15. package/dist/channels/health.js +99 -0
  16. package/dist/channels/host.d.ts +55 -0
  17. package/dist/channels/host.js +185 -0
  18. package/dist/channels/http.d.ts +31 -0
  19. package/dist/channels/http.js +79 -0
  20. package/dist/channels/index.d.ts +10 -0
  21. package/dist/channels/index.js +9 -0
  22. package/dist/channels/ledger.d.ts +37 -0
  23. package/dist/channels/ledger.js +95 -0
  24. package/dist/channels/providers/agentmail.d.ts +26 -0
  25. package/dist/channels/providers/agentmail.js +81 -0
  26. package/dist/channels/providers/kit.d.ts +40 -0
  27. package/dist/channels/providers/kit.js +78 -0
  28. package/dist/channels/providers/photonImessage.d.ts +45 -0
  29. package/dist/channels/providers/photonImessage.js +152 -0
  30. package/dist/channels/providers/photonWhatsapp.d.ts +49 -0
  31. package/dist/channels/providers/photonWhatsapp.js +146 -0
  32. package/dist/channels/providers/slack.d.ts +25 -0
  33. package/dist/channels/providers/slack.js +101 -0
  34. package/dist/channels/providers/telegram.d.ts +21 -0
  35. package/dist/channels/providers/telegram.js +100 -0
  36. package/dist/channels/providers.d.ts +5 -0
  37. package/dist/channels/providers.js +22 -0
  38. package/dist/channels/registry.d.ts +54 -0
  39. package/dist/channels/registry.js +186 -0
  40. package/dist/channels/secretStore.d.ts +21 -0
  41. package/dist/channels/secretStore.js +75 -0
  42. package/dist/channels/secrets.d.ts +15 -0
  43. package/dist/channels/secrets.js +50 -0
  44. package/dist/channels/service.d.ts +54 -0
  45. package/dist/channels/service.js +307 -0
  46. package/dist/channels/stageChannels.d.ts +45 -0
  47. package/dist/channels/stageChannels.js +110 -0
  48. package/dist/channels/types.d.ts +142 -0
  49. package/dist/channels/types.js +1 -0
  50. package/dist/cli/doctorCommand.js +3 -1
  51. package/dist/config/draftPackage.d.ts +1 -0
  52. package/dist/config/draftPackage.js +4 -0
  53. package/dist/config/hostConfig.js +1 -0
  54. package/dist/config/loadPipeline.js +27 -3
  55. package/dist/config/loadStage.js +15 -0
  56. package/dist/config/mergePipelineIncludes.d.ts +3 -0
  57. package/dist/config/mergePipelineIncludes.js +27 -0
  58. package/dist/config/pipelineStageKeys.js +1 -0
  59. package/dist/config/validateCatalog.d.ts +4 -1
  60. package/dist/config/validateCatalog.js +23 -0
  61. package/dist/errors/codes.d.ts +1 -1
  62. package/dist/errors/codes.js +1 -0
  63. package/dist/mcp/catalogTools.js +1 -0
  64. package/dist/package-meta.d.ts +1 -1
  65. package/dist/package-meta.js +1 -1
  66. package/dist/preflight/channelChecks.d.ts +17 -0
  67. package/dist/preflight/channelChecks.js +106 -0
  68. package/dist/preflight/pipelinePreflight.d.ts +9 -2
  69. package/dist/preflight/pipelinePreflight.js +9 -1
  70. package/dist/runtime/pipelineRunner.js +3 -1
  71. package/dist/runtime/repositoryMaterialize.d.ts +1 -1
  72. package/dist/runtime/runManager.js +4 -1
  73. package/dist/runtime/stageAttemptBootstrap.js +16 -0
  74. package/dist/runtime/stageProcessLauncher.js +31 -1
  75. package/dist/server/bootstrap.d.ts +3 -0
  76. package/dist/server/bootstrap.js +4 -0
  77. package/dist/server/channelRoutes.d.ts +9 -0
  78. package/dist/server/channelRoutes.js +88 -0
  79. package/dist/server/http.js +11 -0
  80. package/dist/tools/readEmail.d.ts +3 -3
  81. package/dist/tools/sendMessage.d.ts +21 -0
  82. package/dist/tools/sendMessage.js +25 -0
  83. package/dist/types/pipeline.d.ts +4 -1
  84. package/dist/types/stage.d.ts +13 -0
  85. package/dist/ui/assets/index-BOhqnPNg.js +260 -0
  86. package/dist/ui/assets/{index-CIPbbBUi.css → index-Clv4sJpU.css} +1 -1
  87. package/dist/ui/index.html +2 -2
  88. package/examples/channels-smoke/README.md +12 -0
  89. package/examples/channels-smoke/channels-smoke.pipeline.yaml +11 -0
  90. package/examples/channels-smoke/channels-smoke.task.yaml +3 -0
  91. package/examples/channels-smoke/send-email.yaml +32 -0
  92. package/examples/channels-smoke/send-text.yaml +31 -0
  93. package/package.json +8 -3
  94. package/dist/ui/assets/index-BoFPkKFg.js +0 -256
@@ -0,0 +1,54 @@
1
+ import { type ChannelErrorCode } from "./errors.js";
2
+ import { type ChannelHealthOptions } from "./health.js";
3
+ import type { ConnectionHealth, ConnectionSummary, ProviderMeta } from "./types.js";
4
+ export type ChannelListing = {
5
+ connections: Array<ConnectionSummary & {
6
+ health?: ConnectionHealth;
7
+ }>;
8
+ /** Set when `connections.yaml` could not be loaded. The file's content is never included. */
9
+ error?: ChannelErrorCode;
10
+ limits: {
11
+ channelsPerStage: number;
12
+ };
13
+ };
14
+ export type ChannelMeta = {
15
+ providers: Array<ProviderMeta & {
16
+ id: string;
17
+ }>;
18
+ errors: Record<string, {
19
+ reason: string;
20
+ hint?: string;
21
+ }>;
22
+ };
23
+ export type ChannelService = {
24
+ /** What the console shows about each provider and each error code. Static; needs no secrets. */
25
+ meta(): ChannelMeta;
26
+ list(): ChannelListing;
27
+ /** Runs one live check now. Returns undefined when the connection is unknown or disabled. */
28
+ checkNow(id: string): Promise<ConnectionHealth | undefined>;
29
+ /** Checks entered values without saving anything. */
30
+ test(input: unknown): Promise<ConnectionHealth>;
31
+ /** Static check of a pinned recipient for a connection. No network, no secret read. */
32
+ validateTarget(id: string, input: unknown): {
33
+ ok: true;
34
+ } | {
35
+ ok: false;
36
+ issues: string[];
37
+ };
38
+ create(input: unknown): Promise<{
39
+ id: string;
40
+ health: ConnectionHealth;
41
+ }>;
42
+ update(id: string, input: unknown): Promise<{
43
+ health?: ConnectionHealth;
44
+ }>;
45
+ remove(id: string): Promise<void>;
46
+ stop(): Promise<void>;
47
+ };
48
+ /**
49
+ * Host-side read and edit model for the Connections page. A `connections.yaml` that fails to load
50
+ * does not stop the host: the listing reports the error code and stages that use channels fail on
51
+ * their own, while every other stage is unaffected. Edits keep secrets out of the YAML: each value
52
+ * goes to the secret store and the file gets a `store:NAME` reference.
53
+ */
54
+ export declare function startChannelService(cwd: string, options?: Pick<ChannelHealthOptions, "intervalMs" | "timeoutMs">): ChannelService;
@@ -0,0 +1,307 @@
1
+ import { z } from "zod";
2
+ import { parseConnectionsYaml } from "./config.js";
3
+ import { addConnectionYaml, removeConnectionYaml, toYamlEntry, updateConnectionYaml } from "./configWriter.js";
4
+ import { ConnectionEdits } from "./connectionEdits.js";
5
+ import { CHANNEL_ERROR_INFO } from "./errorInfo.js";
6
+ import { ChannelError } from "./errors.js";
7
+ import { channelProviders } from "./providers.js";
8
+ import { ChannelHealthMonitor } from "./health.js";
9
+ import { channelHostFor, releaseChannelHost, reloadChannelHosts } from "./host.js";
10
+ import { ChannelRegistry, connectionsFilePath, defaultSecretStore, sealed } from "./registry.js";
11
+ import { STORE_NAME_PATTERN } from "./secretStore.js";
12
+ import { stageChannelLimit } from "./stageChannels.js";
13
+ const idSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/);
14
+ const fieldSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/);
15
+ const valueSchema = z.string().min(1).max(8192).regex(/^[^\r\n\0]+$/);
16
+ const secretsInput = z.record(fieldSchema, valueSchema);
17
+ const createSchema = z.object({
18
+ provider: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/),
19
+ id: idSchema.optional(),
20
+ displayName: z.string().min(1).max(200).optional(),
21
+ config: z.record(z.string(), z.unknown()).default({}),
22
+ secrets: secretsInput.default({}),
23
+ credential: z.union([idSchema, z.object({ id: idSchema.optional(), secrets: secretsInput })]).optional(),
24
+ }).strict();
25
+ const targetText = z.union([z.string().min(1).max(500), z.number().int()]);
26
+ const targetSchema = z.object({
27
+ to: z.union([targetText, z.array(targetText).min(1).max(20)]),
28
+ cc: z.array(targetText).min(1).max(20).optional(),
29
+ template: z.record(z.string(), z.unknown()).optional(),
30
+ }).strict().transform(value => ({
31
+ ...value,
32
+ to: Array.isArray(value.to) ? value.to.map(String) : String(value.to),
33
+ ...(value.cc ? { cc: value.cc.map(String) } : {}),
34
+ }));
35
+ const updateSchema = z.object({
36
+ displayName: z.string().max(200).nullable().optional(),
37
+ enabled: z.boolean().optional(),
38
+ config: z.record(z.string(), z.unknown()).optional(),
39
+ secrets: secretsInput.optional(),
40
+ }).strict();
41
+ const limits = () => ({ channelsPerStage: stageChannelLimit() });
42
+ const invalid = (field) => new ChannelError("CHANNEL_INVALID_INPUT", false, field ? { field } : undefined);
43
+ /**
44
+ * Host-side read and edit model for the Connections page. A `connections.yaml` that fails to load
45
+ * does not stop the host: the listing reports the error code and stages that use channels fail on
46
+ * their own, while every other stage is unaffected. Edits keep secrets out of the YAML: each value
47
+ * goes to the secret store and the file gets a `store:NAME` reference.
48
+ */
49
+ export function startChannelService(cwd, options = {}) {
50
+ const file = () => connectionsFilePath(cwd);
51
+ const store = defaultSecretStore();
52
+ let host;
53
+ let monitor;
54
+ let error;
55
+ let chain = Promise.resolve();
56
+ const serialized = (work) => {
57
+ const next = chain.then(work, work);
58
+ chain = next.catch(() => undefined);
59
+ return next;
60
+ };
61
+ const startMonitor = () => {
62
+ monitor = host ? new ChannelHealthMonitor({ registry: host.registry, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs }) : undefined;
63
+ monitor?.start();
64
+ };
65
+ const load = () => {
66
+ error = undefined;
67
+ try {
68
+ host = channelHostFor(cwd);
69
+ }
70
+ catch (caught) {
71
+ host = undefined;
72
+ error = caught instanceof ChannelError ? caught.code : "CHANNEL_INVALID_INPUT";
73
+ }
74
+ startMonitor();
75
+ };
76
+ const reload = async () => {
77
+ await monitor?.stop();
78
+ reloadChannelHosts();
79
+ if (host) {
80
+ try {
81
+ host.reload();
82
+ }
83
+ catch (caught) {
84
+ error = caught instanceof ChannelError ? caught.code : "CHANNEL_INVALID_INPUT";
85
+ monitor = undefined;
86
+ return;
87
+ }
88
+ error = undefined;
89
+ startMonitor();
90
+ }
91
+ else {
92
+ load();
93
+ }
94
+ };
95
+ load();
96
+ const requireHost = () => {
97
+ if (!host)
98
+ throw new ChannelError(error ?? "CHANNEL_STORAGE_FAILED");
99
+ return host;
100
+ };
101
+ /** Validates the edit against the real provider rules before anything is written. */
102
+ const validate = (text) => {
103
+ const parsed = parseConnectionsYaml(text);
104
+ new ChannelRegistry({ cwd, parsed, secretStore: store, providers: requireHost().registry.providerList() });
105
+ };
106
+ const edits = new ConnectionEdits({ file, store, validate });
107
+ const uniqueId = (base, taken) => {
108
+ if (!taken(base))
109
+ return base;
110
+ for (let n = 2; n < 1000; n++)
111
+ if (!taken(`${base}-${n}`))
112
+ return `${base}-${n}`;
113
+ throw invalid("id");
114
+ };
115
+ const secretRefs = (prefix, secrets) => {
116
+ const refs = {};
117
+ for (const field of Object.keys(secrets)) {
118
+ const name = `${prefix}.${field}`;
119
+ if (!STORE_NAME_PATTERN.test(name))
120
+ throw invalid(field);
121
+ refs[field] = `store:${name}`;
122
+ }
123
+ return refs;
124
+ };
125
+ const draft = (input, id) => {
126
+ const registry = requireHost().registry;
127
+ const provider = registry.providerById(input.provider);
128
+ const config = provider.validateConfig(input.config);
129
+ if (!config.ok)
130
+ throw invalid("config");
131
+ const allowed = new Set([...provider.secrets.required, ...(provider.secrets.optional ?? [])]);
132
+ for (const field of Object.keys(input.secrets))
133
+ if (!allowed.has(field))
134
+ throw invalid(field);
135
+ for (const field of provider.secrets.required)
136
+ if (!(field in input.secrets))
137
+ throw invalid(field);
138
+ let credential;
139
+ if (provider.credentialProvider) {
140
+ if (typeof input.credential === "string")
141
+ credential = registry.resolveCredential(input.credential);
142
+ else if (input.credential)
143
+ credential = { id: input.credential.id ?? "draft", provider: provider.credentialProvider, secrets: input.credential.secrets };
144
+ else
145
+ throw invalid("credential");
146
+ }
147
+ else if (input.credential) {
148
+ throw invalid("credential");
149
+ }
150
+ return sealed({
151
+ id, scope: registry.scope, provider: input.provider, displayName: input.displayName ?? id,
152
+ config: config.value, secrets: input.secrets, ...(credential ? { credential } : {}),
153
+ });
154
+ };
155
+ const parseCreate = (input) => {
156
+ const parsed = createSchema.safeParse(input);
157
+ if (!parsed.success)
158
+ throw invalid();
159
+ return parsed.data;
160
+ };
161
+ return {
162
+ meta() {
163
+ const providers = host ? host.registry.providerList() : channelProviders();
164
+ return {
165
+ providers: providers.map(provider => ({ id: provider.id, ...provider.meta })),
166
+ errors: Object.fromEntries(Object.entries(CHANNEL_ERROR_INFO).map(([code, info]) => [code, { reason: info.reason, ...(info.hint ? { hint: info.hint } : {}) }])),
167
+ };
168
+ },
169
+ list() {
170
+ if (!host)
171
+ return { connections: [], ...(error ? { error } : {}), limits: limits() };
172
+ const health = monitor?.snapshot() ?? {};
173
+ return {
174
+ connections: host.registry.list().map(summary => ({ ...summary, ...(health[summary.id] ? { health: health[summary.id] } : {}) })),
175
+ ...(error ? { error } : {}),
176
+ limits: limits(),
177
+ };
178
+ },
179
+ async checkNow(id) {
180
+ if (!host || !monitor)
181
+ return undefined;
182
+ try {
183
+ host.registry.get(id);
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ return monitor.checkNow(id);
189
+ },
190
+ validateTarget(id, input) {
191
+ const parsed = targetSchema.safeParse(input);
192
+ if (!parsed.success)
193
+ return { ok: false, issues: ["target"] };
194
+ const provider = requireHost().registry.providerFor(id);
195
+ const result = provider.validateTarget(parsed.data);
196
+ return result.ok ? { ok: true } : { ok: false, issues: result.issues };
197
+ },
198
+ async test(input) {
199
+ const parsed = parseCreate(input);
200
+ const connection = draft(parsed, parsed.id ?? "draft");
201
+ const provider = requireHost().registry.providerById(parsed.provider);
202
+ const checkedAt = new Date().toISOString();
203
+ try {
204
+ return await provider.check(connection, AbortSignal.timeout(10000));
205
+ }
206
+ catch (caught) {
207
+ const failure = caught instanceof ChannelError ? caught : new ChannelError("CHANNEL_CONNECTION_FAILED", true);
208
+ return { state: "failed", checkedAt, error: { code: failure.code, retryable: failure.retryable } };
209
+ }
210
+ },
211
+ create(input) {
212
+ return serialized(async () => {
213
+ const parsed = parseCreate(input);
214
+ const registry = requireHost().registry;
215
+ const provider = registry.providerById(parsed.provider);
216
+ const id = parsed.id ?? uniqueId(parsed.provider, candidate => registry.hasConnection(candidate));
217
+ if (registry.hasConnection(id))
218
+ throw new ChannelError("CHANNEL_OPERATION_CONFLICT", false, { connectionId: id });
219
+ draft(parsed, id);
220
+ const secrets = {};
221
+ let credentialYaml;
222
+ let credentialId;
223
+ if (provider.credentialProvider) {
224
+ if (typeof parsed.credential === "string") {
225
+ credentialId = parsed.credential;
226
+ }
227
+ else if (parsed.credential) {
228
+ credentialId = parsed.credential.id ?? uniqueId(`${provider.credentialProvider}-main`, candidate => registry.hasCredential(candidate));
229
+ credentialYaml = { id: credentialId, provider: provider.credentialProvider, secrets: secretRefs(`credential.${credentialId}`, parsed.credential.secrets) };
230
+ for (const [field, value] of Object.entries(parsed.credential.secrets))
231
+ secrets[`credential.${credentialId}.${field}`] = value;
232
+ }
233
+ }
234
+ const refs = secretRefs(`connection.${id}`, parsed.secrets);
235
+ for (const [field, value] of Object.entries(parsed.secrets))
236
+ secrets[`connection.${id}.${field}`] = value;
237
+ const entry = toYamlEntry({
238
+ id, provider: parsed.provider, enabled: true, config: parsed.config, secrets: refs,
239
+ ...(parsed.displayName ? { displayName: parsed.displayName } : {}),
240
+ ...(credentialId ? { credential: credentialId } : {}),
241
+ });
242
+ edits.apply({ secrets, rewrite: text => ({ text: addConnectionYaml(text, entry, credentialYaml) }) });
243
+ await reload();
244
+ const health = (await this.checkNow(id)) ?? { state: "failed", checkedAt: new Date().toISOString(), error: { code: "CHANNEL_CONNECTION_FAILED", retryable: true } };
245
+ return { id, health };
246
+ });
247
+ },
248
+ update(id, input) {
249
+ return serialized(async () => {
250
+ const parsed = updateSchema.safeParse(input);
251
+ if (!parsed.success)
252
+ throw invalid();
253
+ const registry = requireHost().registry;
254
+ const current = registry.get(id, false);
255
+ const provider = registry.providerById(current.provider);
256
+ const patch = {};
257
+ if (parsed.data.displayName !== undefined)
258
+ patch.displayName = parsed.data.displayName;
259
+ if (parsed.data.enabled !== undefined)
260
+ patch.enabled = parsed.data.enabled;
261
+ if (parsed.data.config !== undefined) {
262
+ const config = provider.validateConfig(parsed.data.config);
263
+ if (!config.ok)
264
+ throw invalid("config");
265
+ patch.config = parsed.data.config;
266
+ }
267
+ const allowed = new Set([...provider.secrets.required, ...(provider.secrets.optional ?? [])]);
268
+ const secrets = parsed.data.secrets ?? {};
269
+ for (const field of Object.keys(secrets))
270
+ if (!allowed.has(field))
271
+ throw invalid(field);
272
+ const refs = secretRefs(`connection.${id}`, secrets);
273
+ if (Object.keys(refs).length)
274
+ patch.secrets = refs;
275
+ edits.apply({
276
+ secrets: Object.fromEntries(Object.entries(secrets).map(([field, value]) => [`connection.${id}.${field}`, value])),
277
+ rewrite: text => ({ text: updateConnectionYaml(text, id, patch) }),
278
+ });
279
+ await reload();
280
+ const enabled = parsed.data.enabled ?? current.enabled;
281
+ return enabled ? { health: await this.checkNow(id) } : {};
282
+ });
283
+ },
284
+ remove(id) {
285
+ return serialized(async () => {
286
+ requireHost().registry.get(id, false);
287
+ edits.apply({
288
+ rewrite: text => {
289
+ const removed = removeConnectionYaml(text, id);
290
+ return {
291
+ text: removed.text,
292
+ dropSecretPrefixes: [`connection.${id}.`, ...(removed.removedCredential ? [`credential.${removed.removedCredential}.`] : [])],
293
+ };
294
+ },
295
+ });
296
+ await reload();
297
+ });
298
+ },
299
+ stop() {
300
+ return serialized(async () => {
301
+ await monitor?.stop();
302
+ if (host)
303
+ await releaseChannelHost(cwd);
304
+ });
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,45 @@
1
+ import type { StageChannel, StageConfig } from "../types/stage.js";
2
+ import { type ChannelErrorCode } from "./errors.js";
3
+ import type { ChannelRegistry } from "./registry.js";
4
+ export declare const MAX_STAGE_CHANNELS = 20;
5
+ /**
6
+ * Policy, not capability: how many channels one stage may use. Everything below (merge by alias,
7
+ * picking by name, the per-channel ledger key) still handles several, so raising this one number
8
+ * is all it takes to enable them. It is enforced where a stage is parsed, merged, validated and
9
+ * at the moment of sending, so no single entry point can bypass it.
10
+ */
11
+ export declare const MAX_CHANNELS_PER_STAGE = 1;
12
+ export declare function stageChannelLimit(): number;
13
+ /** Tests that exercise multi-channel behavior raise the policy for their own duration. */
14
+ export declare function setStageChannelLimitForTests(limit: number): () => void;
15
+ export declare function channelLimitMessage(count?: number): string;
16
+ export declare const MAX_CHANNEL_TARGETS = 20;
17
+ export type ParsedChannels = {
18
+ ok: true;
19
+ value: StageChannel[];
20
+ } | {
21
+ ok: false;
22
+ message: string;
23
+ };
24
+ export declare function channelKey(channel: Pick<StageChannel, "alias" | "connection">): string;
25
+ /** Shape check only. Whether the connection exists is checked against `connections.yaml` later. */
26
+ export declare function parseChannels(raw: unknown): ParsedChannels;
27
+ /**
28
+ * Pipeline `channels` are defaults. A stage that declares its own list merges by alias (or
29
+ * connection id when there is no alias) with the stage entry winning. An explicit empty
30
+ * list clears every inherited entry.
31
+ */
32
+ export declare function mergeChannelDefaults(defaults: readonly StageChannel[] | undefined, own: readonly StageChannel[] | undefined): StageChannel[] | undefined;
33
+ export type StageChannelIssue = {
34
+ stageId: string;
35
+ key: string;
36
+ code: ChannelErrorCode;
37
+ message: string;
38
+ };
39
+ export declare function channelTarget(channel: StageChannel): {
40
+ to: string | string[];
41
+ cc?: string[];
42
+ template?: Record<string, unknown>;
43
+ };
44
+ /** Static checks against `connections.yaml`. No network and no secret reads. */
45
+ export declare function validateStageChannels(registry: ChannelRegistry, stage: Pick<StageConfig, "id" | "channels">): StageChannelIssue[];
@@ -0,0 +1,110 @@
1
+ import { z } from "zod";
2
+ import { ChannelError } from "./errors.js";
3
+ export const MAX_STAGE_CHANNELS = 20;
4
+ /**
5
+ * Policy, not capability: how many channels one stage may use. Everything below (merge by alias,
6
+ * picking by name, the per-channel ledger key) still handles several, so raising this one number
7
+ * is all it takes to enable them. It is enforced where a stage is parsed, merged, validated and
8
+ * at the moment of sending, so no single entry point can bypass it.
9
+ */
10
+ export const MAX_CHANNELS_PER_STAGE = 1;
11
+ let channelLimit = MAX_CHANNELS_PER_STAGE;
12
+ export function stageChannelLimit() {
13
+ return channelLimit;
14
+ }
15
+ /** Tests that exercise multi-channel behavior raise the policy for their own duration. */
16
+ export function setStageChannelLimitForTests(limit) {
17
+ const previous = channelLimit;
18
+ channelLimit = limit;
19
+ return () => { channelLimit = previous; };
20
+ }
21
+ export function channelLimitMessage(count) {
22
+ const limit = channelLimit;
23
+ return `a stage can use ${limit === 1 ? "one channel" : `at most ${limit} channels`} for now${count === undefined ? "" : ` (this one has ${count})`}; split the work into separate stages`;
24
+ }
25
+ export const MAX_CHANNEL_TARGETS = 20;
26
+ const targetSchema = z.union([z.string().min(1).max(500), z.number().int().refine(Number.isSafeInteger).transform(String)]);
27
+ const channelSchema = z.object({
28
+ alias: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/).optional(),
29
+ connection: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/),
30
+ operations: z.array(z.enum(["send"])).min(1).max(1),
31
+ to: z.union([targetSchema, z.array(targetSchema).min(1).max(MAX_CHANNEL_TARGETS)]),
32
+ cc: z.array(targetSchema).min(1).max(MAX_CHANNEL_TARGETS).optional(),
33
+ template: z.record(z.string(), z.unknown()).optional(),
34
+ }).strict();
35
+ export function channelKey(channel) {
36
+ return channel.alias ?? channel.connection;
37
+ }
38
+ /** Shape check only. Whether the connection exists is checked against `connections.yaml` later. */
39
+ export function parseChannels(raw) {
40
+ if (Array.isArray(raw) && raw.length > channelLimit)
41
+ return { ok: false, message: `channels: ${channelLimitMessage(raw.length)}` };
42
+ const parsed = z.array(channelSchema).max(MAX_STAGE_CHANNELS).safeParse(raw);
43
+ if (!parsed.success)
44
+ return { ok: false, message: "channels must be a list of { connection, to, operations: [send] } entries (optional alias, cc, template)" };
45
+ const keys = parsed.data.map(channelKey);
46
+ if (new Set(keys).size !== keys.length) {
47
+ return { ok: false, message: "channels entries must have unique aliases; give each entry for the same connection its own alias" };
48
+ }
49
+ return { ok: true, value: parsed.data };
50
+ }
51
+ /**
52
+ * Pipeline `channels` are defaults. A stage that declares its own list merges by alias (or
53
+ * connection id when there is no alias) with the stage entry winning. An explicit empty
54
+ * list clears every inherited entry.
55
+ */
56
+ export function mergeChannelDefaults(defaults, own) {
57
+ if (own === undefined)
58
+ return defaults === undefined ? undefined : structuredClone([...defaults]);
59
+ if (own.length === 0)
60
+ return [];
61
+ const merged = new Map();
62
+ for (const channel of defaults ?? [])
63
+ merged.set(channelKey(channel), channel);
64
+ for (const channel of own)
65
+ merged.set(channelKey(channel), channel);
66
+ return structuredClone([...merged.values()]);
67
+ }
68
+ export function channelTarget(channel) {
69
+ return {
70
+ to: channel.to,
71
+ ...(channel.cc !== undefined ? { cc: channel.cc } : {}),
72
+ ...(channel.template !== undefined ? { template: channel.template } : {}),
73
+ };
74
+ }
75
+ /** Static checks against `connections.yaml`. No network and no secret reads. */
76
+ export function validateStageChannels(registry, stage) {
77
+ const issues = [];
78
+ const count = stage.channels?.length ?? 0;
79
+ if (count > channelLimit) {
80
+ issues.push({ stageId: stage.id, key: stage.channels[0] ? channelKey(stage.channels[0]) : "", code: "CHANNEL_INVALID_INPUT", message: `stage "${stage.id}": ${channelLimitMessage(count)}` });
81
+ }
82
+ for (const channel of stage.channels ?? []) {
83
+ const key = channelKey(channel);
84
+ const fail = (code, message) => {
85
+ issues.push({ stageId: stage.id, key, code, message });
86
+ };
87
+ try {
88
+ registry.get(channel.connection);
89
+ }
90
+ catch (error) {
91
+ if (!(error instanceof ChannelError))
92
+ throw error;
93
+ fail(error.code, error.code === "CHANNEL_DISABLED"
94
+ ? `channel "${key}": connection "${channel.connection}" is disabled`
95
+ : `channel "${key}": connection "${channel.connection}" is not defined in connections.yaml`);
96
+ continue;
97
+ }
98
+ const provider = registry.providerFor(channel.connection);
99
+ const unsupported = channel.operations.find(operation => !provider.operations.includes(operation));
100
+ if (unsupported) {
101
+ fail("CHANNEL_INVALID_INPUT", `channel "${key}": provider "${provider.id}" does not support "${unsupported}"`);
102
+ continue;
103
+ }
104
+ const target = provider.validateTarget(channelTarget(channel));
105
+ if (!target.ok) {
106
+ fail("CHANNEL_TARGET_INVALID", `channel "${key}": invalid target for ${provider.id} (${target.issues.join(", ")})`);
107
+ }
108
+ }
109
+ return issues;
110
+ }
@@ -0,0 +1,142 @@
1
+ import type { ChannelErrorCode } from "./errors.js";
2
+ export type ChannelOperation = "send";
3
+ export type SecretRef = `env:${string}` | `store:${string}`;
4
+ export type ConnectionEntry = {
5
+ id: string;
6
+ provider: string;
7
+ displayName?: string;
8
+ enabled: boolean;
9
+ credential?: string;
10
+ config: Record<string, unknown>;
11
+ secrets: Record<string, SecretRef>;
12
+ };
13
+ export type CredentialEntry = {
14
+ id: string;
15
+ provider: string;
16
+ secrets: Record<string, SecretRef>;
17
+ };
18
+ export type ConnectionHealth = {
19
+ state: "ok" | "failed";
20
+ checkedAt: string;
21
+ error?: {
22
+ code: ChannelErrorCode;
23
+ retryable: boolean;
24
+ };
25
+ };
26
+ export type ValidationResult<T> = {
27
+ ok: true;
28
+ value: T;
29
+ } | {
30
+ ok: false;
31
+ issues: string[];
32
+ };
33
+ export type ResolvedCredential = {
34
+ id: string;
35
+ provider: string;
36
+ secrets: Record<string, string>;
37
+ };
38
+ /**
39
+ * Secret values are readable by providers but not enumerable, so JSON, logging and
40
+ * `util.inspect` of a resolved connection never include them.
41
+ */
42
+ export type ResolvedConnection<C = unknown> = {
43
+ readonly id: string;
44
+ readonly scope: string;
45
+ readonly provider: string;
46
+ readonly displayName: string;
47
+ readonly config: C;
48
+ readonly secrets: Readonly<Record<string, string>>;
49
+ readonly credential?: ResolvedCredential;
50
+ };
51
+ export type OutboundMessage = {
52
+ text: string;
53
+ subject?: string;
54
+ };
55
+ export type SendContext = {
56
+ operationKey: string;
57
+ runId?: string;
58
+ stageId?: string;
59
+ };
60
+ export type SendReceipt = {
61
+ providerMessageId?: string;
62
+ submittedAt: string;
63
+ warnings?: string[];
64
+ };
65
+ export type ProviderSecretSpec = {
66
+ required: readonly string[];
67
+ optional?: readonly string[];
68
+ };
69
+ /** One input in the connect form. The provider says what it needs; the console renders it. */
70
+ export type ProviderField = {
71
+ key: string;
72
+ label: string;
73
+ hint?: string;
74
+ placeholder?: string;
75
+ /** config goes to connections.yaml, secret to the secret store, credential to the shared credential. */
76
+ target: "config" | "secret" | "credential";
77
+ input: "text" | "password" | "select";
78
+ required: boolean;
79
+ options?: Array<{
80
+ value: string;
81
+ label: string;
82
+ }>;
83
+ defaultValue?: string;
84
+ };
85
+ /** Everything the console shows about a provider, so the console holds no provider tables of its own. */
86
+ export type ProviderMeta = {
87
+ label: string;
88
+ /** Shown on the "not connected" tile. */
89
+ blurb: string;
90
+ /** Presentation key for an icon. The console maps it. */
91
+ icon: string;
92
+ /** Set when the provider works through another service, such as "Photon". */
93
+ via?: string;
94
+ note?: string;
95
+ fields: ProviderField[];
96
+ recipient: {
97
+ label: string;
98
+ placeholder: string;
99
+ multiple: boolean;
100
+ cc: boolean;
101
+ template: boolean;
102
+ };
103
+ /** Environment variable names for the `env:` form, in the order the YAML example uses them. */
104
+ envNames: string[];
105
+ /** A connections.yaml snippet that connects this provider. */
106
+ yamlExample: string;
107
+ /** The `to:` lines (and template, if any) of a stage `channels:` entry for this provider. */
108
+ stageExample: string;
109
+ };
110
+ export interface ChannelProvider<C = unknown, T = unknown> {
111
+ readonly id: string;
112
+ readonly meta: ProviderMeta;
113
+ readonly operations: readonly ChannelOperation[];
114
+ readonly secrets: ProviderSecretSpec;
115
+ /** When set, a connection must reference a credential of this provider kind. */
116
+ readonly credentialProvider?: string;
117
+ validateConfig(config: unknown): ValidationResult<C>;
118
+ validateTarget(target: unknown): ValidationResult<T>;
119
+ /** Masked, non-secret identity for listings, such as an address or workspace. */
120
+ describe?(config: C): {
121
+ identity?: string;
122
+ };
123
+ check(connection: ResolvedConnection<C>, signal?: AbortSignal): Promise<ConnectionHealth>;
124
+ /**
125
+ * Optional live check of the targets stages pin on this connection, such as whether a
126
+ * recipient is registered. Runs only after `check` succeeds. Targets are already validated.
127
+ */
128
+ checkTargets?(connection: ResolvedConnection<C>, targets: T[], signal?: AbortSignal): Promise<ConnectionHealth>;
129
+ send(connection: ResolvedConnection<C>, target: T, message: OutboundMessage, context: SendContext): Promise<SendReceipt>;
130
+ }
131
+ export type AnyChannelProvider = ChannelProvider<any, any>;
132
+ export type ConnectionSummary = {
133
+ id: string;
134
+ scope: string;
135
+ provider: string;
136
+ displayName: string;
137
+ enabled: boolean;
138
+ identity?: string;
139
+ credential?: string;
140
+ /** Where each secret field is read from: the host environment or the console secret store. */
141
+ secrets: Record<string, "env" | "store">;
142
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -293,7 +293,9 @@ function preflightToDoctorChecks(preflight, strict) {
293
293
  ? `toolchain:${c.tool ?? i}`
294
294
  : c.kind === "secret"
295
295
  ? `secret:${c.secret ?? i}`
296
- : `mcp:${c.server ?? c.stageId ?? i}`;
296
+ : c.kind === "channel"
297
+ ? `channel:${c.connection ?? i}`
298
+ : `mcp:${c.server ?? c.stageId ?? i}`;
297
299
  let status = "pass";
298
300
  if (c.status === "ok") {
299
301
  status = "pass";
@@ -12,6 +12,7 @@ export type DraftPackage = {
12
12
  model?: unknown;
13
13
  schemas?: unknown;
14
14
  requires?: unknown;
15
+ channels?: unknown;
15
16
  };
16
17
  stages?: DraftStageArtifact[];
17
18
  task?: {