agent-relay-server 0.5.0 → 0.6.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/README.md +8 -8
- package/package.json +1 -1
- package/public/dashboard.js +106 -17
- package/public/icons/agent-relay-192.png +0 -0
- package/public/icons/agent-relay-512.png +0 -0
- package/public/icons/agent-relay.svg +14 -0
- package/public/index.html +91 -4
- package/public/manifest.webmanifest +33 -0
- package/public/sw.js +58 -0
- package/src/cli.ts +80 -17
- package/src/connectors.ts +256 -0
- package/src/db.ts +413 -25
- package/src/index.ts +25 -1
- package/src/routes.ts +380 -32
- package/src/security.ts +2 -1
- package/src/sse.ts +6 -0
- package/src/types.ts +92 -3
package/src/cli.ts
CHANGED
|
@@ -485,11 +485,13 @@ async function handleMessageCommand(args: string[], defaults: { claimable?: bool
|
|
|
485
485
|
const message = await apiRequest("POST", "/api/messages", {
|
|
486
486
|
from,
|
|
487
487
|
to: target,
|
|
488
|
+
kind: claimable ? "task" : "chat",
|
|
488
489
|
subject,
|
|
489
490
|
channel,
|
|
490
491
|
body,
|
|
491
492
|
replyTo,
|
|
492
493
|
claimable,
|
|
494
|
+
payload: claimable ? { title: subject || "Claimable task" } : undefined,
|
|
493
495
|
idempotencyKey,
|
|
494
496
|
});
|
|
495
497
|
if (json) console.log(JSON.stringify(message, null, 2));
|
|
@@ -606,9 +608,16 @@ async function detectAgentId(): Promise<string | undefined> {
|
|
|
606
608
|
const explicit = process.env.AGENT_RELAY_ID;
|
|
607
609
|
if (explicit) return explicit;
|
|
608
610
|
|
|
611
|
+
const contextMatch = currentAgentContextId();
|
|
612
|
+
if (contextMatch) return contextMatch;
|
|
613
|
+
|
|
609
614
|
const cwd = process.cwd();
|
|
615
|
+
const explicitCodexState = process.env.AGENT_RELAY_CODEX_STATE_PATH
|
|
616
|
+
? readCodexState(process.env.AGENT_RELAY_CODEX_STATE_PATH)
|
|
617
|
+
: null;
|
|
618
|
+
if (explicitCodexState?.agentId) return explicitCodexState.agentId;
|
|
619
|
+
|
|
610
620
|
const stateCandidates = [
|
|
611
|
-
process.env.AGENT_RELAY_CODEX_STATE_PATH,
|
|
612
621
|
resolve(cwd, "codex/runtime/live-state.json"),
|
|
613
622
|
...collectCodexStateFiles(),
|
|
614
623
|
].filter((path): path is string => Boolean(path));
|
|
@@ -616,7 +625,7 @@ async function detectAgentId(): Promise<string | undefined> {
|
|
|
616
625
|
const codexMatch = newestCodexAgentId(stateCandidates, cwd);
|
|
617
626
|
if (codexMatch) return codexMatch;
|
|
618
627
|
|
|
619
|
-
const claudeMatch =
|
|
628
|
+
const claudeMatch = currentClaudeAgentId();
|
|
620
629
|
if (claudeMatch) return claudeMatch;
|
|
621
630
|
|
|
622
631
|
try {
|
|
@@ -624,18 +633,77 @@ async function detectAgentId(): Promise<string | undefined> {
|
|
|
624
633
|
const cwdAgents = agents
|
|
625
634
|
.filter((agent) => agent.status !== "offline" && agent.ready !== false && agent.meta?.cwd === cwd && typeof agent.id === "string")
|
|
626
635
|
.sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
|
|
627
|
-
|
|
636
|
+
const uniqueAgentIds = uniqueStrings(cwdAgents.map((agent) => agent.id!));
|
|
637
|
+
return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
|
|
628
638
|
} catch {
|
|
629
639
|
return undefined;
|
|
630
640
|
}
|
|
631
641
|
}
|
|
632
642
|
|
|
643
|
+
function currentAgentContextId(): string | undefined {
|
|
644
|
+
const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
|
|
645
|
+
if (explicitPath) {
|
|
646
|
+
const explicit = readAgentContext(explicitPath);
|
|
647
|
+
if (explicit?.agentId) return explicit.agentId;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const candidates = collectAgentContextFiles();
|
|
651
|
+
const matches = candidates
|
|
652
|
+
.map((path) => readAgentContext(path))
|
|
653
|
+
.filter((context): context is { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } => Boolean(context))
|
|
654
|
+
.filter((context) => context.matchEnv.some((match) => process.env[match.name] === match.value))
|
|
655
|
+
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
|
656
|
+
|
|
657
|
+
const uniqueAgentIds = uniqueStrings(matches.map((context) => context.agentId));
|
|
658
|
+
return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function readAgentContext(path: string): { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } | null {
|
|
662
|
+
if (!existsSync(path)) return null;
|
|
663
|
+
try {
|
|
664
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as {
|
|
665
|
+
agentId?: unknown;
|
|
666
|
+
updatedAt?: unknown;
|
|
667
|
+
matchEnv?: unknown;
|
|
668
|
+
};
|
|
669
|
+
if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
|
|
670
|
+
const matchEnv = Array.isArray(parsed.matchEnv)
|
|
671
|
+
? parsed.matchEnv.flatMap((item) => {
|
|
672
|
+
if (!item || typeof item !== "object") return [];
|
|
673
|
+
const record = item as { name?: unknown; value?: unknown };
|
|
674
|
+
return typeof record.name === "string" && typeof record.value === "string"
|
|
675
|
+
? [{ name: record.name, value: record.value }]
|
|
676
|
+
: [];
|
|
677
|
+
})
|
|
678
|
+
: [];
|
|
679
|
+
const stat = statSync(path);
|
|
680
|
+
const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
|
|
681
|
+
return {
|
|
682
|
+
agentId: parsed.agentId,
|
|
683
|
+
matchEnv,
|
|
684
|
+
updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
|
|
685
|
+
};
|
|
686
|
+
} catch {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function collectAgentContextFiles(): string[] {
|
|
692
|
+
const roots = [
|
|
693
|
+
join(process.env.HOME || "", ".agent-relay", "contexts"),
|
|
694
|
+
].filter((root) => root && existsSync(root));
|
|
695
|
+
const files: string[] = [];
|
|
696
|
+
for (const root of roots) collectFiles(root, ".json", files, 2);
|
|
697
|
+
return files;
|
|
698
|
+
}
|
|
699
|
+
|
|
633
700
|
function newestCodexAgentId(paths: string[], cwd: string): string | undefined {
|
|
634
701
|
const states = paths
|
|
635
702
|
.map((path) => readCodexState(path))
|
|
636
703
|
.filter((state): state is { agentId: string; cwd?: string; updatedAtMs: number } => Boolean(state))
|
|
637
704
|
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
|
|
638
|
-
|
|
705
|
+
const cwdAgentIds = uniqueStrings(states.filter((state) => state.cwd === cwd).map((state) => state.agentId));
|
|
706
|
+
return cwdAgentIds.length === 1 ? cwdAgentIds[0] : undefined;
|
|
639
707
|
}
|
|
640
708
|
|
|
641
709
|
function readCodexState(path: string): { agentId: string; cwd?: string; updatedAtMs: number } | null {
|
|
@@ -678,29 +746,24 @@ function collectFiles(dir: string, name: string, output: string[], depth: number
|
|
|
678
746
|
try {
|
|
679
747
|
const stat = statSync(path);
|
|
680
748
|
if (stat.isDirectory()) collectFiles(path, name, output, depth - 1);
|
|
681
|
-
else if (entry === name) output.push(path);
|
|
749
|
+
else if (name.startsWith(".") ? entry.endsWith(name) : entry === name) output.push(path);
|
|
682
750
|
} catch {
|
|
683
751
|
// Ignore state files that disappear while scanning.
|
|
684
752
|
}
|
|
685
753
|
}
|
|
686
754
|
}
|
|
687
755
|
|
|
688
|
-
function
|
|
689
|
-
|
|
756
|
+
function currentClaudeAgentId(): string | undefined {
|
|
757
|
+
const sessionKey = process.env.CLAUDE_CODE_SESSION_ID || String(process.ppid || "");
|
|
758
|
+
if (!sessionKey) return undefined;
|
|
759
|
+
const safeSessionKey = sessionKey.replace(/[^A-Za-z0-9_.:-]/g, "_");
|
|
760
|
+
const statePath = join("/tmp", `agent-relay-instance-${safeSessionKey}.state`);
|
|
761
|
+
if (!existsSync(statePath)) return undefined;
|
|
690
762
|
try {
|
|
691
|
-
|
|
692
|
-
.filter((entry) => entry.startsWith("agent-relay-instance-") && entry.endsWith(".state"))
|
|
693
|
-
.map((entry) => join("/tmp", entry))
|
|
694
|
-
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
|
|
695
|
-
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
696
|
-
for (const candidate of candidates) {
|
|
697
|
-
const id = readFileSync(candidate.path, "utf8").split(/\r?\n/)[0]?.trim();
|
|
698
|
-
if (id) return id;
|
|
699
|
-
}
|
|
763
|
+
return readFileSync(statePath, "utf8").split(/\r?\n/)[0]?.trim() || undefined;
|
|
700
764
|
} catch {
|
|
701
765
|
return undefined;
|
|
702
766
|
}
|
|
703
|
-
return undefined;
|
|
704
767
|
}
|
|
705
768
|
|
|
706
769
|
function formatPairs(pairs: any[]): string {
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
|
|
5
|
+
import { ValidationError } from "./db";
|
|
6
|
+
|
|
7
|
+
const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
|
|
8
|
+
const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
|
|
9
|
+
const VALID_ACTIONS = new Set(["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"]);
|
|
10
|
+
|
|
11
|
+
function connectorRegistryDir(): string {
|
|
12
|
+
const configured = process.env.AGENT_RELAY_CONNECTORS_DIR;
|
|
13
|
+
if (configured) return resolveHome(configured);
|
|
14
|
+
return join(homedir(), ".agent-relay", "connectors");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveHome(path: string): string {
|
|
18
|
+
return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function connectorDir(id: string): string {
|
|
22
|
+
validateConnectorId(id);
|
|
23
|
+
return join(connectorRegistryDir(), id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJsonFile(path: string): unknown {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRecordFile(path: string): Record<string, unknown> | undefined {
|
|
31
|
+
if (!existsSync(path)) return undefined;
|
|
32
|
+
const parsed = readJsonFile(path);
|
|
33
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateConnectorId(id: string): void {
|
|
41
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(id)) {
|
|
42
|
+
throw new ValidationError("connector id must be lowercase alphanumeric plus dot, underscore, or dash");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeConnectorManifest(value: unknown): ConnectorManifest {
|
|
47
|
+
if (!isRecord(value)) throw new ValidationError("connector manifest must be an object");
|
|
48
|
+
if (value.schema !== CONNECTOR_SCHEMA) throw new ValidationError(`connector manifest schema must be ${CONNECTOR_SCHEMA}`);
|
|
49
|
+
const id = stringField(value, "id");
|
|
50
|
+
validateConnectorId(id);
|
|
51
|
+
const kind = stringField(value, "kind");
|
|
52
|
+
if (!VALID_KINDS.has(kind)) throw new ValidationError("connector kind must be channel, event, provider, or orchestrator");
|
|
53
|
+
const binary = stringField(value, "binary");
|
|
54
|
+
const displayName = stringField(value, "displayName");
|
|
55
|
+
const version = stringField(value, "version");
|
|
56
|
+
const commands = value.commands;
|
|
57
|
+
if (!isRecord(commands)) throw new ValidationError("connector commands must be an object");
|
|
58
|
+
|
|
59
|
+
const normalizedCommands: ConnectorManifest["commands"] = {};
|
|
60
|
+
for (const [action, command] of Object.entries(commands)) {
|
|
61
|
+
if (!VALID_ACTIONS.has(action)) throw new ValidationError(`unsupported connector action: ${action}`);
|
|
62
|
+
if (!Array.isArray(command) || !command.every((part) => typeof part === "string" && part.trim())) {
|
|
63
|
+
throw new ValidationError(`connector command ${action} must be a non-empty string array`);
|
|
64
|
+
}
|
|
65
|
+
normalizedCommands[action as ConnectorAction] = command;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
schema: CONNECTOR_SCHEMA,
|
|
70
|
+
id,
|
|
71
|
+
kind: kind as ConnectorManifest["kind"],
|
|
72
|
+
packageName: optionalString(value.packageName),
|
|
73
|
+
binary,
|
|
74
|
+
displayName,
|
|
75
|
+
description: optionalString(value.description),
|
|
76
|
+
version,
|
|
77
|
+
capabilities: stringArray(value.capabilities, "capabilities"),
|
|
78
|
+
commands: normalizedCommands,
|
|
79
|
+
configSchema: isRecord(value.configSchema) ? value.configSchema : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stringField(record: Record<string, unknown>, field: string): string {
|
|
84
|
+
const value = record[field];
|
|
85
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`connector ${field} required`);
|
|
86
|
+
if (value.length > 500) throw new ValidationError(`connector ${field} is too long`);
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function optionalString(value: unknown): string | undefined {
|
|
91
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stringArray(value: unknown, field: string): string[] {
|
|
95
|
+
if (value === undefined) return [];
|
|
96
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
|
97
|
+
throw new ValidationError(`connector ${field} must be an array of strings`);
|
|
98
|
+
}
|
|
99
|
+
return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function registerConnectorManifest(manifest: ConnectorManifest, opts: { config?: Record<string, unknown>; state?: Record<string, unknown> } = {}): ConnectorSummary {
|
|
103
|
+
const normalized = normalizeConnectorManifest(manifest);
|
|
104
|
+
const dir = connectorDir(normalized.id);
|
|
105
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
106
|
+
writeFileSync(join(dir, "manifest.json"), JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
|
|
107
|
+
if (opts.config) writeConnectorConfig(normalized.id, opts.config);
|
|
108
|
+
if (opts.state) writeFileSync(join(dir, "state.json"), JSON.stringify(opts.state, null, 2) + "\n", { mode: 0o600 });
|
|
109
|
+
return getConnector(normalized.id)!;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function listConnectors(): ConnectorSummary[] {
|
|
113
|
+
const root = connectorRegistryDir();
|
|
114
|
+
if (!existsSync(root)) return [];
|
|
115
|
+
return readdirSync(root, { withFileTypes: true })
|
|
116
|
+
.filter((entry) => entry.isDirectory())
|
|
117
|
+
.flatMap((entry) => {
|
|
118
|
+
try {
|
|
119
|
+
const connector = getConnector(entry.name);
|
|
120
|
+
return connector ? [connector] : [];
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getConnector(id: string): ConnectorSummary | null {
|
|
129
|
+
const dir = connectorDir(id);
|
|
130
|
+
const manifestPath = join(dir, "manifest.json");
|
|
131
|
+
if (!existsSync(manifestPath)) return null;
|
|
132
|
+
const manifest = normalizeConnectorManifest(readJsonFile(manifestPath));
|
|
133
|
+
const config = readRecordFile(join(dir, "config.json"));
|
|
134
|
+
const state = readRecordFile(join(dir, "state.json"));
|
|
135
|
+
return {
|
|
136
|
+
id: manifest.id,
|
|
137
|
+
kind: manifest.kind,
|
|
138
|
+
displayName: manifest.displayName,
|
|
139
|
+
description: manifest.description,
|
|
140
|
+
version: manifest.version,
|
|
141
|
+
packageName: manifest.packageName,
|
|
142
|
+
binary: manifest.binary,
|
|
143
|
+
capabilities: manifest.capabilities,
|
|
144
|
+
registryPath: dir,
|
|
145
|
+
manifest,
|
|
146
|
+
config,
|
|
147
|
+
state,
|
|
148
|
+
runtime: summarizeRuntime(manifest, state),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function summarizeRuntime(manifest: ConnectorManifest, state?: Record<string, unknown>): ConnectorSummary["runtime"] {
|
|
153
|
+
return {
|
|
154
|
+
installed: true,
|
|
155
|
+
enabled: typeof state?.enabled === "boolean" ? state.enabled : undefined,
|
|
156
|
+
running: typeof state?.running === "boolean" ? state.running : undefined,
|
|
157
|
+
status: typeof state?.status === "string" && ["ok", "warn", "error", "unknown"].includes(state.status) ? state.status as any : "unknown",
|
|
158
|
+
detail: typeof state?.detail === "string" ? state.detail : undefined,
|
|
159
|
+
updatedAt: typeof state?.updatedAt === "string" ? state.updatedAt : undefined,
|
|
160
|
+
raw: state?.raw ?? (manifest.commands.status ? undefined : { detail: "status command not declared" }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readConnectorConfig(id: string): Record<string, unknown> {
|
|
165
|
+
return readRecordFile(join(connectorDir(id), "config.json")) ?? {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function writeConnectorConfig(id: string, config: Record<string, unknown>): Record<string, unknown> {
|
|
169
|
+
if (!isRecord(config)) throw new ValidationError("connector config must be an object");
|
|
170
|
+
const path = join(connectorDir(id), "config.json");
|
|
171
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
172
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
173
|
+
return config;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function runConnectorAction(id: string, action: ConnectorAction): ConnectorActionResult {
|
|
177
|
+
if (!VALID_ACTIONS.has(action)) throw new ValidationError("unsupported connector action");
|
|
178
|
+
const connector = getConnector(id);
|
|
179
|
+
if (!connector) throw new ValidationError(`connector ${id} not found`);
|
|
180
|
+
const command = connector.manifest.commands[action];
|
|
181
|
+
if (!command?.length) throw new ValidationError(`connector ${id} does not support ${action}`);
|
|
182
|
+
|
|
183
|
+
const proc = Bun.spawnSync({
|
|
184
|
+
cmd: command,
|
|
185
|
+
stdout: "pipe",
|
|
186
|
+
stderr: "pipe",
|
|
187
|
+
env: { ...process.env, AGENT_RELAY_CONNECTOR_ID: id, AGENT_RELAY_CONNECTORS_DIR: connectorRegistryDir() },
|
|
188
|
+
});
|
|
189
|
+
const stdout = new TextDecoder().decode(proc.stdout).trim();
|
|
190
|
+
const stderr = new TextDecoder().decode(proc.stderr).trim();
|
|
191
|
+
const parsed = parseJsonMaybe(stdout);
|
|
192
|
+
const ok = proc.success;
|
|
193
|
+
const result: ConnectorActionResult = {
|
|
194
|
+
connectorId: id,
|
|
195
|
+
action,
|
|
196
|
+
command,
|
|
197
|
+
ok,
|
|
198
|
+
exitCode: proc.exitCode,
|
|
199
|
+
stdout,
|
|
200
|
+
stderr,
|
|
201
|
+
parsed,
|
|
202
|
+
};
|
|
203
|
+
if ((action === "status" || action === "doctor") && ok) {
|
|
204
|
+
writeActionState(id, action, parsed ?? stdout);
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseJsonMaybe(value: string): unknown {
|
|
210
|
+
if (!value) return undefined;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(value);
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function writeActionState(id: string, action: ConnectorAction, raw: unknown): void {
|
|
219
|
+
const current = readRecordFile(join(connectorDir(id), "state.json")) ?? {};
|
|
220
|
+
const next = {
|
|
221
|
+
...current,
|
|
222
|
+
status: action === "doctor" ? doctorStatus(raw) : statusValue(raw),
|
|
223
|
+
detail: detailValue(raw),
|
|
224
|
+
running: runningValue(raw),
|
|
225
|
+
enabled: enabledValue(raw),
|
|
226
|
+
updatedAt: new Date().toISOString(),
|
|
227
|
+
raw,
|
|
228
|
+
};
|
|
229
|
+
writeFileSync(join(connectorDir(id), "state.json"), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function statusValue(raw: unknown): ConnectorSummary["runtime"]["status"] {
|
|
233
|
+
if (isRecord(raw) && typeof raw.status === "string" && ["ok", "warn", "error", "unknown"].includes(raw.status)) return raw.status as any;
|
|
234
|
+
return "unknown";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function doctorStatus(raw: unknown): ConnectorSummary["runtime"]["status"] {
|
|
238
|
+
if (!isRecord(raw) || !Array.isArray(raw.checks)) return statusValue(raw);
|
|
239
|
+
if (raw.checks.some((check) => isRecord(check) && check.status === "error")) return "error";
|
|
240
|
+
if (raw.checks.some((check) => isRecord(check) && check.status === "warn")) return "warn";
|
|
241
|
+
return "ok";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function detailValue(raw: unknown): string | undefined {
|
|
245
|
+
if (isRecord(raw) && typeof raw.detail === "string") return raw.detail;
|
|
246
|
+
if (isRecord(raw) && typeof raw.summary === "string") return raw.summary;
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function runningValue(raw: unknown): boolean | undefined {
|
|
251
|
+
return isRecord(raw) && typeof raw.running === "boolean" ? raw.running : undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function enabledValue(raw: unknown): boolean | undefined {
|
|
255
|
+
return isRecord(raw) && typeof raw.enabled === "boolean" ? raw.enabled : undefined;
|
|
256
|
+
}
|