settld 0.1.5 → 0.2.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 +32 -0
- package/SETTLD_VERSION +1 -1
- package/bin/settld.js +58 -0
- package/docs/CIRCLE_SANDBOX_E2E.md +12 -0
- package/docs/QUICKSTART_MCP.md +41 -1
- package/docs/QUICKSTART_MCP_HOSTS.md +156 -89
- package/docs/QUICKSTART_POLICY_PACKS.md +65 -0
- package/docs/QUICKSTART_PROFILES.md +198 -0
- package/docs/README.md +18 -0
- package/docs/RELEASE_CHECKLIST.md +26 -0
- package/docs/RELEASING.md +1 -0
- package/docs/SLO.md +62 -1
- package/docs/SUMMARY.md +1 -0
- package/docs/gitbook/README.md +13 -1
- package/docs/gitbook/quickstart.md +57 -58
- package/docs/integrations/README.md +1 -0
- package/docs/integrations/openclaw/PUBLIC_QUICKSTART.md +95 -0
- package/docs/ops/DISPUTE_FINANCE_RECONCILIATION_PACKET.md +56 -0
- package/docs/ops/KERNEL_V0_SHIP_GATE.md +3 -1
- package/docs/ops/MCP_COMPATIBILITY_MATRIX.md +8 -6
- package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +46 -9
- package/docs/ops/TRUST_CONFIG_WIZARD.md +37 -24
- package/docs/plans/2026-02-20-trust-os-v1-jira-backlog.md +348 -0
- package/docs/plans/2026-02-21-agent-economic-actor-operating-model.md +169 -0
- package/docs/plans/2026-02-21-trust-os-v1-strategy.md +241 -0
- package/docs/research/2026-02-21-agent-spend-host-landscape.md +57 -0
- package/docs/spec/ArbitrationOutcomeMapping.v1.md +62 -0
- package/docs/spec/DisputeCaseLifecycle.v1.md +51 -0
- package/docs/spec/OperatorAction.v1.md +90 -0
- package/docs/spec/PolicyDecision.v1.md +83 -0
- package/docs/spec/README.md +5 -0
- package/docs/spec/SettlementDecisionRecord.v2.md +2 -0
- package/docs/spec/schemas/OperatorAction.v1.schema.json +113 -0
- package/docs/spec/schemas/PolicyDecision.v1.schema.json +74 -0
- package/docs/spec/schemas/SettlementDecisionRecord.v2.schema.json +1 -0
- package/docs/spec/x402-error-codes.v1.txt +14 -0
- package/package.json +14 -1
- package/scripts/ci/build-launch-cutover-packet.mjs +177 -21
- package/scripts/ci/run-10x-throughput-drill.mjs +76 -4
- package/scripts/ci/run-10x-throughput-incident-rehearsal.mjs +49 -6
- package/scripts/ci/run-mcp-host-cert-matrix.mjs +201 -0
- package/scripts/ci/run-mcp-host-smoke.mjs +203 -5
- package/scripts/ci/run-offline-verification-parity-gate.mjs +762 -0
- package/scripts/ci/run-onboarding-host-success-gate.mjs +516 -0
- package/scripts/ci/run-onboarding-policy-slo-gate.mjs +537 -0
- package/scripts/ci/run-production-cutover-gate.mjs +540 -0
- package/scripts/ci/run-public-openclaw-npx-smoke.mjs +148 -0
- package/scripts/ci/run-release-promotion-guard.mjs +756 -0
- package/scripts/doctor/mcp-host.mjs +120 -0
- package/scripts/mcp/settld-mcp-server.mjs +330 -20
- package/scripts/ops/dispute-finance-reconciliation-packet.mjs +313 -0
- package/scripts/ops/hosted-baseline-evidence.mjs +286 -77
- package/scripts/ops/run-x402-hitl-smoke.mjs +607 -0
- package/scripts/policy/cli.mjs +600 -0
- package/scripts/profile/cli.mjs +1324 -0
- package/scripts/register-entity-secret.mjs +102 -0
- package/scripts/setup/circle-bootstrap.mjs +310 -0
- package/scripts/setup/host-config.mjs +617 -0
- package/scripts/setup/onboard.mjs +1337 -0
- package/scripts/setup/openclaw-onboard.mjs +423 -0
- package/scripts/setup/wizard.mjs +986 -0
- package/scripts/slo/check.mjs +123 -62
- package/scripts/spec/generate-protocol-vectors.mjs +88 -0
- package/scripts/test/run.sh +23 -9
- package/services/x402-gateway/src/server.js +147 -36
- package/src/api/app.js +2345 -267
- package/src/api/middleware/trust-kernel.js +114 -0
- package/src/api/openapi.js +598 -3
- package/src/api/persistence.js +184 -0
- package/src/api/store.js +277 -0
- package/src/core/agent-wallets.js +134 -0
- package/src/core/event-policy.js +21 -2
- package/src/core/operator-action.js +303 -0
- package/src/core/policy-decision.js +322 -0
- package/src/core/policy-packs.js +207 -0
- package/src/core/profile-fingerprint.js +27 -0
- package/src/core/profile-simulation-reasons.js +84 -0
- package/src/core/profile-templates.js +242 -0
- package/src/core/settlement-kernel.js +27 -1
- package/src/core/wallet-assignment-resolver.js +129 -0
- package/src/core/wallet-provider-bootstrap.js +365 -0
- package/src/db/store-pg.js +631 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
import { canonicalJsonStringify, normalizeForCanonicalJson } from "../../src/core/canonical-json.js";
|
|
9
|
+
import { sha256Hex } from "../../src/core/crypto.js";
|
|
10
|
+
import { SUPPORTED_HOSTS } from "../setup/host-config.mjs";
|
|
11
|
+
|
|
12
|
+
const REPORT_SCHEMA_VERSION = "OnboardingHostSuccessGateReport.v1";
|
|
13
|
+
const ARTIFACT_HASH_SCOPE = "OnboardingHostSuccessGateDeterministicCore.v1";
|
|
14
|
+
const DEFAULT_REPORT_PATH = "artifacts/gates/onboarding-host-success-gate.json";
|
|
15
|
+
const DEFAULT_METRICS_DIR = "artifacts/ops/onboarding-host-success";
|
|
16
|
+
const DEFAULT_ATTEMPTS_PER_HOST = 1;
|
|
17
|
+
const DEFAULT_MIN_SUCCESS_RATE_PCT = 90;
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
19
|
+
const DEFAULT_PROFILE_ID = "engineering-spend";
|
|
20
|
+
const DEFAULT_HOSTS = Object.freeze([...SUPPORTED_HOSTS]);
|
|
21
|
+
const SETTLD_BIN = path.resolve(process.cwd(), "bin", "settld.js");
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
return [
|
|
25
|
+
"usage: node scripts/ci/run-onboarding-host-success-gate.mjs [options]",
|
|
26
|
+
"",
|
|
27
|
+
"options:",
|
|
28
|
+
" --report <file> Output report path (default: artifacts/gates/onboarding-host-success-gate.json)",
|
|
29
|
+
" --metrics-dir <dir> Metrics directory (default: artifacts/ops/onboarding-host-success)",
|
|
30
|
+
" --hosts <csv> Hosts to test (default: codex,claude,cursor,openclaw)",
|
|
31
|
+
" --attempts <n> Attempts per host (default: 1)",
|
|
32
|
+
" --min-success-rate-pct <n> Minimum pass rate per host in percent (default: 90)",
|
|
33
|
+
" --timeout-ms <n> Per-attempt timeout (default: 60000)",
|
|
34
|
+
" --base-url <url> Settld API URL (required)",
|
|
35
|
+
" --tenant-id <id> Tenant ID (required)",
|
|
36
|
+
" --api-key <key> Tenant API key (required)",
|
|
37
|
+
" --profile-id <id> Starter profile ID (default: engineering-spend)",
|
|
38
|
+
" --clean-home-root <dir> Root dir for isolated HOME per attempt (default: os tmpdir)",
|
|
39
|
+
" --help Show help",
|
|
40
|
+
"",
|
|
41
|
+
"env fallbacks:",
|
|
42
|
+
" ONBOARDING_HOST_SUCCESS_GATE_REPORT_PATH",
|
|
43
|
+
" ONBOARDING_HOST_SUCCESS_METRICS_DIR",
|
|
44
|
+
" ONBOARDING_HOST_SUCCESS_HOSTS",
|
|
45
|
+
" ONBOARDING_HOST_SUCCESS_ATTEMPTS",
|
|
46
|
+
" ONBOARDING_HOST_SUCCESS_RATE_MIN_PCT",
|
|
47
|
+
" ONBOARDING_HOST_SUCCESS_TIMEOUT_MS",
|
|
48
|
+
" ONBOARDING_PROFILE_ID",
|
|
49
|
+
" ONBOARDING_CLEAN_HOME_ROOT",
|
|
50
|
+
" SETTLD_BASE_URL / SETTLD_RUNTIME_BASE_URL / SETTLD_RUNTIME_URL / SETTLD_API_URL",
|
|
51
|
+
" SETTLD_TENANT_ID / SETTLD_RUNTIME_TENANT_ID",
|
|
52
|
+
" SETTLD_API_KEY / SETTLD_RUNTIME_BEARER_TOKEN / SETTLD_BEARER_TOKEN / SETTLD_TOKEN"
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeOptionalString(value) {
|
|
57
|
+
if (typeof value !== "string") return null;
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
return trimmed === "" ? null : trimmed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toPositiveInt(value, fieldName, fallback) {
|
|
63
|
+
const resolved = normalizeOptionalString(value);
|
|
64
|
+
if (resolved === null) return fallback;
|
|
65
|
+
const parsed = Number(resolved);
|
|
66
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
67
|
+
throw new Error(`${fieldName} must be an integer >= 1`);
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toPercent(value, fieldName, fallback) {
|
|
73
|
+
const resolved = normalizeOptionalString(value);
|
|
74
|
+
if (resolved === null) return fallback;
|
|
75
|
+
const parsed = Number(resolved);
|
|
76
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) {
|
|
77
|
+
throw new Error(`${fieldName} must be between 0 and 100`);
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeHttpUrl(value, fieldName) {
|
|
83
|
+
const raw = normalizeOptionalString(value);
|
|
84
|
+
if (!raw) throw new Error(`${fieldName} is required`);
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = new URL(raw);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`${fieldName} must be a valid URL`);
|
|
90
|
+
}
|
|
91
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
92
|
+
throw new Error(`${fieldName} must use http(s)`);
|
|
93
|
+
}
|
|
94
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseHostsCsv(value) {
|
|
98
|
+
const raw = normalizeOptionalString(value);
|
|
99
|
+
if (!raw) return [...DEFAULT_HOSTS];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const part of raw.split(",")) {
|
|
103
|
+
const host = String(part).trim().toLowerCase();
|
|
104
|
+
if (!host || seen.has(host)) continue;
|
|
105
|
+
seen.add(host);
|
|
106
|
+
out.push(host);
|
|
107
|
+
}
|
|
108
|
+
if (!out.length) throw new Error("--hosts must contain at least one host");
|
|
109
|
+
for (const host of out) {
|
|
110
|
+
if (!SUPPORTED_HOSTS.includes(host)) {
|
|
111
|
+
throw new Error(`unsupported host in --hosts: ${host}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseJsonOrNull(text) {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(String(text ?? ""));
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function summarizeText(text, limit = 260) {
|
|
126
|
+
const value = String(text ?? "").trim().replace(/\s+/g, " ");
|
|
127
|
+
if (!value) return "";
|
|
128
|
+
if (value.length <= limit) return value;
|
|
129
|
+
return `${value.slice(0, limit - 3)}...`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildOnboardingArgs({ host, baseUrl, tenantId, apiKey, profileId }) {
|
|
133
|
+
return [
|
|
134
|
+
SETTLD_BIN,
|
|
135
|
+
"setup",
|
|
136
|
+
"--non-interactive",
|
|
137
|
+
"--host",
|
|
138
|
+
host,
|
|
139
|
+
"--base-url",
|
|
140
|
+
baseUrl,
|
|
141
|
+
"--tenant-id",
|
|
142
|
+
tenantId,
|
|
143
|
+
"--settld-api-key",
|
|
144
|
+
apiKey,
|
|
145
|
+
"--wallet-mode",
|
|
146
|
+
"none",
|
|
147
|
+
"--profile-id",
|
|
148
|
+
profileId,
|
|
149
|
+
"--preflight-only",
|
|
150
|
+
"--no-smoke",
|
|
151
|
+
"--format",
|
|
152
|
+
"json"
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function evaluateAttemptResult({ statusCode, stdout, stderr, durationMs }) {
|
|
157
|
+
const parsed = parseJsonOrNull(stdout);
|
|
158
|
+
if (statusCode !== 0) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
detail: summarizeText(stderr || stdout) || `exit ${statusCode}`,
|
|
162
|
+
parsed,
|
|
163
|
+
durationMs
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (!parsed || typeof parsed !== "object") {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
detail: "onboarding output is not valid JSON",
|
|
170
|
+
parsed,
|
|
171
|
+
durationMs
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const requiredChecks = new Set(["api_health", "tenant_auth", "profile_policy", "host_config"]);
|
|
175
|
+
const presentChecks = new Set(
|
|
176
|
+
Array.isArray(parsed?.preflight?.checks)
|
|
177
|
+
? parsed.preflight.checks.map((row) => String(row?.name ?? "").trim()).filter(Boolean)
|
|
178
|
+
: []
|
|
179
|
+
);
|
|
180
|
+
const missingChecks = [...requiredChecks].filter((check) => !presentChecks.has(check));
|
|
181
|
+
if (parsed.ok !== true || parsed.preflightOnly !== true || parsed?.preflight?.ok !== true || missingChecks.length > 0) {
|
|
182
|
+
const missingText = missingChecks.length ? `missing preflight checks: ${missingChecks.join(", ")}` : "preflight output not ok";
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
detail: missingText,
|
|
186
|
+
parsed,
|
|
187
|
+
durationMs
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
detail: "onboarding preflight passed",
|
|
193
|
+
parsed,
|
|
194
|
+
durationMs
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function runOnboardingHostAttempt({
|
|
199
|
+
host,
|
|
200
|
+
attempt,
|
|
201
|
+
baseUrl,
|
|
202
|
+
tenantId,
|
|
203
|
+
apiKey,
|
|
204
|
+
profileId,
|
|
205
|
+
timeoutMs,
|
|
206
|
+
cleanHomeRoot = null
|
|
207
|
+
} = {}) {
|
|
208
|
+
const root = cleanHomeRoot ? path.resolve(cleanHomeRoot) : os.tmpdir();
|
|
209
|
+
const tempHome = await mkdtemp(path.join(root, `settld-onboard-${host}-a${attempt}-`));
|
|
210
|
+
const startedAt = Date.now();
|
|
211
|
+
try {
|
|
212
|
+
const result = spawnSync(process.execPath, buildOnboardingArgs({ host, baseUrl, tenantId, apiKey, profileId }), {
|
|
213
|
+
cwd: process.cwd(),
|
|
214
|
+
env: {
|
|
215
|
+
...process.env,
|
|
216
|
+
HOME: tempHome,
|
|
217
|
+
USERPROFILE: tempHome
|
|
218
|
+
},
|
|
219
|
+
encoding: "utf8",
|
|
220
|
+
timeout: timeoutMs,
|
|
221
|
+
maxBuffer: 1_048_576,
|
|
222
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
223
|
+
});
|
|
224
|
+
const durationMs = Date.now() - startedAt;
|
|
225
|
+
if (result.error?.code === "ETIMEDOUT") {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
detail: `attempt timed out after ${timeoutMs}ms`,
|
|
229
|
+
durationMs
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return evaluateAttemptResult({
|
|
233
|
+
statusCode: result.status ?? 1,
|
|
234
|
+
stdout: result.stdout ?? "",
|
|
235
|
+
stderr: result.stderr ?? "",
|
|
236
|
+
durationMs
|
|
237
|
+
});
|
|
238
|
+
} finally {
|
|
239
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function roundRate(value) {
|
|
244
|
+
return Math.round(value * 100) / 100;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildHostMetricsText(row) {
|
|
248
|
+
const host = String(row.host ?? "");
|
|
249
|
+
return [
|
|
250
|
+
`onboarding_host_setup_attempts_total_gauge{host="${host}"} ${row.attempts}`,
|
|
251
|
+
`onboarding_host_setup_success_total_gauge{host="${host}"} ${row.successes}`,
|
|
252
|
+
`onboarding_host_setup_failure_total_gauge{host="${host}"} ${row.failures}`,
|
|
253
|
+
`onboarding_host_setup_success_rate_pct_gauge{host="${host}"} ${row.successRatePct}`
|
|
254
|
+
].join("\n") + "\n";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function evaluateHostSuccessVerdict(hosts, { minSuccessRatePct }) {
|
|
258
|
+
const rows = Array.isArray(hosts) ? hosts : [];
|
|
259
|
+
const requiredHosts = rows.length;
|
|
260
|
+
const passedHosts = rows.filter((row) => row?.status === "passed").length;
|
|
261
|
+
const failedHosts = requiredHosts - passedHosts;
|
|
262
|
+
const ok = requiredHosts > 0 && failedHosts === 0;
|
|
263
|
+
return {
|
|
264
|
+
ok,
|
|
265
|
+
status: ok ? "pass" : "fail",
|
|
266
|
+
requiredHosts,
|
|
267
|
+
passedHosts,
|
|
268
|
+
failedHosts,
|
|
269
|
+
minSuccessRatePct
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function computeOnboardingHostSuccessArtifactHash(report) {
|
|
274
|
+
const hosts = Array.isArray(report?.hosts) ? report.hosts : [];
|
|
275
|
+
const normalized = normalizeForCanonicalJson(
|
|
276
|
+
{
|
|
277
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
278
|
+
artifactHashScope: ARTIFACT_HASH_SCOPE,
|
|
279
|
+
context: {
|
|
280
|
+
attemptsPerHost: report?.context?.attemptsPerHost ?? null,
|
|
281
|
+
minSuccessRatePct: report?.context?.minSuccessRatePct ?? null,
|
|
282
|
+
hosts: report?.context?.hosts ?? []
|
|
283
|
+
},
|
|
284
|
+
hosts: hosts.map((row) => ({
|
|
285
|
+
host: row?.host ?? null,
|
|
286
|
+
attempts: row?.attempts ?? null,
|
|
287
|
+
successes: row?.successes ?? null,
|
|
288
|
+
failures: row?.failures ?? null,
|
|
289
|
+
successRatePct: row?.successRatePct ?? null,
|
|
290
|
+
status: row?.status ?? null
|
|
291
|
+
})),
|
|
292
|
+
verdict: report?.verdict ?? null
|
|
293
|
+
},
|
|
294
|
+
{ path: "$" }
|
|
295
|
+
);
|
|
296
|
+
return sha256Hex(canonicalJsonStringify(normalized));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function parseArgs(argv, env = process.env, cwd = process.cwd()) {
|
|
300
|
+
const out = {
|
|
301
|
+
help: false,
|
|
302
|
+
reportPath: path.resolve(cwd, normalizeOptionalString(env.ONBOARDING_HOST_SUCCESS_GATE_REPORT_PATH) ?? DEFAULT_REPORT_PATH),
|
|
303
|
+
metricsDir: path.resolve(cwd, normalizeOptionalString(env.ONBOARDING_HOST_SUCCESS_METRICS_DIR) ?? DEFAULT_METRICS_DIR),
|
|
304
|
+
hosts: parseHostsCsv(normalizeOptionalString(env.ONBOARDING_HOST_SUCCESS_HOSTS) ?? DEFAULT_HOSTS.join(",")),
|
|
305
|
+
attemptsPerHost: toPositiveInt(env.ONBOARDING_HOST_SUCCESS_ATTEMPTS, "ONBOARDING_HOST_SUCCESS_ATTEMPTS", DEFAULT_ATTEMPTS_PER_HOST),
|
|
306
|
+
minSuccessRatePct: toPercent(
|
|
307
|
+
env.ONBOARDING_HOST_SUCCESS_RATE_MIN_PCT,
|
|
308
|
+
"ONBOARDING_HOST_SUCCESS_RATE_MIN_PCT",
|
|
309
|
+
DEFAULT_MIN_SUCCESS_RATE_PCT
|
|
310
|
+
),
|
|
311
|
+
timeoutMs: toPositiveInt(env.ONBOARDING_HOST_SUCCESS_TIMEOUT_MS, "ONBOARDING_HOST_SUCCESS_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
|
|
312
|
+
baseUrl: normalizeOptionalString(
|
|
313
|
+
env.SETTLD_BASE_URL ?? env.SETTLD_RUNTIME_BASE_URL ?? env.SETTLD_RUNTIME_URL ?? env.SETTLD_API_URL
|
|
314
|
+
),
|
|
315
|
+
tenantId: normalizeOptionalString(env.SETTLD_TENANT_ID ?? env.SETTLD_RUNTIME_TENANT_ID),
|
|
316
|
+
apiKey: normalizeOptionalString(env.SETTLD_API_KEY ?? env.SETTLD_RUNTIME_BEARER_TOKEN ?? env.SETTLD_BEARER_TOKEN ?? env.SETTLD_TOKEN),
|
|
317
|
+
profileId: normalizeOptionalString(env.ONBOARDING_PROFILE_ID) ?? DEFAULT_PROFILE_ID,
|
|
318
|
+
cleanHomeRoot: normalizeOptionalString(env.ONBOARDING_CLEAN_HOME_ROOT)
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
322
|
+
const arg = String(argv[i] ?? "").trim();
|
|
323
|
+
if (!arg) continue;
|
|
324
|
+
if (arg === "--help" || arg === "-h") {
|
|
325
|
+
out.help = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (arg === "--report") {
|
|
329
|
+
const value = normalizeOptionalString(argv[i + 1]);
|
|
330
|
+
if (!value) throw new Error("--report requires a file path");
|
|
331
|
+
out.reportPath = path.resolve(cwd, value);
|
|
332
|
+
i += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (arg === "--metrics-dir") {
|
|
336
|
+
const value = normalizeOptionalString(argv[i + 1]);
|
|
337
|
+
if (!value) throw new Error("--metrics-dir requires a directory path");
|
|
338
|
+
out.metricsDir = path.resolve(cwd, value);
|
|
339
|
+
i += 1;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (arg === "--hosts") {
|
|
343
|
+
const value = normalizeOptionalString(argv[i + 1]);
|
|
344
|
+
if (!value) throw new Error("--hosts requires a csv value");
|
|
345
|
+
out.hosts = parseHostsCsv(value);
|
|
346
|
+
i += 1;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (arg === "--attempts") {
|
|
350
|
+
out.attemptsPerHost = toPositiveInt(argv[i + 1], "--attempts", DEFAULT_ATTEMPTS_PER_HOST);
|
|
351
|
+
i += 1;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (arg === "--min-success-rate-pct") {
|
|
355
|
+
out.minSuccessRatePct = toPercent(argv[i + 1], "--min-success-rate-pct", DEFAULT_MIN_SUCCESS_RATE_PCT);
|
|
356
|
+
i += 1;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (arg === "--timeout-ms") {
|
|
360
|
+
out.timeoutMs = toPositiveInt(argv[i + 1], "--timeout-ms", DEFAULT_TIMEOUT_MS);
|
|
361
|
+
i += 1;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (arg === "--base-url") {
|
|
365
|
+
out.baseUrl = normalizeOptionalString(argv[i + 1]);
|
|
366
|
+
i += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (arg === "--tenant-id") {
|
|
370
|
+
out.tenantId = normalizeOptionalString(argv[i + 1]);
|
|
371
|
+
i += 1;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (arg === "--api-key") {
|
|
375
|
+
out.apiKey = normalizeOptionalString(argv[i + 1]);
|
|
376
|
+
i += 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (arg === "--profile-id") {
|
|
380
|
+
out.profileId = normalizeOptionalString(argv[i + 1]) ?? DEFAULT_PROFILE_ID;
|
|
381
|
+
i += 1;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (arg === "--clean-home-root") {
|
|
385
|
+
const value = normalizeOptionalString(argv[i + 1]);
|
|
386
|
+
if (!value) throw new Error("--clean-home-root requires a directory path");
|
|
387
|
+
out.cleanHomeRoot = path.resolve(cwd, value);
|
|
388
|
+
i += 1;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!out.help) {
|
|
395
|
+
out.baseUrl = normalizeHttpUrl(out.baseUrl, "--base-url");
|
|
396
|
+
if (!out.tenantId) throw new Error("--tenant-id is required");
|
|
397
|
+
if (!out.apiKey) throw new Error("--api-key is required");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function runOnboardingHostSuccessGate(args, env = process.env, cwd = process.cwd(), deps = {}) {
|
|
404
|
+
const runAttempt = deps.runAttempt ?? runOnboardingHostAttempt;
|
|
405
|
+
const generatedAt = new Date().toISOString();
|
|
406
|
+
const hostRows = [];
|
|
407
|
+
const blockingIssues = [];
|
|
408
|
+
|
|
409
|
+
for (const host of args.hosts) {
|
|
410
|
+
const attempts = [];
|
|
411
|
+
for (let index = 1; index <= args.attemptsPerHost; index += 1) {
|
|
412
|
+
const attempt = await runAttempt({
|
|
413
|
+
host,
|
|
414
|
+
attempt: index,
|
|
415
|
+
baseUrl: args.baseUrl,
|
|
416
|
+
tenantId: args.tenantId,
|
|
417
|
+
apiKey: args.apiKey,
|
|
418
|
+
profileId: args.profileId,
|
|
419
|
+
timeoutMs: args.timeoutMs,
|
|
420
|
+
cleanHomeRoot: args.cleanHomeRoot,
|
|
421
|
+
env,
|
|
422
|
+
cwd
|
|
423
|
+
});
|
|
424
|
+
attempts.push({
|
|
425
|
+
attempt: index,
|
|
426
|
+
ok: attempt.ok === true,
|
|
427
|
+
durationMs: Number(attempt.durationMs ?? 0),
|
|
428
|
+
detail: String(attempt.detail ?? "")
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const successes = attempts.filter((row) => row.ok === true).length;
|
|
432
|
+
const failures = attempts.length - successes;
|
|
433
|
+
const successRatePct = attempts.length > 0 ? roundRate((successes / attempts.length) * 100) : 0;
|
|
434
|
+
const status = successRatePct >= args.minSuccessRatePct ? "passed" : "failed";
|
|
435
|
+
const row = {
|
|
436
|
+
host,
|
|
437
|
+
attempts: attempts.length,
|
|
438
|
+
successes,
|
|
439
|
+
failures,
|
|
440
|
+
successRatePct,
|
|
441
|
+
status,
|
|
442
|
+
runs: attempts
|
|
443
|
+
};
|
|
444
|
+
hostRows.push(row);
|
|
445
|
+
if (status !== "passed") {
|
|
446
|
+
const firstFailure = attempts.find((attempt) => attempt.ok !== true);
|
|
447
|
+
blockingIssues.push({
|
|
448
|
+
host,
|
|
449
|
+
code: "host_success_rate_below_threshold",
|
|
450
|
+
detail:
|
|
451
|
+
firstFailure?.detail ||
|
|
452
|
+
`host success rate ${successRatePct}% below threshold ${args.minSuccessRatePct}%`
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
hostRows.sort((left, right) => String(left.host).localeCompare(String(right.host)));
|
|
458
|
+
const verdict = evaluateHostSuccessVerdict(hostRows, { minSuccessRatePct: args.minSuccessRatePct });
|
|
459
|
+
|
|
460
|
+
const report = {
|
|
461
|
+
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
462
|
+
generatedAt,
|
|
463
|
+
artifactHashScope: ARTIFACT_HASH_SCOPE,
|
|
464
|
+
context: {
|
|
465
|
+
baseUrl: args.baseUrl,
|
|
466
|
+
tenantId: args.tenantId,
|
|
467
|
+
profileId: args.profileId,
|
|
468
|
+
attemptsPerHost: args.attemptsPerHost,
|
|
469
|
+
minSuccessRatePct: args.minSuccessRatePct,
|
|
470
|
+
timeoutMs: args.timeoutMs,
|
|
471
|
+
hosts: [...args.hosts]
|
|
472
|
+
},
|
|
473
|
+
hosts: hostRows,
|
|
474
|
+
blockingIssues,
|
|
475
|
+
verdict
|
|
476
|
+
};
|
|
477
|
+
report.artifactHash = computeOnboardingHostSuccessArtifactHash(report);
|
|
478
|
+
|
|
479
|
+
await mkdir(path.dirname(args.reportPath), { recursive: true });
|
|
480
|
+
await writeFile(args.reportPath, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
481
|
+
|
|
482
|
+
await mkdir(args.metricsDir, { recursive: true });
|
|
483
|
+
for (const row of hostRows) {
|
|
484
|
+
const metricsPath = path.join(args.metricsDir, `${row.host}.prom`);
|
|
485
|
+
await writeFile(metricsPath, buildHostMetricsText(row), "utf8");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { report, reportPath: args.reportPath, metricsDir: args.metricsDir };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function main() {
|
|
492
|
+
const args = parseArgs(process.argv.slice(2), process.env, process.cwd());
|
|
493
|
+
if (args.help) {
|
|
494
|
+
process.stdout.write(`${usage()}\n`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const { report, reportPath, metricsDir } = await runOnboardingHostSuccessGate(args, process.env, process.cwd());
|
|
498
|
+
process.stdout.write(`wrote onboarding host success gate report: ${reportPath}\n`);
|
|
499
|
+
process.stdout.write(`wrote onboarding host success metrics: ${metricsDir}\n`);
|
|
500
|
+
if (!report.verdict.ok) process.exitCode = 1;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const isDirectExecution = (() => {
|
|
504
|
+
try {
|
|
505
|
+
return import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
})();
|
|
510
|
+
|
|
511
|
+
if (isDirectExecution) {
|
|
512
|
+
main().catch((err) => {
|
|
513
|
+
process.stderr.write(`${err?.stack ?? err?.message ?? String(err)}\n`);
|
|
514
|
+
process.exit(1);
|
|
515
|
+
});
|
|
516
|
+
}
|