@ran-sh/dsh-crew 2.1.6 → 2.1.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -83,3 +83,22 @@ An evidence record may contain:
83
83
  ```
84
84
 
85
85
  The matrix does not fetch or trust arbitrary remote data by itself. Loading and authenticating an evidence source is the responsibility of the higher layer that calls the builder.
86
+
87
+ `src/ci-evidence.mjs` is that layer for the `ci` rows. It resolves the running
88
+ version's tag to the commit it was cut from, reads that commit's CI run, and
89
+ evidences each platform row from its own job. Three things have to hold before a
90
+ row may go green, because each is a way to look validated without being
91
+ validated: the job's runner labels must name the platform the row claims (a job
92
+ *name* is not a platform), the run must be one this repository pushed (a pull
93
+ request carries its own workflow file, so its runs are not evidence), and the
94
+ job itself must have concluded successfully. The commit is recorded in
95
+ `evidence_ref` so `version → tag → commit` stays auditable. Nothing is read from
96
+ or written to credentials: the public API is used anonymously.
97
+
98
+ One limit belongs where a reader of a green row will meet it. The mapping starts
99
+ from the version number, so the evidence describes the commit the matching tag
100
+ points at. A payload installed with `update --candidate <dir>` can carry local
101
+ edits that were never tagged while reporting the same version, and the row is
102
+ then green for the tagged commit rather than for the tree that is running.
103
+ `evidence_ref` names that commit, which is as much as this can honestly say
104
+ until the payload records its own revision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -8,10 +8,21 @@
8
8
  // network.
9
9
  //
10
10
  // It fails closed in every direction. A version with no tag, a tag whose commit
11
- // has no run, a run that did not succeed, a missing platform job, a timeout, an
12
- // HTTP error — all of them return no evidence at all, which leaves the CI rows
13
- // NOT_RUN. Promoting a row on anything less than a green run at the exact commit
14
- // being validated is the one failure this module exists to avoid.
11
+ // has no run, a run this repository did not push, a missing platform job, a job
12
+ // that did not pass, a job whose runner is not the platform its row claims, a
13
+ // timeout, an HTTP error — all of them return no evidence at all, which leaves
14
+ // the CI rows NOT_RUN. Promoting a row on anything less than a green run at the
15
+ // exact commit being validated is the one failure this module exists to avoid.
16
+ //
17
+ // The mapping's limit, stated plainly: evidence is resolved *from the version
18
+ // number*, so it describes the commit the matching tag points at. `dsh-crew
19
+ // update --candidate <dir>` can install a tree that was never published while
20
+ // reporting the same version — its local edits are not tagged, so this module
21
+ // would present the tag's commit as the validated one. `evidence_ref` carries
22
+ // that commit so a reader can see which commit the green run covered, but it
23
+ // cannot see the running tree, and nothing here may claim otherwise. Widening
24
+ // this would need the running tree's revision, which the payload does not
25
+ // currently record.
15
26
  //
16
27
  // Authentication is optional and never required: the repository is public, so
17
28
  // the anonymous API answers. A token is used only when the caller already has
@@ -23,6 +34,10 @@ export const CI_EVIDENCE_REPO = 'Ran-sh/dsh-crew';
23
34
  // The row each CI job validates. A platform with no job in the workflow cannot
24
35
  // be evidenced by any run, so `macos_smoke` is deliberately absent rather than
25
36
  // listed and always missing.
