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.
- package/dist/agent/claudeAdapter.js +6 -0
- package/dist/agent/piAdapter.js +9 -0
- package/dist/agent/port.d.ts +2 -0
- package/dist/channels/config.d.ts +11 -0
- package/dist/channels/config.js +95 -0
- package/dist/channels/configWriter.d.ts +40 -0
- package/dist/channels/configWriter.js +148 -0
- package/dist/channels/connectionEdits.d.ts +34 -0
- package/dist/channels/connectionEdits.js +53 -0
- package/dist/channels/errorInfo.d.ts +18 -0
- package/dist/channels/errorInfo.js +71 -0
- package/dist/channels/errors.d.ts +12 -0
- package/dist/channels/errors.js +13 -0
- package/dist/channels/health.d.ts +41 -0
- package/dist/channels/health.js +99 -0
- package/dist/channels/host.d.ts +55 -0
- package/dist/channels/host.js +185 -0
- package/dist/channels/http.d.ts +31 -0
- package/dist/channels/http.js +79 -0
- package/dist/channels/index.d.ts +10 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/ledger.d.ts +37 -0
- package/dist/channels/ledger.js +95 -0
- package/dist/channels/providers/agentmail.d.ts +26 -0
- package/dist/channels/providers/agentmail.js +81 -0
- package/dist/channels/providers/kit.d.ts +40 -0
- package/dist/channels/providers/kit.js +78 -0
- package/dist/channels/providers/photonImessage.d.ts +45 -0
- package/dist/channels/providers/photonImessage.js +152 -0
- package/dist/channels/providers/photonWhatsapp.d.ts +49 -0
- package/dist/channels/providers/photonWhatsapp.js +146 -0
- package/dist/channels/providers/slack.d.ts +25 -0
- package/dist/channels/providers/slack.js +101 -0
- package/dist/channels/providers/telegram.d.ts +21 -0
- package/dist/channels/providers/telegram.js +100 -0
- package/dist/channels/providers.d.ts +5 -0
- package/dist/channels/providers.js +22 -0
- package/dist/channels/registry.d.ts +54 -0
- package/dist/channels/registry.js +186 -0
- package/dist/channels/secretStore.d.ts +21 -0
- package/dist/channels/secretStore.js +75 -0
- package/dist/channels/secrets.d.ts +15 -0
- package/dist/channels/secrets.js +50 -0
- package/dist/channels/service.d.ts +54 -0
- package/dist/channels/service.js +307 -0
- package/dist/channels/stageChannels.d.ts +45 -0
- package/dist/channels/stageChannels.js +110 -0
- package/dist/channels/types.d.ts +142 -0
- package/dist/channels/types.js +1 -0
- package/dist/cli/doctorCommand.js +3 -1
- package/dist/config/draftPackage.d.ts +1 -0
- package/dist/config/draftPackage.js +4 -0
- package/dist/config/hostConfig.js +1 -0
- package/dist/config/loadPipeline.js +27 -3
- package/dist/config/loadStage.js +15 -0
- package/dist/config/mergePipelineIncludes.d.ts +3 -0
- package/dist/config/mergePipelineIncludes.js +27 -0
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/config/validateCatalog.d.ts +4 -1
- package/dist/config/validateCatalog.js +23 -0
- package/dist/errors/codes.d.ts +1 -1
- package/dist/errors/codes.js +1 -0
- package/dist/mcp/catalogTools.js +1 -0
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/preflight/channelChecks.d.ts +17 -0
- package/dist/preflight/channelChecks.js +106 -0
- package/dist/preflight/pipelinePreflight.d.ts +9 -2
- package/dist/preflight/pipelinePreflight.js +9 -1
- package/dist/runtime/pipelineRunner.js +3 -1
- package/dist/runtime/repositoryMaterialize.d.ts +1 -1
- package/dist/runtime/runManager.js +4 -1
- package/dist/runtime/stageAttemptBootstrap.js +16 -0
- package/dist/runtime/stageProcessLauncher.js +31 -1
- package/dist/server/bootstrap.d.ts +3 -0
- package/dist/server/bootstrap.js +4 -0
- package/dist/server/channelRoutes.d.ts +9 -0
- package/dist/server/channelRoutes.js +88 -0
- package/dist/server/http.js +11 -0
- package/dist/tools/readEmail.d.ts +3 -3
- package/dist/tools/sendMessage.d.ts +21 -0
- package/dist/tools/sendMessage.js +25 -0
- package/dist/types/pipeline.d.ts +4 -1
- package/dist/types/stage.d.ts +13 -0
- package/dist/ui/assets/index-BOhqnPNg.js +260 -0
- package/dist/ui/assets/{index-CIPbbBUi.css → index-Clv4sJpU.css} +1 -1
- package/dist/ui/index.html +2 -2
- package/examples/channels-smoke/README.md +12 -0
- package/examples/channels-smoke/channels-smoke.pipeline.yaml +11 -0
- package/examples/channels-smoke/channels-smoke.task.yaml +3 -0
- package/examples/channels-smoke/send-email.yaml +32 -0
- package/examples/channels-smoke/send-text.yaml +31 -0
- package/package.json +8 -3
- package/dist/ui/assets/index-BoFPkKFg.js +0 -256
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ChannelError } from "./errors.js";
|
|
2
|
+
export const DEFAULT_HEALTH_INTERVAL_MS = 300000;
|
|
3
|
+
function failed(code, retryable) {
|
|
4
|
+
return { state: "failed", checkedAt: new Date().toISOString(), error: { code, retryable } };
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Host-only background check. It keeps the last result per connection in memory so the
|
|
8
|
+
* Connections page can show current status. It never sends a message and keeps no secrets.
|
|
9
|
+
* Failures back off up to `maxBackoffFactor` times the interval; successes return to it.
|
|
10
|
+
*/
|
|
11
|
+
export class ChannelHealthMonitor {
|
|
12
|
+
registry;
|
|
13
|
+
intervalMs;
|
|
14
|
+
jitter;
|
|
15
|
+
timeoutMs;
|
|
16
|
+
maxBackoffFactor;
|
|
17
|
+
random;
|
|
18
|
+
onResult;
|
|
19
|
+
results = new Map();
|
|
20
|
+
failures = new Map();
|
|
21
|
+
timers = new Map();
|
|
22
|
+
inflight = new Set();
|
|
23
|
+
controller = new AbortController();
|
|
24
|
+
running = false;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.registry = options.registry;
|
|
27
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_HEALTH_INTERVAL_MS;
|
|
28
|
+
this.jitter = options.jitter ?? 0.2;
|
|
29
|
+
this.timeoutMs = options.timeoutMs ?? 8000;
|
|
30
|
+
this.maxBackoffFactor = options.maxBackoffFactor ?? 8;
|
|
31
|
+
this.random = options.random ?? Math.random;
|
|
32
|
+
this.onResult = options.onResult;
|
|
33
|
+
}
|
|
34
|
+
snapshot() {
|
|
35
|
+
return Object.fromEntries([...this.results].map(([id, health]) => [id, structuredClone(health)]));
|
|
36
|
+
}
|
|
37
|
+
start() {
|
|
38
|
+
if (this.running)
|
|
39
|
+
return;
|
|
40
|
+
this.running = true;
|
|
41
|
+
this.controller = new AbortController();
|
|
42
|
+
for (const connection of this.registry.list()) {
|
|
43
|
+
if (connection.enabled)
|
|
44
|
+
this.schedule(connection.id, Math.floor(this.random() * 2000));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async stop() {
|
|
48
|
+
this.running = false;
|
|
49
|
+
this.controller.abort();
|
|
50
|
+
for (const timer of this.timers.values())
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
this.timers.clear();
|
|
53
|
+
await Promise.allSettled([...this.inflight]);
|
|
54
|
+
}
|
|
55
|
+
/** Runs one check now, outside the schedule. */
|
|
56
|
+
async checkNow(connectionId) {
|
|
57
|
+
const health = await this.runCheck(connectionId);
|
|
58
|
+
this.results.set(connectionId, health);
|
|
59
|
+
this.onResult?.(connectionId, structuredClone(health));
|
|
60
|
+
return structuredClone(health);
|
|
61
|
+
}
|
|
62
|
+
schedule(id, delay) {
|
|
63
|
+
if (!this.running)
|
|
64
|
+
return;
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
this.timers.delete(id);
|
|
67
|
+
const work = this.checkNow(id).then(health => {
|
|
68
|
+
const count = health.state === "ok" ? 0 : (this.failures.get(id) ?? 0) + 1;
|
|
69
|
+
this.failures.set(id, count);
|
|
70
|
+
this.schedule(id, this.nextDelay(count));
|
|
71
|
+
}).finally(() => { this.inflight.delete(work); });
|
|
72
|
+
this.inflight.add(work);
|
|
73
|
+
}, delay);
|
|
74
|
+
timer.unref?.();
|
|
75
|
+
this.timers.set(id, timer);
|
|
76
|
+
}
|
|
77
|
+
nextDelay(failureCount) {
|
|
78
|
+
const factor = Math.min(2 ** failureCount, this.maxBackoffFactor);
|
|
79
|
+
const base = this.intervalMs * (failureCount === 0 ? 1 : factor);
|
|
80
|
+
return Math.round(base * (1 + (this.random() * 2 - 1) * this.jitter));
|
|
81
|
+
}
|
|
82
|
+
async runCheck(id) {
|
|
83
|
+
let timer;
|
|
84
|
+
try {
|
|
85
|
+
const provider = this.registry.providerFor(id);
|
|
86
|
+
const connection = this.registry.resolve(id);
|
|
87
|
+
const timeout = new Promise(resolve => {
|
|
88
|
+
timer = setTimeout(() => resolve(failed("CHANNEL_TIMEOUT", true)), this.timeoutMs);
|
|
89
|
+
});
|
|
90
|
+
return await Promise.race([provider.check(connection, this.controller.signal), timeout]);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return error instanceof ChannelError ? failed(error.code, error.retryable) : failed("CHANNEL_CONNECTION_FAILED", false);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { StageConfig } from "../types/stage.js";
|
|
2
|
+
import { ChannelRegistry, type ChannelRegistryOptions } from "./registry.js";
|
|
3
|
+
import { type ChannelErrorCode } from "./errors.js";
|
|
4
|
+
import { ChannelSends } from "./ledger.js";
|
|
5
|
+
export type ChannelSendInput = {
|
|
6
|
+
channel?: string;
|
|
7
|
+
text: string;
|
|
8
|
+
subject?: string;
|
|
9
|
+
operationKey: string;
|
|
10
|
+
};
|
|
11
|
+
export type ChannelSendResult = {
|
|
12
|
+
channel: string;
|
|
13
|
+
connection: string;
|
|
14
|
+
providerMessageId?: string;
|
|
15
|
+
submittedAt: string;
|
|
16
|
+
warnings?: string[];
|
|
17
|
+
};
|
|
18
|
+
export type StageChannels = {
|
|
19
|
+
send(input: ChannelSendInput): Promise<ChannelSendResult>;
|
|
20
|
+
};
|
|
21
|
+
export declare const MAX_MESSAGE_TEXT = 65536;
|
|
22
|
+
export declare class ChannelHost {
|
|
23
|
+
private readonly cwd;
|
|
24
|
+
private readonly options;
|
|
25
|
+
registry: ChannelRegistry;
|
|
26
|
+
readonly sends: ChannelSends;
|
|
27
|
+
constructor(cwd: string, options?: Omit<ChannelRegistryOptions, "cwd">);
|
|
28
|
+
/** Re-reads connections.yaml after an edit. A file that no longer loads keeps the old registry. */
|
|
29
|
+
reload(): void;
|
|
30
|
+
}
|
|
31
|
+
export declare function channelHostFor(cwd: string): ChannelHost;
|
|
32
|
+
/** Re-reads connections.yaml in every cached host, so an edit made once reaches every project. */
|
|
33
|
+
export declare function reloadChannelHosts(): void;
|
|
34
|
+
export declare function releaseChannelHost(cwd: string): Promise<void>;
|
|
35
|
+
/** Fails with the first problem found. Static only: no network, no secret reads. */
|
|
36
|
+
export declare function validateStageChannelConnections(host: ChannelHost, stage: Pick<StageConfig, "id" | "channels">): void;
|
|
37
|
+
/**
|
|
38
|
+
* Removes every variable the loaded connections point at, and its `_FILE` form, from a worker
|
|
39
|
+
* environment. It reads the names from the same registry the host sends with, so the variables
|
|
40
|
+
* stripped always belong to the file the host actually loaded. A file that cannot be loaded
|
|
41
|
+
* leaves the environment unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export declare function channelsWorkerEnvironment(env: NodeJS.ProcessEnv, cwd: string): NodeJS.ProcessEnv;
|
|
44
|
+
/**
|
|
45
|
+
* Host-side authorization and delivery for one stage. The target comes from the stage's
|
|
46
|
+
* frozen `channels`, never from the caller. The caller's operation key is bound to the run,
|
|
47
|
+
* stage and channel before it reaches the ledger.
|
|
48
|
+
*/
|
|
49
|
+
export declare function stageChannels(host: ChannelHost, stage: StageConfig, runId: string): StageChannels;
|
|
50
|
+
export declare function channelFault(error: unknown): {
|
|
51
|
+
code: ChannelErrorCode;
|
|
52
|
+
retryable: boolean;
|
|
53
|
+
};
|
|
54
|
+
/** The inherited IPC channel identifies the child; no caller-provided run or stage identity is accepted. */
|
|
55
|
+
export declare function workerStageChannels(): StageChannels;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ChannelRegistry } from "./registry.js";
|
|
5
|
+
import { ChannelError } from "./errors.js";
|
|
6
|
+
import { ChannelSends } from "./ledger.js";
|
|
7
|
+
import { channelKey, channelTarget, stageChannelLimit, validateStageChannels } from "./stageChannels.js";
|
|
8
|
+
export const MAX_MESSAGE_TEXT = 65536;
|
|
9
|
+
const WORKER_TIMEOUT_MS = 65000;
|
|
10
|
+
const sendSchema = z.object({
|
|
11
|
+
channel: z.string().min(1).max(200).optional(),
|
|
12
|
+
text: z.string().min(1).max(MAX_MESSAGE_TEXT),
|
|
13
|
+
subject: z.string().min(1).max(998).regex(/^[^\r\n]*$/).optional(),
|
|
14
|
+
operationKey: z.string().min(1).max(120),
|
|
15
|
+
}).strict();
|
|
16
|
+
export class ChannelHost {
|
|
17
|
+
cwd;
|
|
18
|
+
options;
|
|
19
|
+
registry;
|
|
20
|
+
sends;
|
|
21
|
+
constructor(cwd, options = {}) {
|
|
22
|
+
this.cwd = cwd;
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.registry = new ChannelRegistry({ cwd, ...options });
|
|
25
|
+
this.sends = new ChannelSends(this.registry.scope);
|
|
26
|
+
}
|
|
27
|
+
/** Re-reads connections.yaml after an edit. A file that no longer loads keeps the old registry. */
|
|
28
|
+
reload() {
|
|
29
|
+
this.registry = new ChannelRegistry({ cwd: this.cwd, ...this.options });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const hosts = new Map();
|
|
33
|
+
export function channelHostFor(cwd) {
|
|
34
|
+
const key = path.resolve(cwd);
|
|
35
|
+
let entry = hosts.get(key);
|
|
36
|
+
if (entry?.releasing)
|
|
37
|
+
throw new ChannelError("CHANNEL_CONNECTION_FAILED", true);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
entry = { host: new ChannelHost(key) };
|
|
40
|
+
hosts.set(key, entry);
|
|
41
|
+
}
|
|
42
|
+
return entry.host;
|
|
43
|
+
}
|
|
44
|
+
/** Re-reads connections.yaml in every cached host, so an edit made once reaches every project. */
|
|
45
|
+
export function reloadChannelHosts() {
|
|
46
|
+
for (const entry of hosts.values()) {
|
|
47
|
+
try {
|
|
48
|
+
entry.host.reload();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* a host whose file no longer loads keeps its previous registry until the file is fixed */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function releaseChannelHost(cwd) {
|
|
56
|
+
const key = path.resolve(cwd);
|
|
57
|
+
const entry = hosts.get(key);
|
|
58
|
+
if (!entry)
|
|
59
|
+
return;
|
|
60
|
+
entry.releasing ??= (async () => {
|
|
61
|
+
entry.host.sends.setAccepting(false);
|
|
62
|
+
await entry.host.sends.drain();
|
|
63
|
+
})();
|
|
64
|
+
await entry.releasing;
|
|
65
|
+
if (hosts.get(key) === entry)
|
|
66
|
+
hosts.delete(key);
|
|
67
|
+
}
|
|
68
|
+
/** Fails with the first problem found. Static only: no network, no secret reads. */
|
|
69
|
+
export function validateStageChannelConnections(host, stage) {
|
|
70
|
+
const [first] = validateStageChannels(host.registry, stage);
|
|
71
|
+
if (first)
|
|
72
|
+
throw new ChannelError(first.code, false, { connectionId: stage.channels?.find(channel => channelKey(channel) === first.key)?.connection });
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Removes every variable the loaded connections point at, and its `_FILE` form, from a worker
|
|
76
|
+
* environment. It reads the names from the same registry the host sends with, so the variables
|
|
77
|
+
* stripped always belong to the file the host actually loaded. A file that cannot be loaded
|
|
78
|
+
* leaves the environment unchanged.
|
|
79
|
+
*/
|
|
80
|
+
export function channelsWorkerEnvironment(env, cwd) {
|
|
81
|
+
const result = { ...env };
|
|
82
|
+
let names;
|
|
83
|
+
try {
|
|
84
|
+
names = channelHostFor(cwd).registry.envSecretNames();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
delete result[name];
|
|
91
|
+
delete result[`${name}_FILE`];
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
function pick(channels, requested) {
|
|
96
|
+
if (requested !== undefined) {
|
|
97
|
+
const found = channels.find(channel => channelKey(channel) === requested);
|
|
98
|
+
if (!found)
|
|
99
|
+
throw new ChannelError("CHANNEL_UNAUTHORIZED");
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
if (channels.length === 1)
|
|
103
|
+
return channels[0];
|
|
104
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT", false, { field: "channel" });
|
|
105
|
+
}
|
|
106
|
+
function hashOf(parts) {
|
|
107
|
+
return createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Host-side authorization and delivery for one stage. The target comes from the stage's
|
|
111
|
+
* frozen `channels`, never from the caller. The caller's operation key is bound to the run,
|
|
112
|
+
* stage and channel before it reaches the ledger.
|
|
113
|
+
*/
|
|
114
|
+
export function stageChannels(host, stage, runId) {
|
|
115
|
+
const declared = structuredClone(stage.channels ?? []);
|
|
116
|
+
const stageId = stage.id;
|
|
117
|
+
const tooMany = declared.length > stageChannelLimit();
|
|
118
|
+
return {
|
|
119
|
+
async send(raw) {
|
|
120
|
+
if (tooMany)
|
|
121
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
122
|
+
const parsed = sendSchema.safeParse(raw);
|
|
123
|
+
if (!parsed.success)
|
|
124
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
125
|
+
const input = parsed.data;
|
|
126
|
+
const channel = pick(declared, input.channel);
|
|
127
|
+
if (!channel.operations.includes("send"))
|
|
128
|
+
throw new ChannelError("CHANNEL_UNAUTHORIZED", false, { connectionId: channel.connection });
|
|
129
|
+
const key = channelKey(channel);
|
|
130
|
+
const target = host.registry.validateTarget(channel.connection, channelTarget(channel));
|
|
131
|
+
const provider = host.registry.providerFor(channel.connection);
|
|
132
|
+
const connection = host.registry.resolve(channel.connection);
|
|
133
|
+
const message = { text: input.text, ...(input.subject !== undefined ? { subject: input.subject } : {}) };
|
|
134
|
+
const operationKey = hashOf([runId, stageId, key, input.operationKey]);
|
|
135
|
+
const receipt = await host.sends.send({ connectionId: channel.connection, operationKey, hash: hashOf([key, channel.connection, channelTarget(channel), message]) }, () => provider.send(connection, target, message, { operationKey, runId, stageId }));
|
|
136
|
+
return {
|
|
137
|
+
channel: key,
|
|
138
|
+
connection: channel.connection,
|
|
139
|
+
...(receipt.providerMessageId !== undefined ? { providerMessageId: receipt.providerMessageId } : {}),
|
|
140
|
+
submittedAt: receipt.submittedAt,
|
|
141
|
+
...(receipt.warnings?.length ? { warnings: receipt.warnings } : {}),
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
export function channelFault(error) {
|
|
147
|
+
return error instanceof ChannelError
|
|
148
|
+
? { code: error.code, retryable: error.retryable }
|
|
149
|
+
: { code: "CHANNEL_CONNECTION_FAILED", retryable: false };
|
|
150
|
+
}
|
|
151
|
+
/** The inherited IPC channel identifies the child; no caller-provided run or stage identity is accepted. */
|
|
152
|
+
export function workerStageChannels() {
|
|
153
|
+
return {
|
|
154
|
+
send(input) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
if (!process.connected || !process.send) {
|
|
157
|
+
reject(new ChannelError("CHANNEL_UNAUTHORIZED"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const requestId = randomUUID();
|
|
161
|
+
const finish = (error, result) => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
process.off("message", receive);
|
|
164
|
+
process.off("disconnect", disconnected);
|
|
165
|
+
if (error)
|
|
166
|
+
reject(error);
|
|
167
|
+
else
|
|
168
|
+
resolve(result);
|
|
169
|
+
};
|
|
170
|
+
const disconnected = () => finish(new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN"));
|
|
171
|
+
const receive = (message) => {
|
|
172
|
+
const response = message;
|
|
173
|
+
if (response?.type !== "channel.response" || response.requestId !== requestId)
|
|
174
|
+
return;
|
|
175
|
+
finish(response.error ? new ChannelError(response.error.code, response.error.retryable) : undefined, response.receipt);
|
|
176
|
+
};
|
|
177
|
+
const timer = setTimeout(disconnected, WORKER_TIMEOUT_MS);
|
|
178
|
+
process.on("message", receive);
|
|
179
|
+
process.once("disconnect", disconnected);
|
|
180
|
+
process.send({ type: "channel.send", requestId, input }, error => { if (error)
|
|
181
|
+
disconnected(); });
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ChannelError } from "./errors.js";
|
|
2
|
+
export type HttpMode = "check" | "send";
|
|
3
|
+
export type HttpCall = {
|
|
4
|
+
fetch: typeof fetch;
|
|
5
|
+
url: string;
|
|
6
|
+
method: "GET" | "POST";
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
mode: HttpMode;
|
|
12
|
+
connectionId?: string;
|
|
13
|
+
};
|
|
14
|
+
export type HttpResult = {
|
|
15
|
+
status: number;
|
|
16
|
+
text: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Maps a transport failure. For a send, only failures that happen before the request leaves
|
|
20
|
+
* the machine are definite; everything else may have reached the provider, so it is unknown.
|
|
21
|
+
*/
|
|
22
|
+
export declare function networkFailure(error: unknown, mode: HttpMode, timedOut: boolean, connectionId?: string): ChannelError;
|
|
23
|
+
/** Maps an HTTP status to a channel error. `retryable` is true only for transient conditions. */
|
|
24
|
+
export declare function statusFailure(status: number, mode: HttpMode, connectionId?: string): ChannelError;
|
|
25
|
+
/**
|
|
26
|
+
* One bounded HTTP call. Redirects are refused so a credential header never follows a
|
|
27
|
+
* redirect to another host. Response bodies are returned to the provider but never put in
|
|
28
|
+
* an error, so provider messages cannot carry a secret into a log or a tool result.
|
|
29
|
+
*/
|
|
30
|
+
export declare function httpCall(call: HttpCall): Promise<HttpResult>;
|
|
31
|
+
export declare function parseJson(text: string): unknown;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ChannelError } from "./errors.js";
|
|
2
|
+
const NOT_SENT_CODES = new Set(["ENOTFOUND", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "ERR_TLS_CERT_ALTNAME_INVALID", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE"]);
|
|
3
|
+
function causeCode(error) {
|
|
4
|
+
const cause = error?.cause;
|
|
5
|
+
return typeof cause?.code === "string" ? cause.code : undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Maps a transport failure. For a send, only failures that happen before the request leaves
|
|
9
|
+
* the machine are definite; everything else may have reached the provider, so it is unknown.
|
|
10
|
+
*/
|
|
11
|
+
export function networkFailure(error, mode, timedOut, connectionId) {
|
|
12
|
+
const detail = connectionId ? { connectionId } : undefined;
|
|
13
|
+
if (timedOut)
|
|
14
|
+
return new ChannelError(mode === "send" ? "CHANNEL_SEND_OUTCOME_UNKNOWN" : "CHANNEL_TIMEOUT", mode !== "send", detail);
|
|
15
|
+
if (mode === "check")
|
|
16
|
+
return new ChannelError("CHANNEL_CONNECTION_FAILED", true, detail);
|
|
17
|
+
const code = causeCode(error);
|
|
18
|
+
if (code !== undefined && NOT_SENT_CODES.has(code))
|
|
19
|
+
return new ChannelError("CHANNEL_CONNECTION_FAILED", true, detail);
|
|
20
|
+
return new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, detail);
|
|
21
|
+
}
|
|
22
|
+
/** Maps an HTTP status to a channel error. `retryable` is true only for transient conditions. */
|
|
23
|
+
export function statusFailure(status, mode, connectionId) {
|
|
24
|
+
const detail = connectionId ? { connectionId } : undefined;
|
|
25
|
+
if (status === 401)
|
|
26
|
+
return new ChannelError("CHANNEL_AUTH_FAILED", false, detail);
|
|
27
|
+
if (status === 403)
|
|
28
|
+
return new ChannelError("CHANNEL_UNAUTHORIZED", false, detail);
|
|
29
|
+
if (status === 429)
|
|
30
|
+
return new ChannelError("CHANNEL_RATE_LIMIT", true, detail);
|
|
31
|
+
if (status >= 500) {
|
|
32
|
+
return mode === "send"
|
|
33
|
+
? new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, detail)
|
|
34
|
+
: new ChannelError("CHANNEL_CONNECTION_FAILED", true, detail);
|
|
35
|
+
}
|
|
36
|
+
if (status === 408)
|
|
37
|
+
return new ChannelError(mode === "send" ? "CHANNEL_SEND_OUTCOME_UNKNOWN" : "CHANNEL_TIMEOUT", mode !== "send", detail);
|
|
38
|
+
return new ChannelError("CHANNEL_INVALID_INPUT", false, detail);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One bounded HTTP call. Redirects are refused so a credential header never follows a
|
|
42
|
+
* redirect to another host. Response bodies are returned to the provider but never put in
|
|
43
|
+
* an error, so provider messages cannot carry a secret into a log or a tool result.
|
|
44
|
+
*/
|
|
45
|
+
export async function httpCall(call) {
|
|
46
|
+
const timeout = AbortSignal.timeout(call.timeoutMs);
|
|
47
|
+
const signal = call.signal ? AbortSignal.any([call.signal, timeout]) : timeout;
|
|
48
|
+
let response;
|
|
49
|
+
try {
|
|
50
|
+
response = await call.fetch(call.url, {
|
|
51
|
+
method: call.method,
|
|
52
|
+
headers: { ...call.headers, ...(call.body !== undefined ? { "content-type": "application/json" } : {}) },
|
|
53
|
+
...(call.body !== undefined ? { body: JSON.stringify(call.body) } : {}),
|
|
54
|
+
redirect: "error",
|
|
55
|
+
signal,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw networkFailure(error, call.mode, timeout.aborted, call.connectionId);
|
|
60
|
+
}
|
|
61
|
+
let text;
|
|
62
|
+
try {
|
|
63
|
+
text = await response.text();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (response.ok && call.mode === "send")
|
|
67
|
+
return { status: response.status, text: "" };
|
|
68
|
+
throw networkFailure(error, call.mode, timeout.aborted, call.connectionId);
|
|
69
|
+
}
|
|
70
|
+
return { status: response.status, text };
|
|
71
|
+
}
|
|
72
|
+
export function parseJson(text) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(text);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ChannelError, type ChannelErrorCode, type ChannelErrorDetail } from "./errors.js";
|
|
2
|
+
export { CONNECTIONS_FILE_NAME, ChannelRegistry, type ChannelRegistryOptions } from "./registry.js";
|
|
3
|
+
export { loadConnectionsFile, parseConnectionsYaml, type ConnectionsFile } from "./config.js";
|
|
4
|
+
export { isSecretRef, redactSecrets, resolveSecretRef, resolveSecrets } from "./secrets.js";
|
|
5
|
+
export type * from "./types.js";
|
|
6
|
+
export { MAX_CHANNEL_TARGETS, MAX_CHANNELS_PER_STAGE, MAX_STAGE_CHANNELS, channelLimitMessage, setStageChannelLimitForTests, stageChannelLimit, channelKey, channelTarget, mergeChannelDefaults, parseChannels, validateStageChannels, type ParsedChannels, type StageChannelIssue, } from "./stageChannels.js";
|
|
7
|
+
export { ChannelHost, MAX_MESSAGE_TEXT, channelFault, channelHostFor, channelsWorkerEnvironment, releaseChannelHost, stageChannels, validateStageChannelConnections, workerStageChannels, type ChannelSendInput, type ChannelSendResult, type StageChannels, } from "./host.js";
|
|
8
|
+
export { ChannelSends, type ChannelSendRecord } from "./ledger.js";
|
|
9
|
+
export { channelProviders, registerChannelProvider, unregisterChannelProvider } from "./providers.js";
|
|
10
|
+
export { ChannelHealthMonitor, DEFAULT_HEALTH_INTERVAL_MS, type ChannelHealthOptions } from "./health.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { ChannelError } from "./errors.js";
|
|
2
|
+
export { CONNECTIONS_FILE_NAME, ChannelRegistry } from "./registry.js";
|
|
3
|
+
export { loadConnectionsFile, parseConnectionsYaml } from "./config.js";
|
|
4
|
+
export { isSecretRef, redactSecrets, resolveSecretRef, resolveSecrets } from "./secrets.js";
|
|
5
|
+
export { MAX_CHANNEL_TARGETS, MAX_CHANNELS_PER_STAGE, MAX_STAGE_CHANNELS, channelLimitMessage, setStageChannelLimitForTests, stageChannelLimit, channelKey, channelTarget, mergeChannelDefaults, parseChannels, validateStageChannels, } from "./stageChannels.js";
|
|
6
|
+
export { ChannelHost, MAX_MESSAGE_TEXT, channelFault, channelHostFor, channelsWorkerEnvironment, releaseChannelHost, stageChannels, validateStageChannelConnections, workerStageChannels, } from "./host.js";
|
|
7
|
+
export { ChannelSends } from "./ledger.js";
|
|
8
|
+
export { channelProviders, registerChannelProvider, unregisterChannelProvider } from "./providers.js";
|
|
9
|
+
export { ChannelHealthMonitor, DEFAULT_HEALTH_INTERVAL_MS } from "./health.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ChannelErrorCode } from "./errors.js";
|
|
2
|
+
import type { SendReceipt } from "./types.js";
|
|
3
|
+
export type ChannelSendRecord = {
|
|
4
|
+
operationId: string;
|
|
5
|
+
connectionId: string;
|
|
6
|
+
operationKey: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
state: "pending" | "submitted" | "failed" | "unknown";
|
|
9
|
+
createdAt: string;
|
|
10
|
+
receipt?: SendReceipt;
|
|
11
|
+
error?: {
|
|
12
|
+
code: ChannelErrorCode;
|
|
13
|
+
retryable: boolean;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type ChannelSendIdentity = {
|
|
17
|
+
connectionId: string;
|
|
18
|
+
operationKey: string;
|
|
19
|
+
hash: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Durable record of send attempts, so a retried tool call or a resumed stage never sends twice.
|
|
23
|
+
* It stores a hash of the request, never the message text. The host is the single writer.
|
|
24
|
+
* A pending record that survives a crash becomes an uncertain outcome, never a resend.
|
|
25
|
+
*/
|
|
26
|
+
export declare class ChannelSends {
|
|
27
|
+
private readonly file;
|
|
28
|
+
private records;
|
|
29
|
+
private readonly active;
|
|
30
|
+
private accepting;
|
|
31
|
+
constructor(scope: string);
|
|
32
|
+
list(): ChannelSendRecord[];
|
|
33
|
+
drain(): Promise<void>;
|
|
34
|
+
setAccepting(accepting: boolean): void;
|
|
35
|
+
send(identity: ChannelSendIdentity, submit: () => Promise<SendReceipt>): Promise<SendReceipt>;
|
|
36
|
+
private persist;
|
|
37
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { storeRootFor } from "../runstore/paths.js";
|
|
5
|
+
import { ChannelError } from "./errors.js";
|
|
6
|
+
const RETENTION_MS = 30 * 86400000;
|
|
7
|
+
const MAX_RECORDS = 10000;
|
|
8
|
+
/**
|
|
9
|
+
* Durable record of send attempts, so a retried tool call or a resumed stage never sends twice.
|
|
10
|
+
* It stores a hash of the request, never the message text. The host is the single writer.
|
|
11
|
+
* A pending record that survives a crash becomes an uncertain outcome, never a resend.
|
|
12
|
+
*/
|
|
13
|
+
export class ChannelSends {
|
|
14
|
+
file;
|
|
15
|
+
records;
|
|
16
|
+
active = new Map();
|
|
17
|
+
accepting = true;
|
|
18
|
+
constructor(scope) {
|
|
19
|
+
this.file = path.join(storeRootFor(scope), "channel-sends.json");
|
|
20
|
+
this.records = existsSync(this.file) ? JSON.parse(readFileSync(this.file, "utf8")) : [];
|
|
21
|
+
for (const record of this.records)
|
|
22
|
+
if (record.state === "pending")
|
|
23
|
+
record.state = "unknown";
|
|
24
|
+
}
|
|
25
|
+
list() {
|
|
26
|
+
return structuredClone(this.records);
|
|
27
|
+
}
|
|
28
|
+
async drain() {
|
|
29
|
+
await Promise.allSettled(this.active.values());
|
|
30
|
+
}
|
|
31
|
+
setAccepting(accepting) {
|
|
32
|
+
this.accepting = accepting;
|
|
33
|
+
}
|
|
34
|
+
async send(identity, submit) {
|
|
35
|
+
if (!this.accepting)
|
|
36
|
+
throw new ChannelError("CHANNEL_CONNECTION_FAILED", true, { connectionId: identity.connectionId });
|
|
37
|
+
const key = `${identity.connectionId}\0${identity.operationKey}`;
|
|
38
|
+
const found = this.records.find(record => record.connectionId === identity.connectionId && record.operationKey === identity.operationKey);
|
|
39
|
+
if (found) {
|
|
40
|
+
if (found.hash !== identity.hash)
|
|
41
|
+
throw new ChannelError("CHANNEL_OPERATION_CONFLICT", false, { connectionId: identity.connectionId });
|
|
42
|
+
const active = this.active.get(key);
|
|
43
|
+
if (active)
|
|
44
|
+
return active;
|
|
45
|
+
if (found.receipt)
|
|
46
|
+
return structuredClone(found.receipt);
|
|
47
|
+
if (found.state === "failed" && found.error)
|
|
48
|
+
throw new ChannelError(found.error.code, found.error.retryable, { connectionId: identity.connectionId });
|
|
49
|
+
throw new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, { connectionId: identity.connectionId });
|
|
50
|
+
}
|
|
51
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
52
|
+
this.records = this.records.filter(record => !["submitted", "failed"].includes(record.state) || Date.parse(record.createdAt) >= cutoff);
|
|
53
|
+
if (this.records.length >= MAX_RECORDS)
|
|
54
|
+
throw new ChannelError("CHANNEL_RESOURCE_LIMIT", false, { connectionId: identity.connectionId });
|
|
55
|
+
const record = {
|
|
56
|
+
operationId: randomUUID(), connectionId: identity.connectionId, operationKey: identity.operationKey,
|
|
57
|
+
hash: identity.hash, state: "pending", createdAt: new Date().toISOString(),
|
|
58
|
+
};
|
|
59
|
+
this.records.push(record);
|
|
60
|
+
this.persist();
|
|
61
|
+
const work = Promise.resolve().then(submit).then(receipt => {
|
|
62
|
+
record.state = "submitted";
|
|
63
|
+
record.receipt = receipt;
|
|
64
|
+
try {
|
|
65
|
+
this.persist();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { ...structuredClone(receipt), warnings: [...(receipt.warnings ?? []), "CHANNEL_STORAGE_FAILED"] };
|
|
69
|
+
}
|
|
70
|
+
return structuredClone(receipt);
|
|
71
|
+
}).catch(error => {
|
|
72
|
+
if (record.state === "submitted")
|
|
73
|
+
throw error;
|
|
74
|
+
const fault = error instanceof ChannelError ? error : new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, { connectionId: identity.connectionId });
|
|
75
|
+
record.state = fault.code === "CHANNEL_SEND_OUTCOME_UNKNOWN" ? "unknown" : "failed";
|
|
76
|
+
record.error = { code: fault.code, retryable: fault.retryable };
|
|
77
|
+
try {
|
|
78
|
+
this.persist();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
record.state = "unknown";
|
|
82
|
+
throw new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, { connectionId: identity.connectionId });
|
|
83
|
+
}
|
|
84
|
+
throw new ChannelError(fault.code, fault.retryable, { connectionId: identity.connectionId });
|
|
85
|
+
}).finally(() => { this.active.delete(key); });
|
|
86
|
+
this.active.set(key, work);
|
|
87
|
+
return work;
|
|
88
|
+
}
|
|
89
|
+
persist() {
|
|
90
|
+
mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
91
|
+
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
|
92
|
+
writeFileSync(temporary, JSON.stringify(this.records), { mode: 0o600 });
|
|
93
|
+
renameSync(temporary, this.file);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ChannelProvider, ProviderMeta } from "../types.js";
|
|
2
|
+
export type AgentMailConfig = {
|
|
3
|
+
inbox: string;
|
|
4
|
+
region: "us" | "eu";
|
|
5
|
+
};
|
|
6
|
+
export type AgentMailTarget = {
|
|
7
|
+
to: string[];
|
|
8
|
+
cc?: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare const AGENTMAIL_BASE_URLS: {
|
|
11
|
+
readonly us: "https://api.agentmail.to";
|
|
12
|
+
readonly eu: "https://api.agentmail.eu";
|
|
13
|
+
};
|
|
14
|
+
export declare const AGENTMAIL_DEFAULT_SUBJECT = "Message from Stageflow";
|
|
15
|
+
export declare const AGENTMAIL_META: ProviderMeta;
|
|
16
|
+
export type AgentMailOptions = {
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
baseUrls?: Partial<Record<"us" | "eu", string>>;
|
|
19
|
+
sendTimeoutMs?: number;
|
|
20
|
+
checkTimeoutMs?: number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Sends mail from one existing AgentMail inbox through the REST API. Needs a key that can
|
|
24
|
+
* read the inbox and send messages. Receiving is not part of this phase.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createAgentMailProvider(options?: AgentMailOptions): ChannelProvider<AgentMailConfig, AgentMailTarget>;
|