@kici-dev/shared 0.1.14 → 0.1.16

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 CHANGED
@@ -1 +1,13 @@
1
- TBD
1
+ # @kici-dev/shared
2
+
3
+ Shared utilities for the KiCI CI/CD stack — logging, zx setup, crypto, telemetry, health and metrics routes. No business logic. Re-exports `@kici-dev/core`.
4
+
5
+ This is an internal support library — other `@kici-dev` packages depend on it; it is not meant to be installed directly.
6
+
7
+ Part of [KiCI](https://kici.dev) — CI/CD workflows as TypeScript code: author them with full language power, dry-run them locally, and run them on your own infrastructure.
8
+
9
+ ## Links
10
+
11
+ - Documentation: <https://docs.kici.dev/architecture/overview/>
12
+ - Source: <https://github.com/kici-dev/kici-public/tree/main/packages/shared>
13
+ - License: Apache-2.0
@@ -187,6 +187,7 @@ export interface SeedEnvironmentOpts {
187
187
  waitTimerSeconds?: number | null;
188
188
  holdExpirySeconds?: number | null;
189
189
  minimumTrust?: string | null;
190
+ globPattern?: string | null;
190
191
  }
191
192
  export interface SeedEnvironmentResult {
192
193
  envId: string;
@@ -198,6 +199,24 @@ export interface SeedEnvironmentResult {
198
199
  * are JSON-serialised server-side; pass them as plain arrays or objects.
199
200
  */
200
201
  export declare function seedEnvironmentDirect(databaseUrl: string, opts: SeedEnvironmentOpts): Promise<SeedEnvironmentResult>;
202
+ export interface DeleteEnvironmentOpts {
203
+ orgId: string;
204
+ name: string;
205
+ }
206
+ /**
207
+ * Delete an environment keyed by (org_id, name). Returns whether a row was
208
+ * removed. The `environment_bindings`, `environment_variables`, and
209
+ * `environment_source_overrides` children all carry
210
+ * `FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE`,
211
+ * so a single DELETE on `environments` cascades to those children. The
212
+ * `held_runs` FK uses `ON DELETE SET NULL`, so terminal held-run history
213
+ * survives the delete with a null environment reference. Pending held runs
214
+ * still reference the environment, so this helper pre-checks their count and
215
+ * throws before issuing the DELETE — approve or reject them first.
216
+ */
217
+ export declare function deleteEnvironmentDirect(databaseUrl: string, opts: DeleteEnvironmentOpts): Promise<{
218
+ deleted: boolean;
219
+ }>;
201
220
  export interface SeedEnvironmentBindingOpts {
202
221
  orgId: string;
203
222
  envName: string;
@@ -219,6 +238,7 @@ export interface SetEnvironmentPolicyOpts {
219
238
  holdExpirySeconds?: number | null;
220
239
  minimumTrust?: string | null;
221
240
  enabled?: boolean;
241
+ allowLocalExecution?: boolean;
222
242
  }
223
243
  /**
224
244
  * UPDATE only the policy fields that were explicitly provided. Columns that
@@ -1462,5 +1482,14 @@ export declare function clearPeerCredentialsRevokedAtByIdsDirect(databaseUrl: st
1462
1482
  export declare function countActivePeerCredentialsByInstanceDirect(databaseUrl: string, opts: {
1463
1483
  instanceId: string;
1464
1484
  }): Promise<number>;
1485
+ /**
1486
+ * Terminate every idle backend of the connecting user except our own
1487
+ * connection. Mirrors what a Postgres leader demotion does to idle pooled
1488
+ * connections — used by resilience tests to verify the pg pool error
1489
+ * handlers absorb the termination without a process restart.
1490
+ *
1491
+ * Returns the number of backends terminated.
1492
+ */
1493
+ export declare function terminateIdleDbBackendsDirect(databaseUrl: string): Promise<number>;
1465
1494
  export {};
1466
1495
  //# sourceMappingURL=db-admin.d.ts.map
package/dist/db-admin.js CHANGED
@@ -351,7 +351,8 @@ const ENV_POLICY_COLUMNS = new Set([
351
351
  "wait_timer_seconds",
352
352
  "hold_expiry_seconds",
353
353
  "minimum_trust",
354
- "enabled"
354
+ "enabled",
355
+ "allow_local_execution"
355
356
  ]);
356
357
  /**
357
358
  * Upsert an environment row keyed by (org_id, name). Returns the env id and
@@ -370,9 +371,9 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
370
371
  const reviewersJson = opts.requiredReviewers === void 0 ? null : JSON.stringify(opts.requiredReviewers);
371
372
  const row = (await pool.query(`INSERT INTO environments
372
373
  (org_id, name, type, enabled, branch_restrictions, required_reviewers,
373
- wait_timer_seconds, hold_expiry_seconds, minimum_trust)
374
+ wait_timer_seconds, hold_expiry_seconds, minimum_trust, glob_pattern)
374
375
  VALUES ($1, $2, COALESCE($3, 'fixed'), COALESCE($4, true), $5::jsonb, $6::jsonb,
375
- $7, COALESCE($8, 86400), $9)
376
+ $7, COALESCE($8, 86400), $9, $10)
376
377
  ON CONFLICT (org_id, name) DO UPDATE SET
377
378
  type = COALESCE(EXCLUDED.type, environments.type),
378
379
  enabled = EXCLUDED.enabled,
@@ -381,6 +382,7 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
381
382
  wait_timer_seconds = EXCLUDED.wait_timer_seconds,
382
383
  hold_expiry_seconds = EXCLUDED.hold_expiry_seconds,
383
384
  minimum_trust = EXCLUDED.minimum_trust,
385
+ glob_pattern = COALESCE(EXCLUDED.glob_pattern, environments.glob_pattern),
384
386
  updated_at = now()
385
387
  RETURNING id, (xmax = 0) AS inserted`, [
386
388
  opts.orgId,
@@ -391,7 +393,8 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
391
393
  reviewersJson,
392
394
  opts.waitTimerSeconds ?? null,
393
395
  opts.holdExpirySeconds ?? null,
394
- opts.minimumTrust ?? null
396
+ opts.minimumTrust ?? null,
397
+ opts.globPattern ?? null
395
398
  ])).rows[0];
396
399
  if (!row) throw new Error(`environment: upsert returned no row for ${opts.name}`);
397
400
  return {
@@ -403,6 +406,33 @@ async function seedEnvironmentDirect(databaseUrl, opts) {
403
406
  }
404
407
  }
405
408
  /**
409
+ * Delete an environment keyed by (org_id, name). Returns whether a row was
410
+ * removed. The `environment_bindings`, `environment_variables`, and
411
+ * `environment_source_overrides` children all carry
412
+ * `FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE`,
413
+ * so a single DELETE on `environments` cascades to those children. The
414
+ * `held_runs` FK uses `ON DELETE SET NULL`, so terminal held-run history
415
+ * survives the delete with a null environment reference. Pending held runs
416
+ * still reference the environment, so this helper pre-checks their count and
417
+ * throws before issuing the DELETE — approve or reject them first.
418
+ */
419
+ async function deleteEnvironmentDirect(databaseUrl, opts) {
420
+ const pool = new pg.Pool({
421
+ connectionString: databaseUrl,
422
+ max: 1
423
+ });
424
+ try {
425
+ const pending = await pool.query(`SELECT count(*)::text AS count FROM held_runs hr
426
+ JOIN environments e ON e.id = hr.environment_id
427
+ WHERE e.org_id = $1 AND e.name = $2 AND hr.status = 'pending'`, [opts.orgId, opts.name]);
428
+ const pendingCount = Number(pending.rows[0]?.count ?? 0);
429
+ if (pendingCount > 0) throw new Error(`environment has ${pendingCount} pending held run(s) — approve or reject them first`);
430
+ return { deleted: (await pool.query(`DELETE FROM environments WHERE org_id = $1 AND name = $2 RETURNING id`, [opts.orgId, opts.name])).rows.length > 0 };
431
+ } finally {
432
+ await pool.end();
433
+ }
434
+ }
435
+ /**
406
436
  * Upsert an `environment_bindings` row connecting `envName` to `scopePattern`.
407
437
  * Throws if the environment does not exist.
408
438
  */
@@ -449,6 +479,7 @@ async function setEnvironmentPolicyDirect(databaseUrl, opts) {
449
479
  if (opts.holdExpirySeconds !== void 0) addSet("hold_expiry_seconds", opts.holdExpirySeconds);
450
480
  if (opts.minimumTrust !== void 0) addSet("minimum_trust", opts.minimumTrust);
451
481
  if (opts.enabled !== void 0) addSet("enabled", opts.enabled);
482
+ if (opts.allowLocalExecution !== void 0) addSet("allow_local_execution", opts.allowLocalExecution);
452
483
  if (setClauses.length === 0) throw new Error("environment: setEnvironmentPolicy requires at least one policy field");
453
484
  const pool = new pg.Pool({
454
485
  connectionString: databaseUrl,
@@ -2555,7 +2586,27 @@ async function countActivePeerCredentialsByInstanceDirect(databaseUrl, opts) {
2555
2586
  await pool.end();
2556
2587
  }
2557
2588
  }
2589
+ /**
2590
+ * Terminate every idle backend of the connecting user except our own
2591
+ * connection. Mirrors what a Postgres leader demotion does to idle pooled
2592
+ * connections — used by resilience tests to verify the pg pool error
2593
+ * handlers absorb the termination without a process restart.
2594
+ *
2595
+ * Returns the number of backends terminated.
2596
+ */
2597
+ async function terminateIdleDbBackendsDirect(databaseUrl) {
2598
+ const pool = createPool(databaseUrl);
2599
+ try {
2600
+ return (await pool.query(`SELECT pg_terminate_backend(pid)
2601
+ FROM pg_stat_activity
2602
+ WHERE pid <> pg_backend_pid()
2603
+ AND usename = current_user
2604
+ AND state = 'idle'`)).rowCount ?? 0;
2605
+ } finally {
2606
+ await pool.end();
2607
+ }
2608
+ }
2558
2609
  //#endregion
2559
- export { MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createDbRole, createEnvironmentTemplateDirect, createJoinTokenDirect, createReadOnlyDbUser, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listEnvironmentsDirect, listExecutionJobsDirect, listExecutionRunsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedCrossRepoTrustDirect, seedEnvironmentBindingDirect, seedEnvironmentDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setEnvironmentPolicyDirect, setEnvironmentSecretDirect, showEnvironmentDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2610
+ export { MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createDbRole, createEnvironmentTemplateDirect, createJoinTokenDirect, createReadOnlyDbUser, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteEnvironmentDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listEnvironmentsDirect, listExecutionJobsDirect, listExecutionRunsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedCrossRepoTrustDirect, seedEnvironmentBindingDirect, seedEnvironmentDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setEnvironmentPolicyDirect, setEnvironmentSecretDirect, showEnvironmentDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, terminateIdleDbBackendsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2560
2611
 
2561
2612
  //# sourceMappingURL=db-admin.js.map
package/dist/db.d.ts CHANGED
@@ -1,9 +1,28 @@
1
1
  import pg from 'pg';
2
2
  import { Kysely } from 'kysely';
3
+ /** Where a pg connection error surfaced. */
4
+ export type PgPoolErrorSource = 'idle-pool' | 'client';
5
+ export interface CreatePoolOptions {
6
+ /** Extra pg.Pool config merged over the connection string (e.g. max, connectionTimeoutMillis). */
7
+ config?: Omit<pg.PoolConfig, 'connectionString'>;
8
+ /**
9
+ * Optional hook invoked after the built-in log line on every absorbed
10
+ * connection error (e.g. to increment a metrics counter). Additive — it
11
+ * never replaces the log.
12
+ */
13
+ onError?: (err: Error, source: PgPoolErrorSource) => void;
14
+ }
3
15
  /**
4
16
  * Create PostgreSQL connection pool.
17
+ *
18
+ * Always attaches error handlers for both idle pooled clients (the pool's
19
+ * own 'error' event) and checked-out clients (per-client 'error' via the
20
+ * 'connect' hook). Without them, a terminated backend — e.g. a Postgres
21
+ * leader switchover — escalates to an uncaughtException and a full process
22
+ * restart. The broken connection is logged and discarded; pg replaces it on
23
+ * the next acquire. In-flight query failures still reject to their callers.
5
24
  */
6
- export declare function createPool(databaseUrl: string): pg.Pool;
25
+ export declare function createPool(databaseUrl: string, options?: CreatePoolOptions): pg.Pool;
7
26
  /**
8
27
  * Create Kysely database instance (PostgreSQL only).
9
28
  *
package/dist/db.js CHANGED
@@ -1,12 +1,44 @@
1
1
  import "./chunk-gOLHoazu.js";
2
2
  import pg from "pg";
3
3
  import { Kysely, PostgresDialect } from "kysely";
4
+ import { createLogger } from "@kici-dev/core";
4
5
  //#region src/db.ts
6
+ let poolLogger;
7
+ function getPoolLogger() {
8
+ poolLogger ??= createLogger({ prefix: "pg-pool" });
9
+ return poolLogger;
10
+ }
5
11
  /**
6
12
  * Create PostgreSQL connection pool.
13
+ *
14
+ * Always attaches error handlers for both idle pooled clients (the pool's
15
+ * own 'error' event) and checked-out clients (per-client 'error' via the
16
+ * 'connect' hook). Without them, a terminated backend — e.g. a Postgres
17
+ * leader switchover — escalates to an uncaughtException and a full process
18
+ * restart. The broken connection is logged and discarded; pg replaces it on
19
+ * the next acquire. In-flight query failures still reject to their callers.
7
20
  */
8
- function createPool(databaseUrl) {
9
- return new pg.Pool({ connectionString: databaseUrl });
21
+ function createPool(databaseUrl, options) {
22
+ const pool = new pg.Pool({
23
+ connectionString: databaseUrl,
24
+ ...options?.config
25
+ });
26
+ const seen = /* @__PURE__ */ new WeakSet();
27
+ const handle = (err, source) => {
28
+ if (seen.has(err)) return;
29
+ seen.add(err);
30
+ getPoolLogger().warn("Discarded broken pg connection", {
31
+ source,
32
+ error: err.message,
33
+ stack: err.stack
34
+ });
35
+ options?.onError?.(err, source);
36
+ };
37
+ pool.on("error", (err) => handle(err, "idle-pool"));
38
+ pool.on("connect", (client) => {
39
+ client.on("error", (err) => handle(err, "client"));
40
+ });
41
+ return pool;
10
42
  }
11
43
  /**
12
44
  * Create Kysely database instance (PostgreSQL only).
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=db.test.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared debug-bundle archive primitives.
3
+ *
4
+ * Allowlist config redaction and windowed log-file archiving, used by the
5
+ * orchestrator's in-process bundle writer, the kici-admin CLI's local bundle,
6
+ * and the agent's fleet mini-bundle assembler. Keeping one copy means a node
7
+ * cannot drift from the redaction posture of its peers.
8
+ */
9
+ import type archiver from 'archiver';
10
+ /** Maximum total log bytes to include in bundle (50MB). */
11
+ export declare const MAX_LOG_BYTES: number;
12
+ /**
13
+ * Redact config values using allowlist approach.
14
+ * Only known-safe fields are preserved; everything else becomes "****".
15
+ */
16
+ export declare function redactConfig(obj: unknown, parentKey?: string): unknown;
17
+ /**
18
+ * Add log files from logDir to the archive, respecting MAX_LOG_BYTES cap
19
+ * and the logWindow time filter. Matches any `*.log` file in the directory,
20
+ * so the per-instance filename pattern produced by
21
+ * `buildLogFilename()` is picked up without additional configuration.
22
+ *
23
+ * Exported for reuse by the `kici-admin debug-bundle` CLI command, which
24
+ * runs outside the orchestrator process but still needs to include the
25
+ * same log files in its locally-assembled bundle.
26
+ */
27
+ export declare function addLogsToArchive(archive: archiver.Archiver, logDir: string, logWindowHours: number): Promise<void>;
28
+ //# sourceMappingURL=bundle-archive.d.ts.map
@@ -0,0 +1,141 @@
1
+ import "../chunk-gOLHoazu.js";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ //#region src/diagnostics/bundle-archive.ts
5
+ /**
6
+ * Shared debug-bundle archive primitives.
7
+ *
8
+ * Allowlist config redaction and windowed log-file archiving, used by the
9
+ * orchestrator's in-process bundle writer, the kici-admin CLI's local bundle,
10
+ * and the agent's fleet mini-bundle assembler. Keeping one copy means a node
11
+ * cannot drift from the redaction posture of its peers.
12
+ */
13
+ /** Maximum total log bytes to include in bundle (50MB). */
14
+ const MAX_LOG_BYTES = 50 * 1024 * 1024;
15
+ /**
16
+ * Config field names that are safe to include unredacted.
17
+ * Everything else gets replaced with "****".
18
+ */
19
+ const SAFE_CONFIG_KEYS = new Set([
20
+ "mode",
21
+ "host",
22
+ "port",
23
+ "logLevel",
24
+ "region",
25
+ "environment",
26
+ "name",
27
+ "label",
28
+ "labels",
29
+ "enabled",
30
+ "disabled",
31
+ "timeout",
32
+ "interval",
33
+ "maxRetries",
34
+ "retries",
35
+ "workers",
36
+ "concurrency",
37
+ "maxConcurrency",
38
+ "batchSize",
39
+ "bufferSize",
40
+ "warmPool",
41
+ "cooldown",
42
+ "type",
43
+ "provider",
44
+ "scaler",
45
+ "driver",
46
+ "backend",
47
+ "protocol",
48
+ "scheme",
49
+ "path",
50
+ "basePath",
51
+ "metricsPath",
52
+ "healthPath",
53
+ "logFormat",
54
+ "logFile",
55
+ "logDir",
56
+ "dataDir",
57
+ "version",
58
+ "debug",
59
+ "verbose",
60
+ "quiet",
61
+ "tls",
62
+ "cors",
63
+ "rateLimiting",
64
+ "maxConnections",
65
+ "poolSize",
66
+ "minPool",
67
+ "maxPool",
68
+ "idleTimeout",
69
+ "connectTimeout",
70
+ "requestTimeout",
71
+ "shutdownTimeout",
72
+ "gracefulShutdown"
73
+ ]);
74
+ /**
75
+ * Redact config values using allowlist approach.
76
+ * Only known-safe fields are preserved; everything else becomes "****".
77
+ */
78
+ function redactConfig(obj, parentKey) {
79
+ if (obj === null || obj === void 0) return obj;
80
+ if (Array.isArray(obj)) return obj.map((item) => redactConfig(item, parentKey));
81
+ if (typeof obj === "object") {
82
+ const result = {};
83
+ for (const [key, value] of Object.entries(obj)) result[key] = redactConfig(value, key);
84
+ return result;
85
+ }
86
+ if (typeof obj === "string" && parentKey && !SAFE_CONFIG_KEYS.has(parentKey)) return "****";
87
+ if (typeof obj === "number" || typeof obj === "boolean") return obj;
88
+ if (typeof obj === "string" && parentKey && SAFE_CONFIG_KEYS.has(parentKey)) return obj;
89
+ if (typeof obj === "string") return "****";
90
+ return obj;
91
+ }
92
+ /**
93
+ * Add log files from logDir to the archive, respecting MAX_LOG_BYTES cap
94
+ * and the logWindow time filter. Matches any `*.log` file in the directory,
95
+ * so the per-instance filename pattern produced by
96
+ * `buildLogFilename()` is picked up without additional configuration.
97
+ *
98
+ * Exported for reuse by the `kici-admin debug-bundle` CLI command, which
99
+ * runs outside the orchestrator process but still needs to include the
100
+ * same log files in its locally-assembled bundle.
101
+ */
102
+ async function addLogsToArchive(archive, logDir, logWindowHours) {
103
+ const cutoff = Date.now() - logWindowHours * 60 * 60 * 1e3;
104
+ const entries = fs.readdirSync(logDir).filter((f) => {
105
+ if (!f.endsWith(".log")) return false;
106
+ return fs.statSync(path.join(logDir, f)).mtimeMs >= cutoff;
107
+ });
108
+ entries.sort((a, b) => {
109
+ const aStat = fs.statSync(path.join(logDir, a));
110
+ return fs.statSync(path.join(logDir, b)).mtimeMs - aStat.mtimeMs;
111
+ });
112
+ let totalBytes = 0;
113
+ let totalLines = 0;
114
+ let errors = 0;
115
+ let warnings = 0;
116
+ for (const entry of entries) {
117
+ const filePath = path.join(logDir, entry);
118
+ const stat = fs.statSync(filePath);
119
+ if (totalBytes + stat.size > 52428800) break;
120
+ const content = fs.readFileSync(filePath, "utf-8");
121
+ archive.append(content, { name: `logs/${entry}` });
122
+ totalBytes += stat.size;
123
+ const lines = content.split("\n");
124
+ totalLines += lines.length;
125
+ for (const line of lines) {
126
+ if (/\berror\b/i.test(line)) errors++;
127
+ if (/\bwarn(ing)?\b/i.test(line)) warnings++;
128
+ }
129
+ }
130
+ const summary = {
131
+ totalLines,
132
+ errors,
133
+ warnings,
134
+ totalBytes
135
+ };
136
+ archive.append(JSON.stringify(summary, null, 2), { name: "logs/summary.json" });
137
+ }
138
+ //#endregion
139
+ export { MAX_LOG_BYTES, addLogsToArchive, redactConfig };
140
+
141
+ //# sourceMappingURL=bundle-archive.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bundle-archive.test.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Channel-agnostic chunked transfer for fleet bundle ZIPs.
3
+ *
4
+ * The WS frame cap (WS_MAX_PAYLOAD_BYTES = 25 MiB) forbids shipping a whole
5
+ * bundle in one frame, so a bundle Buffer is split into ordered base64 frames
6
+ * (~85 KiB raw each, matching the webhook-relay frame size) and reassembled by
7
+ * the receiver. Used on both the orchestrator-agent and peer channels.
8
+ */
9
+ /** Raw bytes per chunk before base64 (matches webhook-relay's ~85 KiB frames). */
10
+ export declare const FLEET_CHUNK_BYTES: number;
11
+ export interface BundleChunkFrame {
12
+ seq: number;
13
+ dataB64: string;
14
+ isLast: boolean;
15
+ }
16
+ /** Split a Buffer into ordered base64 frames. An empty buffer yields one final frame. */
17
+ export declare function chunkBuffer(buf: Buffer, chunkBytes?: number): BundleChunkFrame[];
18
+ /**
19
+ * Correlation core for any "send a request, await a chunked response" channel.
20
+ *
21
+ * Key-agnostic so both the orchestrator-agent channel (keyed by requestId) and
22
+ * the peer channel (keyed by messageId) share one implementation. Pending
23
+ * requests reject on timeout, on an error frame, or on disconnect. No
24
+ * orchestrator-initiated request/response primitive existed before fleet
25
+ * collection; this is it.
26
+ */
27
+ export declare class ChunkRequestWaiter {
28
+ private pending;
29
+ /** Register a pending request `id` that rejects after `timeoutMs`. */
30
+ add(id: string, timeoutMs: number): Promise<Buffer>;
31
+ /** Accumulate a chunk; resolves the pending request on the final frame. */
32
+ onChunk(id: string, seq: number, dataB64: string, isLast: boolean): void;
33
+ /** Reject the pending request `id` with `message`. */
34
+ onError(id: string, message: string): void;
35
+ /** Reject every pending request — used when the underlying connection drops. */
36
+ rejectAll(reason: string): void;
37
+ }
38
+ /** Reassembles ordered frames into a Buffer. Throws on gaps/reordering. */
39
+ export declare class BundleChunkAssembler {
40
+ private parts;
41
+ private next;
42
+ private done;
43
+ /** Returns the assembled Buffer on the final frame, otherwise undefined. */
44
+ accept(seq: number, dataB64: string, isLast: boolean): Buffer | undefined;
45
+ }
46
+ //# sourceMappingURL=bundle-chunks.d.ts.map
@@ -0,0 +1,111 @@
1
+ import "../chunk-gOLHoazu.js";
2
+ //#region src/diagnostics/bundle-chunks.ts
3
+ /**
4
+ * Channel-agnostic chunked transfer for fleet bundle ZIPs.
5
+ *
6
+ * The WS frame cap (WS_MAX_PAYLOAD_BYTES = 25 MiB) forbids shipping a whole
7
+ * bundle in one frame, so a bundle Buffer is split into ordered base64 frames
8
+ * (~85 KiB raw each, matching the webhook-relay frame size) and reassembled by
9
+ * the receiver. Used on both the orchestrator-agent and peer channels.
10
+ */
11
+ /** Raw bytes per chunk before base64 (matches webhook-relay's ~85 KiB frames). */
12
+ const FLEET_CHUNK_BYTES = 85 * 1024;
13
+ /** Split a Buffer into ordered base64 frames. An empty buffer yields one final frame. */
14
+ function chunkBuffer(buf, chunkBytes = FLEET_CHUNK_BYTES) {
15
+ const frames = [];
16
+ if (buf.length === 0) return [{
17
+ seq: 0,
18
+ dataB64: "",
19
+ isLast: true
20
+ }];
21
+ for (let offset = 0, seq = 0; offset < buf.length; offset += chunkBytes, seq++) {
22
+ const slice = buf.subarray(offset, Math.min(offset + chunkBytes, buf.length));
23
+ frames.push({
24
+ seq,
25
+ dataB64: slice.toString("base64"),
26
+ isLast: offset + chunkBytes >= buf.length
27
+ });
28
+ }
29
+ return frames;
30
+ }
31
+ /**
32
+ * Correlation core for any "send a request, await a chunked response" channel.
33
+ *
34
+ * Key-agnostic so both the orchestrator-agent channel (keyed by requestId) and
35
+ * the peer channel (keyed by messageId) share one implementation. Pending
36
+ * requests reject on timeout, on an error frame, or on disconnect. No
37
+ * orchestrator-initiated request/response primitive existed before fleet
38
+ * collection; this is it.
39
+ */
40
+ var ChunkRequestWaiter = class {
41
+ pending = /* @__PURE__ */ new Map();
42
+ /** Register a pending request `id` that rejects after `timeoutMs`. */
43
+ add(id, timeoutMs) {
44
+ return new Promise((resolve, reject) => {
45
+ const timer = setTimeout(() => {
46
+ this.pending.delete(id);
47
+ reject(/* @__PURE__ */ new Error(`chunk request ${id} timed out after ${timeoutMs}ms`));
48
+ }, timeoutMs);
49
+ this.pending.set(id, {
50
+ asm: new BundleChunkAssembler(),
51
+ resolve,
52
+ reject,
53
+ timer
54
+ });
55
+ });
56
+ }
57
+ /** Accumulate a chunk; resolves the pending request on the final frame. */
58
+ onChunk(id, seq, dataB64, isLast) {
59
+ const p = this.pending.get(id);
60
+ if (!p) return;
61
+ try {
62
+ const done = p.asm.accept(seq, dataB64, isLast);
63
+ if (done) {
64
+ clearTimeout(p.timer);
65
+ this.pending.delete(id);
66
+ p.resolve(done);
67
+ }
68
+ } catch (err) {
69
+ clearTimeout(p.timer);
70
+ this.pending.delete(id);
71
+ p.reject(err instanceof Error ? err : new Error(String(err)));
72
+ }
73
+ }
74
+ /** Reject the pending request `id` with `message`. */
75
+ onError(id, message) {
76
+ const p = this.pending.get(id);
77
+ if (!p) return;
78
+ clearTimeout(p.timer);
79
+ this.pending.delete(id);
80
+ p.reject(new Error(message));
81
+ }
82
+ /** Reject every pending request — used when the underlying connection drops. */
83
+ rejectAll(reason) {
84
+ for (const [, p] of this.pending) {
85
+ clearTimeout(p.timer);
86
+ p.reject(new Error(reason));
87
+ }
88
+ this.pending.clear();
89
+ }
90
+ };
91
+ /** Reassembles ordered frames into a Buffer. Throws on gaps/reordering. */
92
+ var BundleChunkAssembler = class {
93
+ parts = [];
94
+ next = 0;
95
+ done = false;
96
+ /** Returns the assembled Buffer on the final frame, otherwise undefined. */
97
+ accept(seq, dataB64, isLast) {
98
+ if (this.done) throw new Error("bundle chunk received after final frame");
99
+ if (seq !== this.next) throw new Error(`out-of-order bundle chunk: expected ${this.next}, got ${seq}`);
100
+ this.next++;
101
+ this.parts.push(Buffer.from(dataB64, "base64"));
102
+ if (isLast) {
103
+ this.done = true;
104
+ return Buffer.concat(this.parts);
105
+ }
106
+ }
107
+ };
108
+ //#endregion
109
+ export { BundleChunkAssembler, ChunkRequestWaiter, FLEET_CHUNK_BYTES, chunkBuffer };
110
+
111
+ //# sourceMappingURL=bundle-chunks.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bundle-chunks.test.d.ts.map
@@ -50,9 +50,21 @@ export interface ShutdownHandle {
50
50
  /**
51
51
  * Wire up SIGTERM / SIGINT (and optionally uncaughtException /
52
52
  * unhandledRejection) handlers that execute the provided teardown
53
- * steps sequentially, then `process.exit(0)`.
53
+ * steps sequentially, then exit.
54
+ *
55
+ * Exit codes: signal-triggered and programmatic shutdowns exit 0;
56
+ * shutdowns triggered by uncaughtException / unhandledRejection exit 1 so
57
+ * `Restart=on-failure`-style supervisor policies and exit-code alerting
58
+ * see the fatal cause. Escalation is sticky — a fatal trigger arriving
59
+ * while a clean shutdown is already in progress still raises the final
60
+ * exit code to 1.
54
61
  *
55
62
  * A force-exit timer ensures the process terminates even if a step hangs.
63
+ * The force-exit honors the same sticky exit code as a clean completion: a
64
+ * slow SIGTERM/SIGINT stop that merely overran the grace period still exits 0,
65
+ * so systemd records an intentional stop rather than marking the unit `failed`
66
+ * (which would break `systemctl restart` recovery). Only a fatal trigger
67
+ * (uncaughtException / unhandledRejection) raises the force-exit code to 1.
56
68
  */
57
69
  export declare function setupGracefulShutdown(options: GracefulShutdownOptions): ShutdownHandle;
58
70
  //# sourceMappingURL=graceful-shutdown.d.ts.map