37
+ // `runner` is a gate, not a label. Matching on the job name alone would let a
38
+ // job called `deterministic` that ran on a macOS runner evidence the *linux*
39
+ // row, which is exactly the "looks validated, isn't" the matrix exists to
40
+ // refuse; the job's own labels have to name the platform the row claims.
26
41
  const PLATFORM_JOBS = [
27
42
  { row: 'linux_deterministic', job: 'deterministic', runner: 'ubuntu' },
28
43
  { row: 'windows_regressions', job: 'windows-paths', runner: 'windows' },
@@ -30,6 +45,10 @@ const PLATFORM_JOBS = [
30
45
  ];
31
46
 
32
47
  const CACHE_TTL_MS = 10 * 60 * 1000;
48
+ // Negative results expire sooner. Caching "nothing proven" for the full window
49
+ // would let one transient network hiccup freeze a row at NOT_RUN for ten
50
+ // minutes, and the cost of asking again is bounded by the deadline.
51
+ const NEGATIVE_CACHE_TTL_MS = 60 * 1000;
33
52
  const cache = new Map();
34
53
 
35
54
  /** Test seam: drop memoized evidence between cases. */
@@ -55,8 +74,8 @@ function apiHeaders(token) {
55
74
  // route must not depend on the transport cooperating, so the request is also
56
75
  // raced against the clock. A fetch that ignores its signal then returns nothing
57
76
  // at the deadline instead of holding the route open.
58
- async function getJson(fetchImpl, url, { token, deadline }) {
59
- const remaining = deadline - Date.now();
77
+ async function getJson(fetchImpl, url, { token, deadline, now }) {
78
+ const remaining = deadline - now();
60
79
  if (remaining <= 0) return null;
61
80
  const controller = new AbortController();
62
81
  let timer;
@@ -83,14 +102,14 @@ async function getJson(fetchImpl, url, { token, deadline }) {
83
102
  * version with no tag resolves to null and no evidence is produced: the running
84
103
  * code is then not the code CI validated, and saying so is the honest answer.
85
104
  */
86
- async function resolveVersionCommit(fetchImpl, { repo, version, token, deadline }) {
105
+ async function resolveVersionCommit(fetchImpl, { repo, version, token, deadline, now }) {
87
106
  const tag = `v${version}`;
88
- const ref = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`, { token, deadline });
107
+ const ref = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`, { token, deadline, now });
89
108
  const object = ref?.object;
90
109
  if (!object) return null;
91
110
  if (object.type === 'commit' && typeof object.sha === 'string') return object.sha;
92
111
  if (object.type === 'tag' && typeof object.sha === 'string') {
93
- const annotated = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/tags/${object.sha}`, { token, deadline });
112
+ const annotated = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/tags/${object.sha}`, { token, deadline, now });
94
113
  const sha = annotated?.object?.sha;
95
114
  return typeof sha === 'string' ? sha : null;
96
115
  }
@@ -103,7 +122,11 @@ function evidenceFor(jobs, { runId, sha }) {
103
122
  const match = jobs.find((entry) => entry?.name === job);
104
123
  if (!match) continue;
105
124
  if (String(match.conclusion ?? '').toLowerCase() !== 'success') continue;
106
- const labels = Array.isArray(match.labels) ? match.labels.join(',') : '';
125
+ // The job's labels must name the platform the row claims. A name is not a
126
+ // platform, and a runner label that does not match is treated as no
127
+ // evidence at all rather than as evidence for the wrong row.
128
+ const labels = Array.isArray(match.labels) ? match.labels : [];
129
+ if (!labels.some((label) => String(label).toLowerCase().includes(runner))) continue;
107
130
  evidence[row] = {
108
131
  status: 'PASS',
109
132
  reason_code: 'CI_GREEN',
@@ -111,7 +134,7 @@ function evidenceFor(jobs, { runId, sha }) {
111
134
  // The commit is part of the reference on purpose: version -> tag -> commit
112
135
  // is a mapping, and the reader has to be able to audit which commit the
113
136
  // green run actually covered.
114
- evidence_ref: `run-${runId}/${job}/${runner}@${String(sha).slice(0, 12)}${labels ? ` (${labels})` : ''}`,
137
+ evidence_ref: `run-${runId}/${job}/${runner}@${String(sha).slice(0, 12)}${labels.length ? ` (${labels.join(',')})` : ''}`,
115
138
  };
116
139
  }
117
140
  return evidence;
@@ -133,30 +156,41 @@ export async function loadCiEvidence({
133
156
  if (typeof version !== 'string' || version.trim() === '') return {};
134
157
  if (typeof fetchImpl !== 'function') return {};
135
158
 
136
- const key = `${repo}@${version.trim()}`;
159
+ // The credential is part of the key: an anonymous result produced under a
160
+ // rate limit must not be served to a call that offered a token.
161
+ const key = `${repo}@${version.trim()}@${typeof token === 'string' && token.trim() !== '' ? 'auth' : 'anon'}`;
137
162
  const hit = cache.get(key);
138
- if (useCache && hit && now() - hit.at < CACHE_TTL_MS) return hit.evidence;
163
+ if (useCache && hit) {
164
+ const ttl = Object.keys(hit.evidence).length > 0 ? CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS;
165
+ if (now() - hit.at < ttl) return hit.evidence;
166
+ }
139
167
 
140
168
  const deadline = now() + timeoutMs;
141
- const evidence = await loadUncached({ repo, version: version.trim(), token, fetchImpl, deadline });
169
+ const evidence = await loadUncached({ repo, version: version.trim(), token, fetchImpl, deadline, now });
142
170
  cache.set(key, { at: now(), evidence });
143
171
  return evidence;
144
172
  }
145
173
 
146
- async function loadUncached({ repo, version, token, fetchImpl, deadline }) {
147
- const sha = await resolveVersionCommit(fetchImpl, { repo, version, token, deadline });
174
+ async function loadUncached({ repo, version, token, fetchImpl, deadline, now }) {
175
+ const sha = await resolveVersionCommit(fetchImpl, { repo, version, token, deadline, now });
148
176
  if (!sha) return {};
149
177
 
150
- const runs = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs?head_sha=${sha}&per_page=20`, { token, deadline });
178
+ const runs = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs?head_sha=${sha}&per_page=20`, { token, deadline, now });
151
179
  const list = Array.isArray(runs?.workflow_runs) ? runs.workflow_runs : [];
152
- // The newest CI run for this commit, and each row judged by its own job. The
153
- // run's overall conclusion is deliberately not a gate: one platform's red job
154
- // must not withdraw another platform's evidence, and each row asks only
155
- // whether *its* validation ran and passed.
156
- const ciRun = list.find((run) => run?.name === 'CI');
180
+ // The newest CI run for this commit, and each row judged by its own job.
181
+ //
182
+ // The run's overall conclusion is deliberately not a gate: one platform's red
183
+ // job must not withdraw another platform's evidence, and each row asks only
184
+ // whether *its* validation ran and passed. The run's *identity* is a gate
185
+ // though. A workflow is matched by name, and a name is not an identity — a
186
+ // pull request can carry a workflow file of its own, so a run this repository
187
+ // did not push, or one belonging to a fork, is not accepted as evidence.
188
+ const ciRun = list.find((run) => run?.name === 'CI'
189
+ && String(run?.event ?? '').toLowerCase() === 'push'
190
+ && run?.head_repository?.full_name === repo);
157
191
  if (!ciRun) return {};
158
192
 
159
- const jobsBody = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs/${ciRun.id}/jobs?per_page=50`, { token, deadline });
193
+ const jobsBody = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs/${ciRun.id}/jobs?per_page=50`, { token, deadline, now });
160
194
  const jobs = Array.isArray(jobsBody?.jobs) ? jobsBody.jobs : [];
161
195
  return evidenceFor(jobs, { runId: ciRun.id, sha });
162
196
  }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.1.6';
39
+ export const RUNTIME_VERSION = '2.1.8';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -408,9 +408,7 @@ export function createWorkflowRuntime(adapters, {
408
408
  source: job.source,
409
409
  model_class_hint: job.model_class_hint,
410
410
  escalation_reason: escalationReason,
411
- onAttemptStarted: (actualId) => {
412
- if (typeof actualId === 'string' && actualId) job.current_attempt_id = actualId;
413
- },
411
+ onAttemptStarted: (actualId) => adoptAttemptId(job, actualId),
414
412
  });
415
413
  job.current_attempt_id = null;
416
414
  if (job.cancelling) { await cancelWorkflow(job); return; }
@@ -602,9 +600,7 @@ export function createWorkflowRuntime(adapters, {
602
600
  source: job.source,
603
601
  model_class_hint: 'pro',
604
602
  escalation_reason: null,
605
- onAttemptStarted: (actualId) => {
606
- if (typeof actualId === 'string' && actualId) job.current_attempt_id = actualId;
607
- },
603
+ onAttemptStarted: (actualId) => adoptAttemptId(job, actualId),
608
604
  });
609
605
  job.current_attempt_id = null;
610
606
  const attemptView = { ...attemptRecord(ar, 0), phase: 'review' };
@@ -665,6 +661,31 @@ export function createWorkflowRuntime(adapters, {
665
661
  : `${workflowId}-a${suffix || '0'}`;
666
662
  }
667
663
 
664
+ /**
665
+ * Record the id the executor actually started, and honour a cancel that
666
+ * arrived while it was still being dispatched.
667
+ *
668
+ * `cancelWorkflow` can only stop what `current_attempt_id` names. During
669
+ * dispatch that is the workflow-scoped placeholder, which the transport does
670
+ * not recognise as a runnable attempt, so a cancel landing in that window
671
+ * stops nothing — and the job the executor starts a moment later runs to
672
+ * completion while the workflow reports itself cancelled. Re-cancelling after
673
+ * the attempt returns does not help: `cancelWorkflow` returns the promise it
674
+ * already made. This is the one moment the real id becomes known, so the
675
+ * cancel that already happened is applied here.
676
+ */
677
+ function adoptAttemptId(job, actualId) {
678
+ if (typeof actualId !== 'string' || actualId === '') return;
679
+ job.current_attempt_id = actualId;
680
+ if (!job.cancelling) return;
681
+ try {
682
+ Promise.resolve(adapters.cancelAttempt?.(actualId)).catch(() => {});
683
+ } catch {
684
+ // A transport that throws synchronously is still a cancel that did not
685
+ // land; the workflow's own terminal state is unchanged by it.
686
+ }
687
+ }
688
+
668
689
  function cancelWorkflow(job) {
669
690
  if (job.cancelPromise) return job.cancelPromise;
670
691
  if (job.status !== 'running') return;