@kici-dev/agent 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.
@@ -13,5 +13,13 @@
13
13
  * This file is compiled alongside the agent by rolldown (existing build), but
14
14
  * runs as a SEPARATE process spawned by the sandbox backend.
15
15
  */
16
- export {};
16
+ import type { Job, GenericInitConfig } from '@kici-dev/sdk';
17
+ /** Raw provider webhook body for ctx.rawPayload — nested in the envelope. */
18
+ export declare function rawPayloadFromEvent(event: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
19
+ /**
20
+ * Normalize `Job.init` (config | config[] | false | undefined) to an ordered
21
+ * array of init specs. `false` is an explicit opt-out and `undefined` (no
22
+ * config) both resolve to an empty list — the init phase is then a no-op.
23
+ */
24
+ export declare function resolveInitSpecs(job: Job | undefined): GenericInitConfig[];
17
25
  //# sourceMappingURL=workflow-runner.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Startup garbage collection for this agent's own temp-directory families.
3
+ *
4
+ * Job workdirs (`kici-<6 random chars>`, see job-runner.ts) and isolated
5
+ * pnpm stores (`kici-pnpm-store-*`, see dep-installer.ts) clean themselves
6
+ * up in `finally` blocks — but a hard process death (SIGKILL, OOM kill)
7
+ * skips those, and on a long-lived bare-metal agent the leftovers then
8
+ * accumulate forever. Collecting anything older than a day at startup is
9
+ * safe on shared hosts: no job lives remotely that long (job timeouts are
10
+ * minutes), so a concurrent agent's in-flight dirs are never eligible.
11
+ */
12
+ /**
13
+ * Collect this agent's stale temp dirs. `base` is overridable for tests;
14
+ * production callers use the default temp root. Never throws.
15
+ */
16
+ export declare function gcStaleAgentTmpDirs(base?: string): Promise<string[]>;
17
+ //# sourceMappingURL=tmp-gc.d.ts.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { loadConfig, type AppConfig } from './config.js';
2
2
  export { installDeps, type InstallDepsOptions } from './execution/dep-installer.js';
3
3
  export { findLocalProtocolDeps, assertResolvableDeps, formatUnresolvableDepError, kiciHasLocalProtocolDeps, LocalDepProtocol, type LocalProtocolDep, } from './execution/validate-kici-deps.js';
4
+ export { createCacheApi, packCachePaths, extractCacheTarball, downloadAndExtractCache, resolveCachePath, type CacheTransport, type CacheRoots, } from './execution/cache/index.js';
4
5
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,19 +1,34 @@
1
- import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
2
- import { dirname as __cjs_dirname } from "node:path";
3
- __cjs_dirname(__cjs_fileURLToPath(import.meta.url));
4
1
  import "node:module";
5
- import { hostname, tmpdir } from "node:os";
6
- import { randomUUID } from "node:crypto";
2
+ import { homedir, hostname, tmpdir } from "node:os";
3
+ import { createHash, randomUUID } from "node:crypto";
7
4
  import { z } from "zod";
8
5
  import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
9
6
  import { KNOWN_ROLES, validateNoReservedLabels } from "@kici-dev/engine";
10
7
  import { execFile } from "node:child_process";
11
- import { access, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
12
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { access, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
9
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
13
10
  import { promisify } from "node:util";
14
- import { createLogger, toErrorMessage } from "@kici-dev/shared";
11
+ import { createLogger, sha256, toErrorMessage } from "@kici-dev/shared";
15
12
  import { PNPM_IGNORE_BUILD_GATE_ARG, PackageManager, detectPackageManagerFromManifests } from "@kici-dev/shared/package-manager";
16
13
  import { existsSync } from "node:fs";
14
+ import { Readable, Transform } from "node:stream";
15
+ import { pipeline } from "node:stream/promises";
16
+ import { createGunzip } from "node:zlib";
17
+ import { c, x } from "tar";
18
+ import https from "node:https";
19
+ import http from "node:http";
20
+ import "node:url";
21
+ var __defProp = Object.defineProperty;
22
+ var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
23
+ var __exportAll = (all, no_symbols) => {
24
+ let target = {};
25
+ for (var name in all) __defProp(target, name, {
26
+ get: all[name],
27
+ enumerable: true
28
+ });
29
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
30
+ return target;
31
+ };
17
32
  import.meta.url;
18
33
  //#endregion
19
34
  //#region src/config.ts
@@ -444,7 +459,7 @@ async function assertResolvableDeps(args) {
444
459
  * env vars — the install runs with `--ignore-scripts` whenever a private
445
460
  * registry is configured.
446
461
  */
447
- const logger = createLogger({ prefix: "dep-installer" });
462
+ const logger$2 = createLogger({ prefix: "dep-installer" });
448
463
  const execFileAsync = promisify(execFile);
449
464
  /** Install subprocess timeout (10 min) and stdout/stderr buffer (128 MiB). */
450
465
  const INSTALL_TIMEOUT_MS = 6e5;
@@ -479,7 +494,7 @@ async function detectKiciPackageManager(repoRoot, kiciDir) {
479
494
  async function installDeps(kiciDir, opts = {}) {
480
495
  const repoRoot = opts.repoRoot ?? dirname(kiciDir);
481
496
  const packageManager = await detectKiciPackageManager(repoRoot, kiciDir);
482
- logger.info("Installing deps inline", {
497
+ logger$2.info("Installing deps inline", {
483
498
  packageManager,
484
499
  dir: kiciDir
485
500
  });
@@ -520,7 +535,7 @@ async function installDeps(kiciDir, opts = {}) {
520
535
  if (packageManager === PackageManager.Pnpm && await kiciHasLocalProtocolDeps(kiciDir)) await buildWorkspaceClosure(repoRoot);
521
536
  const durationMs = Date.now() - startTime;
522
537
  process.stderr.write(`[dep-installer:trace] install complete: ${durationMs}ms\n`);
523
- logger.info("Deps installed inline", {
538
+ logger$2.info("Deps installed inline", {
524
539
  packageManager,
525
540
  durationMs
526
541
  });
@@ -658,7 +673,322 @@ function logSubprocessStreams(e, tokens) {
658
673
  if (e && typeof e === "object" && "stdout" in e) process.stderr.write(`[dep-installer:trace] stdout: ${redactNpmOutput(String(e.stdout), tokens).slice(0, 500)}\n`);
659
674
  if (e && typeof e === "object" && "stderr" in e) process.stderr.write(`[dep-installer:trace] stderr: ${redactNpmOutput(String(e.stderr), tokens).slice(0, 500)}\n`);
660
675
  }
676
+ /**
677
+ * Rewrite localhost URLs to use the orchestrator host.
678
+ *
679
+ * The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
680
+ * but agent containers can't reach localhost. This utility replaces the
681
+ * host with the orchestrator's host derived from KICI_ORCHESTRATOR_URL.
682
+ */
683
+ function resolveOrchestratorUrl(url) {
684
+ if (!url.match(/^https?:\/\/(localhost|127\.0\.0\.1)[:/]/)) return url;
685
+ const orchestratorUrl = process.env.KICI_ORCHESTRATOR_URL;
686
+ if (!orchestratorUrl) return url;
687
+ try {
688
+ const orchestratorParsed = new URL(orchestratorUrl.replace(/^ws/, "http"));
689
+ const parsed = new URL(url);
690
+ parsed.hostname = orchestratorParsed.hostname;
691
+ return parsed.toString();
692
+ } catch {
693
+ return url;
694
+ }
695
+ }
696
+ var SCRATCH_DIR_BASENAME_PREFIX;
697
+ var init_dep_restore = __esmMin((() => {
698
+ createLogger({ prefix: "dep-restore" });
699
+ SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
700
+ `${SCRATCH_DIR_BASENAME_PREFIX}`;
701
+ }));
702
+ //#endregion
703
+ //#region src/execution/download.ts
704
+ /**
705
+ * Shared HTTP/HTTPS download utility.
706
+ *
707
+ * Extracted from workflow-loader.ts to avoid duplication across
708
+ * dep-restore.ts and workflow-loader.ts.
709
+ */
710
+ var download_exports = /* @__PURE__ */ __exportAll({
711
+ downloadUrl: () => downloadUrl,
712
+ uploadToPresignedUrl: () => uploadToPresignedUrl
713
+ });
714
+ /**
715
+ * Download content from an HTTP/HTTPS URL.
716
+ *
717
+ * Includes a 5-minute timeout to prevent the agent from hanging indefinitely
718
+ * on slow or unresponsive endpoints.
719
+ *
720
+ * @param url - The URL to download from
721
+ * @returns The response body as a Buffer
722
+ */
723
+ function downloadUrl(url) {
724
+ return new Promise((resolve, reject) => {
725
+ (url.startsWith("https:") ? https : http).get(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS$1) }, (res) => {
726
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
727
+ reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} downloading from ${url}`));
728
+ res.resume();
729
+ return;
730
+ }
731
+ const chunks = [];
732
+ res.on("data", (chunk) => chunks.push(chunk));
733
+ res.on("end", () => resolve(Buffer.concat(chunks)));
734
+ res.on("error", reject);
735
+ }).on("error", reject);
736
+ });
737
+ }
738
+ /**
739
+ * Upload a buffer to a pre-signed S3 URL via HTTP PUT.
740
+ *
741
+ * Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
742
+ * 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
743
+ * filesystem cache backend's signed URLs work from container agents that
744
+ * can't reach the orchestrator's host loopback directly.
745
+ *
746
+ * @param url - The pre-signed URL to upload to
747
+ * @param data - The buffer to upload
748
+ */
749
+ function uploadToPresignedUrl(url, data) {
750
+ return new Promise((resolve, reject) => {
751
+ const resolved = resolveOrchestratorUrl(url);
752
+ const parsed = new URL(resolved);
753
+ const req = (parsed.protocol === "https:" ? https : http).request({
754
+ hostname: parsed.hostname,
755
+ port: parsed.port,
756
+ path: parsed.pathname + parsed.search,
757
+ method: "PUT",
758
+ headers: { "Content-Length": data.length }
759
+ }, (res) => {
760
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
761
+ reject(/* @__PURE__ */ new Error(`HTTP ${res.statusCode} uploading to pre-signed URL`));
762
+ res.resume();
763
+ return;
764
+ }
765
+ res.resume();
766
+ res.on("end", () => resolve());
767
+ res.on("error", reject);
768
+ });
769
+ req.on("error", reject);
770
+ req.end(data);
771
+ });
772
+ }
773
+ var DOWNLOAD_TIMEOUT_MS$1;
774
+ var init_download = __esmMin((() => {
775
+ init_dep_restore();
776
+ DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
777
+ }));
778
+ //#endregion
779
+ //#region src/execution/cache/cache-engine.ts
780
+ /**
781
+ * User-facing cache engine (sandbox-side).
782
+ *
783
+ * Packs `CacheSpec.paths` into a gzip tarball (mirrors dep-packer's tar+sha256
784
+ * approach) and restores a tarball with on-the-fly SHA-256 verification
785
+ * (mirrors dep-restore's streaming pipeline). Drives the orchestrator over an
786
+ * injected request-response transport (IPC -> agent WS -> orchestrator).
787
+ *
788
+ * Path safety: each path is either `~`-prefixed (home-relative) or
789
+ * repo-root-relative; absolute paths and `..` escapes are rejected so a
790
+ * workflow cannot exfiltrate or clobber files outside its tree/home.
791
+ *
792
+ * Multi-root layout: a spec may mix repo-relative and home-relative paths.
793
+ * Each entry is staged under an anchor prefix — `__repo__/<rel>` for
794
+ * repo-root-relative entries, `__home__/<rel>` for `~`-prefixed entries — so a
795
+ * single tarball can carry both roots and extract restores each group to the
796
+ * right destination (repo entries under `workDir`, home entries under the
797
+ * homedir). Extraction lands in a scratch dir first, then moves each group
798
+ * into place so a partial restore never leaves half-written paths in the live
799
+ * tree (mirrors dep-restore).
800
+ */
801
+ const logger = createLogger({ prefix: "cache-engine" });
802
+ /** Download timeout for a presigned cache GET: 5 minutes. */
803
+ const DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
804
+ /** Anchor prefix for repo-root-relative cache entries inside the tar. */
805
+ const REPO_ANCHOR = "__repo__";
806
+ /** Anchor prefix for home-relative (`~`) cache entries inside the tar. */
807
+ const HOME_ANCHOR = "__home__";
808
+ /**
809
+ * Resolve a cache path. `~`-prefixed -> home root; otherwise repo-root-relative.
810
+ * Rejects absolute paths and `..` escapes so a workflow cannot read or clobber
811
+ * files outside its tree / home.
812
+ */
813
+ function resolveCachePath(workDir, p, roots) {
814
+ const home = roots?.home ?? homedir();
815
+ if (p === "~" || p.startsWith("~/")) {
816
+ const rel = p === "~" ? "" : p.slice(2);
817
+ return rel ? join(home, rel) : home;
818
+ }
819
+ if (isAbsolute(p)) throw new Error(`cache path must be repo-relative or ~-prefixed: ${p}`);
820
+ const resolved = resolve(workDir, p);
821
+ const rel = relative(workDir, resolved);
822
+ if (rel === ".." || rel.startsWith(`..${sep}`)) throw new Error(`cache path escapes the repo root: ${p}`);
823
+ return resolved;
824
+ }
825
+ /** Resolve + anchor every spec path; rejects escapes via resolveCachePath. */
826
+ function anchorEntries(workDir, paths, roots) {
827
+ const home = roots?.home ?? homedir();
828
+ return paths.map((p) => {
829
+ const abs = resolveCachePath(workDir, p, roots);
830
+ const isHome = p === "~" || p.startsWith("~/");
831
+ return {
832
+ abs,
833
+ anchor: isHome ? HOME_ANCHOR : REPO_ANCHOR,
834
+ rel: relative(isHome ? home : workDir, abs)
835
+ };
836
+ });
837
+ }
838
+ /**
839
+ * Pack the spec's paths into a gzip tarball + its SHA-256.
840
+ *
841
+ * Each path is copied into a staging dir under its anchor prefix
842
+ * (`__repo__/<rel>` or `__home__/<rel>`), the staging dir is tarred (portable
843
+ * mode strips uid/gid/mtime), and the staging dir is removed. The resulting
844
+ * tarball self-describes which root each entry restores to.
845
+ */
846
+ async function packCachePaths(workDir, paths, roots) {
847
+ const entries = anchorEntries(workDir, paths, roots);
848
+ const staging = await mkdtemp(join(tmpdir(), "kici-cache-pack-"));
849
+ try {
850
+ const topLevel = /* @__PURE__ */ new Set();
851
+ for (const e of entries) {
852
+ const dest = join(staging, e.anchor, e.rel);
853
+ await mkdir(dirname(dest), { recursive: true });
854
+ await cp(e.abs, dest, {
855
+ recursive: true,
856
+ verbatimSymlinks: true
857
+ });
858
+ topLevel.add(e.anchor);
859
+ }
860
+ const stream = c({
861
+ gzip: true,
862
+ portable: true,
863
+ cwd: staging
864
+ }, [...topLevel]);
865
+ const chunks = [];
866
+ for await (const chunk of stream) chunks.push(Buffer.from(chunk));
867
+ const tarball = Buffer.concat(chunks);
868
+ const hash = sha256(tarball);
869
+ logger.info("packed user cache", {
870
+ sizeBytes: tarball.length,
871
+ hash: hash.slice(0, 12),
872
+ paths
873
+ });
874
+ return {
875
+ tarball,
876
+ hash
877
+ };
878
+ } finally {
879
+ await rm(staging, {
880
+ recursive: true,
881
+ force: true
882
+ });
883
+ }
884
+ }
885
+ /** Move the extracted `__repo__` / `__home__` groups from a scratch dir into place. */
886
+ async function moveAnchoredGroups(scratchDir, workDir, home) {
887
+ for (const anchor of await readdir(scratchDir)) {
888
+ const anchorDir = join(scratchDir, anchor);
889
+ const destRoot = anchor === HOME_ANCHOR ? home : anchor === REPO_ANCHOR ? workDir : null;
890
+ if (!destRoot) continue;
891
+ for (const child of await readdir(anchorDir)) {
892
+ const dest = join(destRoot, child);
893
+ await mkdir(dirname(dest), { recursive: true });
894
+ await rm(dest, {
895
+ recursive: true,
896
+ force: true
897
+ });
898
+ await rename(join(anchorDir, child), dest);
899
+ }
900
+ }
901
+ }
902
+ /**
903
+ * Extract a cache tarball buffer, verifying its SHA-256 first, then move each
904
+ * anchored group into place (repo entries under `workDir`, home entries under
905
+ * the home root). Extracts into a scratch dir so a partial restore never
906
+ * leaves half-written paths in the live tree.
907
+ */
908
+ async function extractCacheTarball(tarball, workDir, expectedHash, roots) {
909
+ const actual = sha256(tarball);
910
+ if (actual !== expectedHash) throw new Error(`Cache tarball checksum mismatch: expected ${expectedHash}, got ${actual}`);
911
+ const home = roots?.home ?? homedir();
912
+ await mkdir(workDir, { recursive: true });
913
+ const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
914
+ try {
915
+ await new Promise((res, rej) => {
916
+ Readable.from(tarball).pipe(x({
917
+ cwd: scratch,
918
+ gzip: true
919
+ })).on("finish", res).on("error", rej);
920
+ });
921
+ await moveAnchoredGroups(scratch, workDir, home);
922
+ } finally {
923
+ await rm(scratch, {
924
+ recursive: true,
925
+ force: true
926
+ });
927
+ }
928
+ }
929
+ /**
930
+ * Stream-download a presigned URL, verify its SHA-256 on the fly (mirrors
931
+ * dep-restore's response -> hash -> gunzip -> tar pipeline), then move the
932
+ * anchored groups into place. Extracts into a scratch dir so a failed download
933
+ * never half-writes the live tree.
934
+ */
935
+ async function downloadAndExtractCache(url, workDir, expectedHash, roots) {
936
+ const home = roots?.home ?? homedir();
937
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
938
+ if (!response.ok || !response.body) throw new Error(`cache download HTTP ${response.status}`);
939
+ const hash = createHash("sha256");
940
+ const hashTransform = new Transform({ transform(chunk, _enc, cb) {
941
+ hash.update(chunk);
942
+ cb(null, chunk);
943
+ } });
944
+ await mkdir(workDir, { recursive: true });
945
+ const scratch = await mkdtemp(join(tmpdir(), "kici-cache-extract-"));
946
+ try {
947
+ await pipeline(Readable.fromWeb(response.body), hashTransform, createGunzip(), x({ cwd: scratch }));
948
+ const digest = hash.digest("hex");
949
+ if (digest !== expectedHash) throw new Error(`Cache tarball checksum mismatch on download: expected ${expectedHash}, got ${digest}`);
950
+ await moveAnchoredGroups(scratch, workDir, home);
951
+ } finally {
952
+ await rm(scratch, {
953
+ recursive: true,
954
+ force: true
955
+ });
956
+ }
957
+ }
958
+ /** Build the imperative `ctx.cache` API bound to a workDir + transport. */
959
+ function createCacheApi(workDir, transport, roots) {
960
+ return {
961
+ async restore(spec) {
962
+ const r = await transport.restore(spec.key, spec.restoreKeys);
963
+ if (!r.hit || !r.downloadUrl || !r.tarHash) return { hit: false };
964
+ await downloadAndExtractCache(r.downloadUrl, workDir, r.tarHash, roots);
965
+ logger.info("user cache restored", {
966
+ key: spec.key,
967
+ matchedKey: r.matchedKey
968
+ });
969
+ return {
970
+ hit: true,
971
+ matchedKey: r.matchedKey
972
+ };
973
+ },
974
+ async save(spec) {
975
+ const begin = await transport.beginSave(spec.key);
976
+ if (begin.skip || !begin.uploadUrl) {
977
+ logger.info("user cache save skipped (key exists)", { key: spec.key });
978
+ return;
979
+ }
980
+ const { tarball, hash } = await packCachePaths(workDir, spec.paths, roots);
981
+ const { uploadToPresignedUrl } = await Promise.resolve().then(() => (init_download(), download_exports));
982
+ await uploadToPresignedUrl(begin.uploadUrl, tarball);
983
+ await transport.completeSave(spec.key, hash, tarball.length);
984
+ logger.info("user cache saved", {
985
+ key: spec.key,
986
+ sizeBytes: tarball.length
987
+ });
988
+ }
989
+ };
990
+ }
661
991
  //#endregion
662
- export { LocalDepProtocol, assertResolvableDeps, findLocalProtocolDeps, formatUnresolvableDepError, installDeps, kiciHasLocalProtocolDeps, loadConfig };
992
+ export { LocalDepProtocol, assertResolvableDeps, createCacheApi, downloadAndExtractCache, extractCacheTarball, findLocalProtocolDeps, formatUnresolvableDepError, installDeps, kiciHasLocalProtocolDeps, loadConfig, packCachePaths, resolveCachePath };
663
993
 
664
994
  //# sourceMappingURL=index.js.map