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
|
@@ -78,6 +78,12 @@ function preflight(input) {
|
|
|
78
78
|
reason: `stage declares skill "${input.stage.skill}"; the claude agent backend does not support skills yet`,
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
+
if (input.stage.channels?.length) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: "stage declares channels; the claude agent backend does not provide send_message yet, so the stage would run without it",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
81
87
|
return resolveClaudeModel(input.stage.model);
|
|
82
88
|
}
|
|
83
89
|
function resultFromCapture(capture, providerError) {
|
package/dist/agent/piAdapter.js
CHANGED
|
@@ -49,6 +49,7 @@ import { remindUntilEmitted } from "./stageTurnDriver.js";
|
|
|
49
49
|
import { createAskOperatorTool, } from "../tools/askOperator.js";
|
|
50
50
|
import { createWriteStageArtifactTool } from "../tools/writeStageArtifact.js";
|
|
51
51
|
import { createSendEmailTool } from "../tools/sendEmail.js";
|
|
52
|
+
import { createSendMessageTool } from "../tools/sendMessage.js";
|
|
52
53
|
import { createReplyEmailTool } from "../tools/replyEmail.js";
|
|
53
54
|
import { createGetEmailTool, createSearchEmailTool, createDownloadEmailAttachmentTool } from "../tools/readEmail.js";
|
|
54
55
|
import "./cursorProvider.js";
|
|
@@ -493,6 +494,9 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
|
|
|
493
494
|
].join("\n")
|
|
494
495
|
: `Create factory stage artifacts under ${attemptArtifactsPath} relative to the run folder.`;
|
|
495
496
|
return [
|
|
497
|
+
...(input.stage.channels?.length ? [
|
|
498
|
+
`Stageflow channels: ${JSON.stringify(input.stage.channels.map(channel => ({ channel: channel.alias ?? channel.connection, operations: channel.operations })))}. Use send_message with the channel name. Recipients are fixed by configuration and you cannot change them. Use a stable operationKey for each intended message. Provider acceptance does not prove delivery. Never resend an unknown submission automatically.`,
|
|
499
|
+
] : []),
|
|
496
500
|
...(input.stage.email?.length ? [
|
|
497
501
|
`Stageflow email permissions: ${JSON.stringify(input.stage.email)}. Use only declared accounts and operations. Use a stable operationKey for each intended message. Provider acceptance does not prove delivery. Never resend an unknown submission automatically.`,
|
|
498
502
|
] : []),
|
|
@@ -998,6 +1002,11 @@ async function prepareStageSessionWiring(input, sessionManager, existingAskWaitC
|
|
|
998
1002
|
}
|
|
999
1003
|
tools.push(...mcpTools.names);
|
|
1000
1004
|
}
|
|
1005
|
+
if (input.channels && input.stage.channels?.some(channel => channel.operations.includes("send"))) {
|
|
1006
|
+
const definition = createSendMessageTool(input.channels);
|
|
1007
|
+
customTools.push(defineTool(definition));
|
|
1008
|
+
tools.push(definition.name);
|
|
1009
|
+
}
|
|
1001
1010
|
if (input.email) {
|
|
1002
1011
|
const permitted = new Set(input.stage.email?.flatMap(permission => permission.operations));
|
|
1003
1012
|
const definitions = [
|
package/dist/agent/port.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { StageActivityEvent } from "./activity.js";
|
|
|
8
8
|
import type { ResolvedMcpServers } from "../config/resolveStageMcpServers.js";
|
|
9
9
|
import type { StageRoots } from "../runtime/stageRoots.js";
|
|
10
10
|
import type { StageEmail } from "../email/host.js";
|
|
11
|
+
import type { StageChannels } from "../channels/host.js";
|
|
11
12
|
import type { QaExchange } from "../hitl/qaTrail.js";
|
|
12
13
|
import type { StageUsage } from "../types/usage.js";
|
|
13
14
|
/** Opaque to runtime; adapters interpret. */
|
|
@@ -65,6 +66,7 @@ export type StageRunInput = {
|
|
|
65
66
|
onAssistantTextDelta?: (delta: string) => void;
|
|
66
67
|
skillFilePath?: string;
|
|
67
68
|
email?: StageEmail;
|
|
69
|
+
channels?: StageChannels;
|
|
68
70
|
/** Bundled browser skill; set by the runtime when the stage has `browser`. */
|
|
69
71
|
browserSkillFilePath?: string;
|
|
70
72
|
forkEmitContext?: ForkEmitContext;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ConnectionEntry, CredentialEntry } from "./types.js";
|
|
2
|
+
export declare const CONNECTIONS_FILE_LIMIT_BYTES = 262144;
|
|
3
|
+
export declare const MAX_CONNECTIONS = 100;
|
|
4
|
+
export declare const MAX_CREDENTIALS = 20;
|
|
5
|
+
export type ConnectionsFile = {
|
|
6
|
+
credentials: CredentialEntry[];
|
|
7
|
+
connections: ConnectionEntry[];
|
|
8
|
+
};
|
|
9
|
+
export declare function parseConnectionsYaml(text: string): ConnectionsFile;
|
|
10
|
+
/** A missing file is an empty configuration. Any other fault stops startup instead of half-applying. */
|
|
11
|
+
export declare function loadConnectionsFile(file: string): ConnectionsFile;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { parseDocument } from "yaml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ChannelError } from "./errors.js";
|
|
5
|
+
import { SECRET_REF_PATTERN } from "./secrets.js";
|
|
6
|
+
export const CONNECTIONS_FILE_LIMIT_BYTES = 262144;
|
|
7
|
+
export const MAX_CONNECTIONS = 100;
|
|
8
|
+
export const MAX_CREDENTIALS = 20;
|
|
9
|
+
const idSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/);
|
|
10
|
+
const providerSchema = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/);
|
|
11
|
+
const secretRefSchema = z.string().regex(SECRET_REF_PATTERN);
|
|
12
|
+
const secretFieldSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9_]{0,63}$/);
|
|
13
|
+
const secretsSchema = z.record(secretFieldSchema, secretRefSchema).default({});
|
|
14
|
+
const credentialSchema = z.object({
|
|
15
|
+
id: idSchema,
|
|
16
|
+
provider: providerSchema,
|
|
17
|
+
secrets: secretsSchema,
|
|
18
|
+
}).strict();
|
|
19
|
+
const connectionSchema = z.object({
|
|
20
|
+
id: idSchema,
|
|
21
|
+
provider: providerSchema,
|
|
22
|
+
displayName: z.string().min(1).max(200).optional(),
|
|
23
|
+
enabled: z.boolean().default(true),
|
|
24
|
+
credential: idSchema.optional(),
|
|
25
|
+
config: z.record(z.string(), z.unknown()).default({}),
|
|
26
|
+
secrets: secretsSchema,
|
|
27
|
+
}).strict();
|
|
28
|
+
const fileSchema = z.object({
|
|
29
|
+
version: z.literal(1),
|
|
30
|
+
credentials: z.array(credentialSchema).max(MAX_CREDENTIALS).default([]),
|
|
31
|
+
connections: z.array(connectionSchema).max(MAX_CONNECTIONS).default([]),
|
|
32
|
+
}).strict();
|
|
33
|
+
export function parseConnectionsYaml(text) {
|
|
34
|
+
const document = parseDocument(text, { uniqueKeys: true, strict: true });
|
|
35
|
+
if (document.errors.length || document.warnings.length)
|
|
36
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = document.toJS({ maxAliasCount: 0 });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
43
|
+
}
|
|
44
|
+
const parsed = fileSchema.safeParse(raw);
|
|
45
|
+
if (!parsed.success)
|
|
46
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
47
|
+
assertUnique(parsed.data.credentials.map(entry => entry.id));
|
|
48
|
+
assertUnique(parsed.data.connections.map(entry => entry.id));
|
|
49
|
+
return {
|
|
50
|
+
credentials: parsed.data.credentials.map(entry => ({ ...entry, secrets: entry.secrets })),
|
|
51
|
+
connections: parsed.data.connections.map(entry => ({ ...entry, secrets: entry.secrets })),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function assertUnique(ids) {
|
|
55
|
+
if (new Set(ids).size !== ids.length)
|
|
56
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
57
|
+
}
|
|
58
|
+
/** A missing file is an empty configuration. Any other fault stops startup instead of half-applying. */
|
|
59
|
+
export function loadConnectionsFile(file) {
|
|
60
|
+
let descriptor;
|
|
61
|
+
try {
|
|
62
|
+
descriptor = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error.code === "ENOENT")
|
|
66
|
+
return { credentials: [], connections: [] };
|
|
67
|
+
throw new ChannelError("CHANNEL_STORAGE_FAILED");
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const stat = fstatSync(descriptor);
|
|
71
|
+
if (!stat.isFile())
|
|
72
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
73
|
+
if (stat.size > CONNECTIONS_FILE_LIMIT_BYTES)
|
|
74
|
+
throw new ChannelError("CHANNEL_RESOURCE_LIMIT");
|
|
75
|
+
const bytes = Buffer.alloc(CONNECTIONS_FILE_LIMIT_BYTES + 1);
|
|
76
|
+
let length = 0;
|
|
77
|
+
while (length < bytes.length) {
|
|
78
|
+
const count = readSync(descriptor, bytes, length, bytes.length - length, null);
|
|
79
|
+
if (!count)
|
|
80
|
+
break;
|
|
81
|
+
length += count;
|
|
82
|
+
}
|
|
83
|
+
if (length > CONNECTIONS_FILE_LIMIT_BYTES)
|
|
84
|
+
throw new ChannelError("CHANNEL_RESOURCE_LIMIT");
|
|
85
|
+
return parseConnectionsYaml(bytes.toString("utf8", 0, length));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof ChannelError)
|
|
89
|
+
throw error;
|
|
90
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
closeSync(descriptor);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ConnectionEntry, CredentialEntry } from "./types.js";
|
|
2
|
+
/** Plain-object form of an entry. Optional fields are omitted when unset. */
|
|
3
|
+
export type ConnectionYamlEntry = {
|
|
4
|
+
id: string;
|
|
5
|
+
provider: string;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
credential?: string;
|
|
9
|
+
config?: Record<string, unknown>;
|
|
10
|
+
secrets?: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
export declare function toYamlEntry(entry: Pick<ConnectionEntry, "id" | "provider" | "enabled" | "config" | "secrets"> & {
|
|
13
|
+
displayName?: string;
|
|
14
|
+
credential?: string;
|
|
15
|
+
}): ConnectionYamlEntry;
|
|
16
|
+
/** Edits keep comments and key order that the user wrote. */
|
|
17
|
+
export declare function addConnectionYaml(text: string, entry: ConnectionYamlEntry, credential?: {
|
|
18
|
+
id: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
secrets: Record<string, string>;
|
|
21
|
+
}): string;
|
|
22
|
+
export type ConnectionPatch = {
|
|
23
|
+
displayName?: string | null;
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
config?: Record<string, unknown>;
|
|
26
|
+
secrets?: Record<string, string>;
|
|
27
|
+
};
|
|
28
|
+
export declare function updateConnectionYaml(text: string, id: string, patch: ConnectionPatch): string;
|
|
29
|
+
/** Removes the entry and any credential that no remaining connection uses. Returns what was dropped. */
|
|
30
|
+
export declare function removeConnectionYaml(text: string, id: string): {
|
|
31
|
+
text: string;
|
|
32
|
+
removedCredential?: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function readConnectionsText(file: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Replaces the file atomically. A bind-mounted file cannot be renamed over, so that case falls
|
|
37
|
+
* back to writing in place.
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeConnectionsText(file: string, text: string): void;
|
|
40
|
+
export type { CredentialEntry };
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isSeq, parseDocument } from "yaml";
|
|
4
|
+
import { CONNECTIONS_FILE_LIMIT_BYTES } from "./config.js";
|
|
5
|
+
import { ChannelError } from "./errors.js";
|
|
6
|
+
export function toYamlEntry(entry) {
|
|
7
|
+
return {
|
|
8
|
+
id: entry.id,
|
|
9
|
+
provider: entry.provider,
|
|
10
|
+
...(entry.displayName ? { displayName: entry.displayName } : {}),
|
|
11
|
+
...(entry.enabled === false ? { enabled: false } : {}),
|
|
12
|
+
...(entry.credential ? { credential: entry.credential } : {}),
|
|
13
|
+
...(Object.keys(entry.config).length ? { config: entry.config } : {}),
|
|
14
|
+
...(Object.keys(entry.secrets).length ? { secrets: entry.secrets } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function open(text) {
|
|
18
|
+
const source = text.trim().length ? text : "version: 1\n";
|
|
19
|
+
const document = parseDocument(source, { uniqueKeys: true, strict: true });
|
|
20
|
+
if (document.errors.length)
|
|
21
|
+
throw new ChannelError("CHANNEL_INVALID_INPUT");
|
|
22
|
+
return document;
|
|
23
|
+
}
|
|
24
|
+
function sequence(document, key) {
|
|
25
|
+
let seq = document.get(key, true);
|
|
26
|
+
if (!isSeq(seq)) {
|
|
27
|
+
document.set(key, document.createNode([]));
|
|
28
|
+
seq = document.get(key, true);
|
|
29
|
+
}
|
|
30
|
+
const result = seq;
|
|
31
|
+
result.flow = false;
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function indexOf(seq, id) {
|
|
35
|
+
return seq.items.findIndex(item => item?.get?.("id") === id);
|
|
36
|
+
}
|
|
37
|
+
/** Edits keep comments and key order that the user wrote. */
|
|
38
|
+
export function addConnectionYaml(text, entry, credential) {
|
|
39
|
+
const document = open(text);
|
|
40
|
+
if (credential) {
|
|
41
|
+
const credentials = sequence(document, "credentials");
|
|
42
|
+
if (indexOf(credentials, credential.id) < 0)
|
|
43
|
+
credentials.add(document.createNode({ id: credential.id, provider: credential.provider, secrets: credential.secrets }));
|
|
44
|
+
}
|
|
45
|
+
const connections = sequence(document, "connections");
|
|
46
|
+
if (indexOf(connections, entry.id) >= 0)
|
|
47
|
+
throw new ChannelError("CHANNEL_OPERATION_CONFLICT", false, { connectionId: entry.id });
|
|
48
|
+
connections.add(document.createNode(entry));
|
|
49
|
+
return document.toString();
|
|
50
|
+
}
|
|
51
|
+
export function updateConnectionYaml(text, id, patch) {
|
|
52
|
+
const document = open(text);
|
|
53
|
+
const connections = sequence(document, "connections");
|
|
54
|
+
const index = indexOf(connections, id);
|
|
55
|
+
if (index < 0)
|
|
56
|
+
throw new ChannelError("CHANNEL_NOT_FOUND", false, { connectionId: id });
|
|
57
|
+
const node = connections.items[index];
|
|
58
|
+
if (patch.displayName !== undefined) {
|
|
59
|
+
if (patch.displayName === null || patch.displayName === "")
|
|
60
|
+
node.delete("displayName");
|
|
61
|
+
else
|
|
62
|
+
node.set("displayName", patch.displayName);
|
|
63
|
+
}
|
|
64
|
+
if (patch.enabled !== undefined) {
|
|
65
|
+
if (patch.enabled)
|
|
66
|
+
node.delete("enabled");
|
|
67
|
+
else
|
|
68
|
+
node.set("enabled", false);
|
|
69
|
+
}
|
|
70
|
+
if (patch.config !== undefined) {
|
|
71
|
+
if (Object.keys(patch.config).length)
|
|
72
|
+
node.set("config", document.createNode(patch.config));
|
|
73
|
+
else
|
|
74
|
+
node.delete("config");
|
|
75
|
+
}
|
|
76
|
+
for (const [field, ref] of Object.entries(patch.secrets ?? {}))
|
|
77
|
+
node.setIn(["secrets", field], ref);
|
|
78
|
+
return document.toString();
|
|
79
|
+
}
|
|
80
|
+
/** Removes the entry and any credential that no remaining connection uses. Returns what was dropped. */
|
|
81
|
+
export function removeConnectionYaml(text, id) {
|
|
82
|
+
const document = open(text);
|
|
83
|
+
const connections = sequence(document, "connections");
|
|
84
|
+
const index = indexOf(connections, id);
|
|
85
|
+
if (index < 0)
|
|
86
|
+
throw new ChannelError("CHANNEL_NOT_FOUND", false, { connectionId: id });
|
|
87
|
+
const credentialId = connections.items[index].get("credential");
|
|
88
|
+
connections.items.splice(index, 1);
|
|
89
|
+
let removedCredential;
|
|
90
|
+
if (typeof credentialId === "string") {
|
|
91
|
+
const stillUsed = connections.items.some(item => item.get("credential") === credentialId);
|
|
92
|
+
const credentials = document.get("credentials", true);
|
|
93
|
+
if (!stillUsed && isSeq(credentials)) {
|
|
94
|
+
const at = indexOf(credentials, credentialId);
|
|
95
|
+
if (at >= 0) {
|
|
96
|
+
credentials.items.splice(at, 1);
|
|
97
|
+
removedCredential = credentialId;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { text: document.toString(), ...(removedCredential ? { removedCredential } : {}) };
|
|
102
|
+
}
|
|
103
|
+
export function readConnectionsText(file) {
|
|
104
|
+
if (!existsSync(file))
|
|
105
|
+
return "";
|
|
106
|
+
const text = readFileSync(file, "utf8");
|
|
107
|
+
if (Buffer.byteLength(text) > CONNECTIONS_FILE_LIMIT_BYTES)
|
|
108
|
+
throw new ChannelError("CHANNEL_RESOURCE_LIMIT");
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Replaces the file atomically. A bind-mounted file cannot be renamed over, so that case falls
|
|
113
|
+
* back to writing in place.
|
|
114
|
+
*/
|
|
115
|
+
export function writeConnectionsText(file, text) {
|
|
116
|
+
if (Buffer.byteLength(text) > CONNECTIONS_FILE_LIMIT_BYTES)
|
|
117
|
+
throw new ChannelError("CHANNEL_RESOURCE_LIMIT");
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
120
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(temporary, text, { mode: 0o644 });
|
|
123
|
+
renameSync(temporary, file);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const code = error.code;
|
|
127
|
+
try {
|
|
128
|
+
unlinkSync(temporary);
|
|
129
|
+
}
|
|
130
|
+
catch { /* nothing to clean */ }
|
|
131
|
+
if (code !== "EBUSY" && code !== "EXDEV" && code !== "EPERM")
|
|
132
|
+
throw error;
|
|
133
|
+
const descriptor = openSync(file, constants.O_WRONLY | constants.O_TRUNC);
|
|
134
|
+
try {
|
|
135
|
+
writeSync(descriptor, text);
|
|
136
|
+
fsyncSync(descriptor);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
closeSync(descriptor);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof ChannelError)
|
|
145
|
+
throw error;
|
|
146
|
+
throw new ChannelError("CHANNEL_STORAGE_FAILED");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** What the edit module needs from the secret store. `ChannelSecretStore` satisfies it. */
|
|
2
|
+
export interface SecretWriter {
|
|
3
|
+
get(name: string): string | undefined;
|
|
4
|
+
set(name: string, value: string): void;
|
|
5
|
+
delete(name: string): void;
|
|
6
|
+
deleteWithPrefix(prefix: string): void;
|
|
7
|
+
}
|
|
8
|
+
export type ConnectionEdit = {
|
|
9
|
+
/** Secret store name to value. Written before the file so a reference never points at nothing. */
|
|
10
|
+
secrets?: Readonly<Record<string, string>>;
|
|
11
|
+
/** Pure transform of the file text. May name secret prefixes to drop once the edit has committed. */
|
|
12
|
+
rewrite(text: string): {
|
|
13
|
+
text: string;
|
|
14
|
+
dropSecretPrefixes?: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type ConnectionEditsOptions = {
|
|
18
|
+
file: () => string;
|
|
19
|
+
store: SecretWriter;
|
|
20
|
+
/** Throws if the new text would not load. Runs before anything is written to the file. */
|
|
21
|
+
validate: (text: string) => void;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* One atomic change to connections: secrets and file together. Either both change or neither
|
|
25
|
+
* does. A secret that already existed is restored to its previous value, not deleted, and a
|
|
26
|
+
* failure while writing, validating or saving leaves nothing half-applied. Dropping the secrets
|
|
27
|
+
* of a removed connection happens only after the file has been saved, and a failure there only
|
|
28
|
+
* leaves an orphaned secret behind, never a broken connection.
|
|
29
|
+
*/
|
|
30
|
+
export declare class ConnectionEdits {
|
|
31
|
+
private readonly options;
|
|
32
|
+
constructor(options: ConnectionEditsOptions);
|
|
33
|
+
apply(edit: ConnectionEdit): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readConnectionsText, writeConnectionsText } from "./configWriter.js";
|
|
2
|
+
/**
|
|
3
|
+
* One atomic change to connections: secrets and file together. Either both change or neither
|
|
4
|
+
* does. A secret that already existed is restored to its previous value, not deleted, and a
|
|
5
|
+
* failure while writing, validating or saving leaves nothing half-applied. Dropping the secrets
|
|
6
|
+
* of a removed connection happens only after the file has been saved, and a failure there only
|
|
7
|
+
* leaves an orphaned secret behind, never a broken connection.
|
|
8
|
+
*/
|
|
9
|
+
export class ConnectionEdits {
|
|
10
|
+
options;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
apply(edit) {
|
|
15
|
+
const { store } = this.options;
|
|
16
|
+
const file = this.options.file();
|
|
17
|
+
const text = readConnectionsText(file);
|
|
18
|
+
const previous = new Map();
|
|
19
|
+
let drop = [];
|
|
20
|
+
try {
|
|
21
|
+
for (const [name, value] of Object.entries(edit.secrets ?? {})) {
|
|
22
|
+
previous.set(name, store.get(name));
|
|
23
|
+
store.set(name, value);
|
|
24
|
+
}
|
|
25
|
+
const next = edit.rewrite(text);
|
|
26
|
+
this.options.validate(next.text);
|
|
27
|
+
writeConnectionsText(file, next.text);
|
|
28
|
+
drop = next.dropSecretPrefixes ?? [];
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
for (const [name, value] of previous) {
|
|
32
|
+
try {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
store.delete(name);
|
|
35
|
+
else
|
|
36
|
+
store.set(name, value);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* restoring is best effort; the original error is the one to report */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
for (const prefix of drop) {
|
|
45
|
+
try {
|
|
46
|
+
store.deleteWithPrefix(prefix);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* an orphaned secret is harmless and is overwritten if the id is reused */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChannelErrorCode } from "./errors.js";
|
|
2
|
+
export type ChannelErrorInfo = {
|
|
3
|
+
/** One sentence for logs, preflight output and the status line. */
|
|
4
|
+
reason: string;
|
|
5
|
+
/** What to do about it, when there is something to do. */
|
|
6
|
+
hint?: string;
|
|
7
|
+
/** HTTP status when the code is returned by the connections API. */
|
|
8
|
+
status: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The one place a channel error code is explained. A `Record` over the code union makes a code
|
|
12
|
+
* without an entry a compile error. Whether a failure is retryable is not here: it depends on
|
|
13
|
+
* the operation (a 5xx is retryable in a check but an unknown outcome in a send), so it stays
|
|
14
|
+
* where the failure is classified.
|
|
15
|
+
*/
|
|
16
|
+
export declare const CHANNEL_ERROR_INFO: Record<ChannelErrorCode, ChannelErrorInfo>;
|
|
17
|
+
export declare function channelErrorReason(code: string): string;
|
|
18
|
+
export declare function channelErrorStatus(code: string): number;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a channel error code is explained. A `Record` over the code union makes a code
|
|
3
|
+
* without an entry a compile error. Whether a failure is retryable is not here: it depends on
|
|
4
|
+
* the operation (a 5xx is retryable in a check but an unknown outcome in a send), so it stays
|
|
5
|
+
* where the failure is classified.
|
|
6
|
+
*/
|
|
7
|
+
export const CHANNEL_ERROR_INFO = {
|
|
8
|
+
CHANNEL_INVALID_INPUT: {
|
|
9
|
+
reason: "The settings are not valid for this provider account",
|
|
10
|
+
hint: "Check the values (for example an unknown inbox or a different workspace) and try again. If the file is hand-edited, fix the entry and restart.",
|
|
11
|
+
status: 400,
|
|
12
|
+
},
|
|
13
|
+
CHANNEL_NOT_FOUND: { reason: "The connection is not defined", status: 404 },
|
|
14
|
+
CHANNEL_DISABLED: { reason: "The connection is disabled", hint: "Enable it, or remove it from the stage.", status: 400 },
|
|
15
|
+
CHANNEL_PROVIDER_UNSUPPORTED: {
|
|
16
|
+
reason: "This provider is not supported here, or its SDK is not installed",
|
|
17
|
+
hint: "iMessage and WhatsApp need the Spectrum SDK packages, which are optional dependencies.",
|
|
18
|
+
status: 400,
|
|
19
|
+
},
|
|
20
|
+
CHANNEL_SECRET_UNAVAILABLE: {
|
|
21
|
+
reason: "A secret for this connection is not set on the host",
|
|
22
|
+
hint: "Set the environment variable each secret names (or its _FILE form) on the host, or use Replace to store one, then check again.",
|
|
23
|
+
status: 500,
|
|
24
|
+
},
|
|
25
|
+
CHANNEL_RESOURCE_LIMIT: { reason: "The request or connections.yaml is too large", status: 413 },
|
|
26
|
+
CHANNEL_STORAGE_FAILED: {
|
|
27
|
+
reason: "Stageflow could not read or write connections.yaml or the secret store on this host",
|
|
28
|
+
hint: "Check that the data directory is writable.",
|
|
29
|
+
status: 500,
|
|
30
|
+
},
|
|
31
|
+
CHANNEL_UNAUTHORIZED: {
|
|
32
|
+
reason: "Credentials were rejected or lack a required permission",
|
|
33
|
+
hint: "The provider refused the credential: it is invalid, or it lacks a permission this provider needs (for AgentMail, reading the inbox and sending). Check the key and its permissions, then try again.",
|
|
34
|
+
status: 500,
|
|
35
|
+
},
|
|
36
|
+
CHANNEL_AUTH_FAILED: {
|
|
37
|
+
reason: "Credentials were rejected",
|
|
38
|
+
hint: "Replace the secret with a valid one, then check again.",
|
|
39
|
+
status: 500,
|
|
40
|
+
},
|
|
41
|
+
CHANNEL_CONNECTION_FAILED: {
|
|
42
|
+
reason: "Could not reach the provider",
|
|
43
|
+
hint: "This is usually temporary. Check the network and try again.",
|
|
44
|
+
status: 500,
|
|
45
|
+
},
|
|
46
|
+
CHANNEL_TIMEOUT: { reason: "The provider did not answer in time", hint: "Try again.", status: 500 },
|
|
47
|
+
CHANNEL_RATE_LIMIT: { reason: "The provider is rate limiting this account", hint: "Wait and try again.", status: 500 },
|
|
48
|
+
CHANNEL_WINDOW_CLOSED: {
|
|
49
|
+
reason: "WhatsApp's 24-hour reply window is closed",
|
|
50
|
+
hint: "Pin an approved template on the stage so it can message first.",
|
|
51
|
+
status: 500,
|
|
52
|
+
},
|
|
53
|
+
CHANNEL_TARGET_INVALID: { reason: "The pinned recipient is not valid for this provider", status: 400 },
|
|
54
|
+
CHANNEL_RECIPIENT_REJECTED: {
|
|
55
|
+
reason: "The provider will not message this recipient",
|
|
56
|
+
hint: "For iMessage on a Free or Pro Photon project, add them as a project user and have them text their assigned line (the \"Texts on\" number) once.",
|
|
57
|
+
status: 500,
|
|
58
|
+
},
|
|
59
|
+
CHANNEL_SEND_OUTCOME_UNKNOWN: {
|
|
60
|
+
reason: "The message may or may not have been sent",
|
|
61
|
+
hint: "Check the provider before sending again. Stageflow never resends it automatically.",
|
|
62
|
+
status: 500,
|
|
63
|
+
},
|
|
64
|
+
CHANNEL_OPERATION_CONFLICT: { reason: "That already exists, or the key was reused with different content", status: 409 },
|
|
65
|
+
};
|
|
66
|
+
export function channelErrorReason(code) {
|
|
67
|
+
return CHANNEL_ERROR_INFO[code]?.reason ?? code;
|
|
68
|
+
}
|
|
69
|
+
export function channelErrorStatus(code) {
|
|
70
|
+
return CHANNEL_ERROR_INFO[code]?.status ?? 500;
|
|
71
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ChannelErrorCode = "CHANNEL_INVALID_INPUT" | "CHANNEL_NOT_FOUND" | "CHANNEL_DISABLED" | "CHANNEL_PROVIDER_UNSUPPORTED" | "CHANNEL_SECRET_UNAVAILABLE" | "CHANNEL_RESOURCE_LIMIT" | "CHANNEL_STORAGE_FAILED" | "CHANNEL_UNAUTHORIZED" | "CHANNEL_AUTH_FAILED" | "CHANNEL_CONNECTION_FAILED" | "CHANNEL_TIMEOUT" | "CHANNEL_RATE_LIMIT" | "CHANNEL_WINDOW_CLOSED" | "CHANNEL_TARGET_INVALID" | "CHANNEL_RECIPIENT_REJECTED" | "CHANNEL_SEND_OUTCOME_UNKNOWN" | "CHANNEL_OPERATION_CONFLICT";
|
|
2
|
+
export type ChannelErrorDetail = {
|
|
3
|
+
connectionId?: string;
|
|
4
|
+
field?: string;
|
|
5
|
+
};
|
|
6
|
+
/** The message is always the code. Detail carries only non-secret identifiers. */
|
|
7
|
+
export declare class ChannelError extends Error {
|
|
8
|
+
readonly code: ChannelErrorCode;
|
|
9
|
+
readonly retryable: boolean;
|
|
10
|
+
readonly detail?: ChannelErrorDetail | undefined;
|
|
11
|
+
constructor(code: ChannelErrorCode, retryable?: boolean, detail?: ChannelErrorDetail | undefined);
|
|
12
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** The message is always the code. Detail carries only non-secret identifiers. */
|
|
2
|
+
export class ChannelError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
retryable;
|
|
5
|
+
detail;
|
|
6
|
+
constructor(code, retryable = false, detail) {
|
|
7
|
+
super(code);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.retryable = retryable;
|
|
10
|
+
this.detail = detail;
|
|
11
|
+
this.name = "ChannelError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ChannelRegistry } from "./registry.js";
|
|
2
|
+
import type { ConnectionHealth } from "./types.js";
|
|
3
|
+
export type ChannelHealthOptions = {
|
|
4
|
+
registry: ChannelRegistry;
|
|
5
|
+
intervalMs?: number;
|
|
6
|
+
jitter?: number;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
maxBackoffFactor?: number;
|
|
9
|
+
random?: () => number;
|
|
10
|
+
onResult?: (connectionId: string, health: ConnectionHealth) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare const DEFAULT_HEALTH_INTERVAL_MS = 300000;
|
|
13
|
+
/**
|
|
14
|
+
* Host-only background check. It keeps the last result per connection in memory so the
|
|
15
|
+
* Connections page can show current status. It never sends a message and keeps no secrets.
|
|
16
|
+
* Failures back off up to `maxBackoffFactor` times the interval; successes return to it.
|
|
17
|
+
*/
|
|
18
|
+
export declare class ChannelHealthMonitor {
|
|
19
|
+
private readonly registry;
|
|
20
|
+
private readonly intervalMs;
|
|
21
|
+
private readonly jitter;
|
|
22
|
+
private readonly timeoutMs;
|
|
23
|
+
private readonly maxBackoffFactor;
|
|
24
|
+
private readonly random;
|
|
25
|
+
private readonly onResult?;
|
|
26
|
+
private readonly results;
|
|
27
|
+
private readonly failures;
|
|
28
|
+
private readonly timers;
|
|
29
|
+
private readonly inflight;
|
|
30
|
+
private controller;
|
|
31
|
+
private running;
|
|
32
|
+
constructor(options: ChannelHealthOptions);
|
|
33
|
+
snapshot(): Record<string, ConnectionHealth>;
|
|
34
|
+
start(): void;
|
|
35
|
+
stop(): Promise<void>;
|
|
36
|
+
/** Runs one check now, outside the schedule. */
|
|
37
|
+
checkNow(connectionId: string): Promise<ConnectionHealth>;
|
|
38
|
+
private schedule;
|
|
39
|
+
private nextDelay;
|
|
40
|
+
private runCheck;
|
|
41
|
+
}
|