c8ctl-plugin-nano 1.35.6 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -5
  2. package/c8ctl-plugin.js +158 -24
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -485,7 +485,7 @@ stdin payload as `task`:
485
485
  Element templates emit flat dotpath header keys (strings); the plugin expands
486
486
  them into a nested object and coerces `"true"/"false"` → bool and numeric
487
487
  strings → int. The normalized shape is
488
- `{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
488
+ `{ schemaVersion, repository{provider,url,ref,sha,depth,singleBranch,filter,baseRef,baseSha,cloneTimeoutMs,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
489
489
 
490
490
  **Prompt = base + optional verbatim append.** The agent's prompt resolves to
491
491
  `task.prompt` (typically a model header filled at deploy time), falling back to a
@@ -531,8 +531,33 @@ the harness:
531
531
  1. resolve the optional repo credential (`repository.authRef`, or `GITHUB_TOKEN`
532
532
  for GitHub) — absent ⇒ anonymous clone;
533
533
  2. `git clone` (honouring `depth`/`submodules`, and `repository.ref`/`branch.base`
534
- as the checkout target) into a throwaway workspace under
535
- `<state>/agent-runs/run-*`;
534
+ as the checkout target). **`ref` is always a branch/tag name** (there is no hex
535
+ heuristic, so a legitimately hex-named branch like `deadbeef` is cloned via
536
+ `--branch`, never misread as a commit); to pin a **raw commit** use the
537
+ dedicated **`repository.sha`** field, which clones `ref`/`branch.base` (if any)
538
+ then fetches + checks the commit out as a detached HEAD. The clone lands in a
539
+ throwaway workspace under `<state>/agent-runs/run-*`. For a **huge monorepo**
540
+ the clone envelope can be
541
+ scoped so it finishes inside the clone timeout: **`singleBranch`** adds
542
+ `--single-branch` (fetch only `ref`, not every branch — a plain `clone --branch`
543
+ still pulls all branches/history); **`filter`** (e.g. `"blob:none"`) adds
544
+ `--filter=<spec>` for a partial/treeless clone (full commit graph, lazy blobs —
545
+ so `merge-base`/`git diff base...head` still work); **`baseRef`**/**`baseSha`**
546
+ additionally `git fetch` the base (respecting `depth`/`filter`) so a
547
+ single-branch/shallow clone can still diff `base...head` (exported as
548
+ `AGENT_REPO_BASE`). **`baseRef`** is always treated as a branch/tag name (even
549
+ one that looks hex-like) and is mapped into `refs/remotes/origin/<baseRef>`;
550
+ **`baseSha`** is the field for a raw commit SHA (fetched by id, exposed as the
551
+ SHA itself). `baseRef` and `baseSha` are **mutually exclusive** — setting both
552
+ is ambiguous, so the base fetch is skipped and a non-fatal `baseFetchError`
553
+ records the misconfiguration. A `--depth 1 --single-branch` of only the head
554
+ otherwise has NO
555
+ base and NO merge-base, so a naive `git diff main` fails. A failed base fetch
556
+ is **non-fatal** — the head clone still succeeds and
557
+ the failure is logged. **`cloneTimeoutMs`** overrides the clone/fetch timeout
558
+ per envelope (default 120s, or the `--clone-timeout` worker flag) as a backstop
559
+ for repos big enough to approach the cap even when shallow; a timeout is now
560
+ reported *as a timeout* rather than an opaque `exit 128`;
536
561
  3. create `branch.create` (if set) off that target;
537
562
  4. set a **committer identity** on the workspace, preferring the operator's own
538
563
  (`GIT_AUTHOR_*` env → global `git config user.name/email` → the
@@ -540,8 +565,8 @@ the harness:
540
565
  none resolve — so autonomous commits are authored by the human running the
541
566
  fleet (who has signed any CLA/DCO), not an anonymous bot;
542
567
  5. run the harness **in the workspace** (`cwd`), with `AGENT_WORKSPACE`,
543
- `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF` exported and the job
544
- envelope on stdin;
568
+ `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`, `AGENT_REPO_BASE`
569
+ exported and the job envelope on stdin;
545
570
  6. on success, enumerate new commits, `git push` the branch when `branch.push`
546
571
  (default true), and — when `task.allowPr` — **reconcile the PR the agent
547
572
  opened** for the branch (`gh pr list --head <branch>`; `openedBy` reports the
package/c8ctl-plugin.js CHANGED
@@ -2294,7 +2294,25 @@ function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
2294
2294
  provider: (str(repo.provider) || 'github').toLowerCase(),
2295
2295
  url: str(repo.url),
2296
2296
  ref: str(repo.ref),
2297
+ // Dedicated field for a raw commit SHA to check out (detached), mirroring
2298
+ // baseSha/baseRef. `ref` is ALWAYS a branch/tag name — there is no hex
2299
+ // heuristic — so a legitimately hex-named branch (e.g. `deadbeef`) is
2300
+ // never misread as a commit; pin a commit via `sha` instead.
2301
+ sha: str(repo.sha),
2297
2302
  depth: coerceInt(repo.depth, undefined),
2303
+ // Scope the fetch to just `ref` (independent of depth) for callers that want
2304
+ // full history of one branch but not every branch of a huge monorepo.
2305
+ singleBranch: coerceBool(repo.singleBranch, false),
2306
+ // Partial/treeless clone spec (e.g. "blob:none"): full commit graph with
2307
+ // lazy blob fetch, so `merge-base` / `git diff base...head` still work.
2308
+ filter: str(repo.filter),
2309
+ // When a base branch/sha is supplied we additionally fetch it so a
2310
+ // single-branch/shallow clone can still diff `base...head`.
2311
+ baseRef: str(repo.baseRef),
2312
+ baseSha: str(repo.baseSha),
2313
+ // Per-envelope override of the clone/fetch timeout (ms) — a backstop for
2314
+ // repos big enough to approach the default 120s cap even when shallow.
2315
+ cloneTimeoutMs: coerceInt(repo.cloneTimeoutMs, undefined),
2298
2316
  submodules: coerceBool(repo.submodules, false),
2299
2317
  authRef: str(repo.authRef),
2300
2318
  };
@@ -2790,12 +2808,51 @@ function redactToken(text, token) {
2790
2808
  return s.replace(/(https?:\/\/)[^@/\s]+@/gi, '$1');
2791
2809
  }
2792
2810
 
2811
+ // Build an operator-actionable diagnostic from one or more runGit results.
2812
+ // git splits its output unpredictably across stdout/stderr, so preferring one
2813
+ // stream (`stderr || stdout`) can drop the only useful line — the root of the
2814
+ // "stub reason"/"opaque exit 128" incidents. Combine BOTH streams of every
2815
+ // command, redact the token, and always append status/signal context (from the
2816
+ // last, i.e. failing, command) so an empty-output failure still says something.
2817
+ function gitErrorDetail(results, token, limit = 500) {
2818
+ const list = Array.isArray(results) ? results : [results];
2819
+ const body = redactToken(
2820
+ list
2821
+ .flatMap((r) => [r?.stderr, r?.stdout])
2822
+ .map((s) => String(s ?? '').trim())
2823
+ .filter(Boolean)
2824
+ .join('\n'),
2825
+ token,
2826
+ ).trim().slice(0, limit);
2827
+ const last = list[list.length - 1] || {};
2828
+ const ctx = [];
2829
+ if (last.status != null) ctx.push(`exit ${last.status}`);
2830
+ if (last.signal) ctx.push(`signal ${last.signal}`);
2831
+ const ctxStr = ctx.length ? `(${ctx.join(', ')})` : '';
2832
+ return [body, ctxStr].filter(Boolean).join(' ') || 'unknown error';
2833
+ }
2834
+
2793
2835
  function runGit(args, { cwd, env, timeoutMs = 120_000 } = {}) {
2794
2836
  try {
2795
2837
  const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: timeoutMs });
2796
- return { status: r.status ?? (r.signal ? 128 : null), stdout: r.stdout || '', stderr: r.stderr || '', signal: r.signal || null };
2838
+ // spawnSync does not throw on timeout it returns with `error.code` set to
2839
+ // 'ETIMEDOUT' and the child SIGTERM-killed (signal set, status null). Surface
2840
+ // that as `timedOut` so callers can report a timeout instead of an
2841
+ // uninformative "exit 128" (ties to #89).
2842
+ const timedOut = !!(r.error && r.error.code === 'ETIMEDOUT');
2843
+ // A non-timeout spawn failure (e.g. ENOENT when `git` is missing, EACCES)
2844
+ // comes back via `r.error` with empty stdout/stderr; discarding
2845
+ // `r.error.message` leaves callers reporting an empty/"unknown error"
2846
+ // detail. Fold the spawn error (code + message) into stderr so
2847
+ // `gitErrorDetail` still surfaces something actionable.
2848
+ let stderr = r.stderr || '';
2849
+ if (r.error && !timedOut) {
2850
+ const spawnMsg = [r.error.code, r.error.message].filter(Boolean).join(': ');
2851
+ stderr = [stderr.trim(), spawnMsg].filter(Boolean).join('\n');
2852
+ }
2853
+ return { status: r.status ?? (r.signal ? 128 : null), stdout: r.stdout || '', stderr, signal: r.signal || null, timedOut, timeoutMs };
2797
2854
  } catch (err) {
2798
- return { status: null, stdout: '', stderr: err.message, signal: null };
2855
+ return { status: null, stdout: '', stderr: err.message, signal: null, timedOut: false, timeoutMs };
2799
2856
  }
2800
2857
  }
2801
2858
 
@@ -3115,38 +3172,108 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
3115
3172
  gitEnv.GIT_CONFIG_GLOBAL = devNull;
3116
3173
  }
3117
3174
 
3118
- const target = repo.ref || envelope.branch?.base || '';
3119
- // `git clone --branch` accepts a branch or tag name but NOT a raw commit SHA.
3120
- // For a SHA we clone the default branch, then fetch + check it out below.
3121
- const isSha = !!target && /^[0-9a-f]{7,40}$/i.test(target);
3175
+ const branchName = repo.ref || envelope.branch?.base || '';
3176
+ // A raw commit is requested ONLY via the dedicated `repository.sha` field
3177
+ // the sole unambiguous way to pin a commit. `ref` (→ `branchName`) is ALWAYS a
3178
+ // branch/tag name and is passed to `git clone --branch`; there is no hex
3179
+ // heuristic, so a legitimately hex-named branch like `deadbeef` is cloned as a
3180
+ // branch, not misread as a SHA. When a `sha` is given we clone `branchName`
3181
+ // (if any — the branch that should contain it) then fetch + detach onto it.
3182
+ const commitSha = repo.sha || '';
3183
+ // `sha` pins a raw commit and is passed to `git fetch origin <sha>` /
3184
+ // `git checkout --detach <sha>`. Validate it is a hex commit id (7–40 chars)
3185
+ // before use: this fails a misconfigured envelope fast and, because a hex id
3186
+ // can never start with `-`, forecloses a value being (mis)parsed as a git
3187
+ // option.
3188
+ if (commitSha && !/^[0-9a-f]{7,40}$/i.test(commitSha)) {
3189
+ throw new ProvisionError(`invalid repository.sha ${JSON.stringify(commitSha)} — expected a hex commit id (7–40 chars)`);
3190
+ }
3191
+ const isSha = !!commitSha;
3192
+ // Per-envelope timeout override (backstop for giant monorepos that approach the
3193
+ // default cap even when shallow); falls back to the caller-supplied timeout.
3194
+ const effectiveTimeoutMs = (repo.cloneTimeoutMs && repo.cloneTimeoutMs > 0) ? repo.cloneTimeoutMs : timeoutMs;
3122
3195
  const cloneArgs = [...credArgs(), 'clone', '--no-tags'];
3123
3196
  if (repo.depth && repo.depth > 0) cloneArgs.push('--depth', String(repo.depth));
3197
+ // `--single-branch` restricts the fetch to just `ref` — a plain `clone --branch`
3198
+ // still downloads every branch and all history. `--depth` implies this, but
3199
+ // honor it independently for a full-history single-branch clone.
3200
+ if (repo.singleBranch) cloneArgs.push('--single-branch');
3201
+ // Partial (blob-filtered) clone: full commit graph, lazy blobs — best fit for
3202
+ // reviewing a PR on a monorepo where a full checkout blows the timeout.
3203
+ if (repo.filter) cloneArgs.push(`--filter=${repo.filter}`);
3124
3204
  if (repo.submodules) cloneArgs.push('--recurse-submodules');
3125
- if (target && !isSha) cloneArgs.push('--branch', target);
3205
+ if (branchName) cloneArgs.push('--branch', branchName);
3126
3206
  const remote = authUrl(repo.url, repo.provider || 'github', !!token);
3127
3207
  cloneArgs.push(remote, workspaceDir);
3128
3208
 
3129
- const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs });
3209
+ const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs: effectiveTimeoutMs });
3130
3210
  if (clone.status !== 0) {
3131
- throw new ProvisionError(describeGitFailure('git clone', clone, { token, timeoutMs }));
3211
+ if (clone.timedOut) {
3212
+ // Preserve whatever git managed to print before SIGTERM (plus exit/signal
3213
+ // context) so a timeout is still diagnosable, not an opaque wall-clock hit.
3214
+ const detail = gitErrorDetail(clone, token);
3215
+ const detailNote = detail && detail !== 'unknown error' ? ` — last git output: ${detail}` : '';
3216
+ throw new ProvisionError(`git clone timed out after ${effectiveTimeoutMs}ms — the repo may be too large, or the network stalled; raise the timeout (repository.cloneTimeoutMs in the envelope, or the worker's --clone-timeout flag) or scope the clone with filter/singleBranch/depth${detailNote}`);
3217
+ }
3218
+ throw new ProvisionError(`git clone failed: ${gitErrorDetail(clone, token)}`);
3132
3219
  }
3133
3220
 
3134
3221
  if (isSha) {
3135
- // The SHA may not be present under a shallow clone of the default branch —
3136
- // fetch it explicitly (best effort), then check it out (detached HEAD).
3137
- const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', target], { cwd: workspaceDir, env: gitEnv, timeoutMs });
3138
- const co = runGit(['checkout', '--detach', target], { cwd: workspaceDir, env: gitEnv, timeoutMs });
3222
+ // The SHA may not be present under a shallow clone of the branch — fetch it
3223
+ // explicitly (best effort), then check it out (detached HEAD).
3224
+ const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: effectiveTimeoutMs });
3225
+ const co = runGit(['checkout', '--detach', commitSha], { cwd: workspaceDir, env: gitEnv });
3139
3226
  if (co.status !== 0) {
3140
- // Prefer the failing checkout's own output, but fall back to the fetch's
3141
- // (a timeout/fatal there is the real cause the checkout can't recover from)
3142
- // ONLY when fetch actually failed a succeeded fetch (status 0) is not the
3143
- // cause, and reporting it would yield a misleading "exit 0" message.
3144
- const useCheckout = !!(co.stderr || co.stdout || fetch.status === 0);
3145
- const source = useCheckout ? co : fetch;
3146
- // Label the failure with the action whose output we actually report, so a
3147
- // fetch timeout/fatal isn't misreported as a checkout failure.
3148
- const action = useCheckout ? `git checkout ${target}` : `git fetch origin ${target}`;
3149
- throw new ProvisionError(describeGitFailure(action, source, { token, timeoutMs }));
3227
+ // Combine the fetch + checkout output (the real reason often lives in the
3228
+ // fetch), and annotate a fetch timeout explicitly so a slow `git fetch
3229
+ // origin <sha>` is not misread as an opaque checkout failure.
3230
+ const fetchNote = fetch.timedOut ? ` (preceding git fetch origin ${commitSha} timed out after ${effectiveTimeoutMs}ms)` : '';
3231
+ throw new ProvisionError(`git checkout ${commitSha} failed: ${gitErrorDetail([fetch, co], token, 300)}${fetchNote}`);
3232
+ }
3233
+ }
3234
+
3235
+ // Optional base fetch: with a single-branch/shallow clone the head has no base
3236
+ // and no merge-base, so a naive `git diff <base>` fails. When a base branch or
3237
+ // sha is supplied, fetch it (respecting depth/filter) into a remote-tracking
3238
+ // ref so the harness can compute `git diff origin/<base>...HEAD`. Best-effort:
3239
+ // a failed base fetch is recorded, not fatal (the head clone still succeeded).
3240
+ let base = '';
3241
+ let baseFetchError;
3242
+ // `baseRef` (branch/tag) and `baseSha` (raw commit) are mutually exclusive — a
3243
+ // caller picks one. If BOTH are set the envelope is ambiguous, so rather than
3244
+ // silently preferring one (a surprising `base...head` diff), skip the base
3245
+ // fetch and record a non-fatal diagnostic so the misconfiguration is visible.
3246
+ if (repo.baseSha && repo.baseRef) {
3247
+ baseFetchError = `ambiguous base: both baseRef (${repo.baseRef}) and baseSha (${repo.baseSha}) set — provide only one`;
3248
+ } else {
3249
+ const baseTarget = repo.baseSha || repo.baseRef;
3250
+ if (baseTarget) {
3251
+ const isBaseSha = !!repo.baseSha;
3252
+ const fetchArgs = [...credArgs(), 'fetch', '--no-tags'];
3253
+ if (repo.depth && repo.depth > 0) fetchArgs.push('--depth', String(repo.depth));
3254
+ if (repo.filter) fetchArgs.push(`--filter=${repo.filter}`);
3255
+ if (isBaseSha) {
3256
+ // A raw sha can't be mapped to a stable name — fetch it (updates FETCH_HEAD)
3257
+ // and expose the sha itself as the diff base.
3258
+ fetchArgs.push('origin', baseTarget);
3259
+ base = baseTarget;
3260
+ } else {
3261
+ // Map the branch onto refs/remotes/origin/<baseRef> so `origin/<base>`
3262
+ // resolves for the reviewer even on a single-branch clone.
3263
+ fetchArgs.push('origin', `+${baseTarget}:refs/remotes/origin/${baseTarget}`);
3264
+ base = `origin/${baseTarget}`;
3265
+ }
3266
+ const bf = runGit(fetchArgs, { cwd: workspaceDir, env: gitEnv, timeoutMs: effectiveTimeoutMs });
3267
+ if (bf.status !== 0) {
3268
+ // Name the base target (branch vs sha) so the warning is actionable when
3269
+ // multiple refs are in play, and preserve git's output/context in both the
3270
+ // timeout and non-timeout paths.
3271
+ const baseLabel = `${isBaseSha ? 'sha' : 'branch'} ${baseTarget}`;
3272
+ base = '';
3273
+ baseFetchError = bf.timedOut
3274
+ ? `base fetch (${baseLabel}) timed out after ${effectiveTimeoutMs}ms: ${gitErrorDetail(bf, token, 300)}`
3275
+ : `base fetch (${baseLabel}) failed: ${gitErrorDetail(bf, token, 300)}`;
3276
+ }
3150
3277
  }
3151
3278
  }
3152
3279
 
@@ -3193,7 +3320,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
3193
3320
  // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
3194
3321
  // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
3195
3322
  // commit" (empty startSha) rather than a bogus revision.
3196
- return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
3323
+ return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: commitSha || branchName || '', base, baseFetchError, remote: redactToken(repo.url, token) };
3197
3324
  }
3198
3325
 
3199
3326
  // Look up a PR for this branch (2a does NOT open it — the harness does, driven
@@ -4725,12 +4852,19 @@ async function workAgent(req, flags) {
4725
4852
  runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
4726
4853
  liveRunDirs.add(runDir);
4727
4854
  provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
4855
+ if (provisioned.baseFetchError) {
4856
+ logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed — ${provisioned.baseFetchError}; base...head diffs may be unavailable`);
4857
+ }
4728
4858
  cwd = provisioned.workspaceDir;
4729
4859
  extraEnv = {
4730
4860
  AGENT_WORKSPACE: provisioned.workspaceDir,
4731
4861
  AGENT_REPO_URL: provisioned.remote,
4732
4862
  AGENT_REPO_BRANCH: provisioned.workingBranch || '',
4733
4863
  AGENT_REPO_REF: provisioned.ref || '',
4864
+ // The fetched base ref (e.g. `origin/main` or a base sha) for
4865
+ // computing `git diff <base>...HEAD`; empty when none was requested
4866
+ // or the base fetch failed (see provisioned.baseFetchError).
4867
+ AGENT_REPO_BASE: provisioned.base || '',
4734
4868
  // Pin the harness's commit identity to the resolved (placeholder-
4735
4869
  // sanitized) committer so the agent's own `git commit` can't be
4736
4870
  // hijacked by a placeholder GIT_AUTHOR_* inherited from process.env
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.35.6",
3
+ "version": "1.36.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.6",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.6",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.6",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.6",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.6",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.6",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.6"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.36.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.36.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.36.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.36.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.36.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.36.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.36.0"
67
67
  }
68
68
  }