@clipboard-health/groundcrew 4.2.0 → 4.2.2

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 (70) hide show
  1. package/README.md +15 -25
  2. package/dist/commands/cleaner.d.ts +1 -1
  3. package/dist/commands/cleaner.d.ts.map +1 -1
  4. package/dist/commands/cleaner.js +4 -2
  5. package/dist/commands/dispatcher.d.ts +7 -6
  6. package/dist/commands/dispatcher.d.ts.map +1 -1
  7. package/dist/commands/dispatcher.js +56 -28
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +18 -22
  10. package/dist/commands/eligibility.d.ts +1 -1
  11. package/dist/commands/eligibility.d.ts.map +1 -1
  12. package/dist/commands/eligibility.js +7 -6
  13. package/dist/commands/orchestrator.d.ts.map +1 -1
  14. package/dist/commands/orchestrator.js +18 -14
  15. package/dist/commands/resumeWorkspace.d.ts.map +1 -1
  16. package/dist/commands/resumeWorkspace.js +3 -2
  17. package/dist/commands/setupWorkspace.d.ts +2 -4
  18. package/dist/commands/setupWorkspace.d.ts.map +1 -1
  19. package/dist/commands/setupWorkspace.js +27 -27
  20. package/dist/commands/status.d.ts.map +1 -1
  21. package/dist/commands/status.js +6 -3
  22. package/dist/index.d.ts +3 -2
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -2
  25. package/dist/lib/adapters/linear/client.d.ts +22 -0
  26. package/dist/lib/adapters/linear/client.d.ts.map +1 -0
  27. package/dist/lib/adapters/linear/client.js +36 -0
  28. package/dist/lib/adapters/linear/factory.d.ts +24 -14
  29. package/dist/lib/adapters/linear/factory.d.ts.map +1 -1
  30. package/dist/lib/adapters/linear/factory.js +113 -46
  31. package/dist/lib/{boardSource.d.ts → adapters/linear/fetch.d.ts} +22 -74
  32. package/dist/lib/adapters/linear/fetch.d.ts.map +1 -0
  33. package/dist/lib/{boardSource.js → adapters/linear/fetch.js} +28 -136
  34. package/dist/lib/adapters/linear/index.d.ts +1 -0
  35. package/dist/lib/adapters/linear/index.d.ts.map +1 -1
  36. package/dist/lib/adapters/linear/parsing.d.ts +44 -0
  37. package/dist/lib/adapters/linear/parsing.d.ts.map +1 -0
  38. package/dist/lib/adapters/linear/parsing.js +144 -0
  39. package/dist/lib/{linearIssueStatus.d.ts → adapters/linear/writeback.d.ts} +1 -2
  40. package/dist/lib/adapters/linear/writeback.d.ts.map +1 -0
  41. package/dist/lib/{linearIssueStatus.js → adapters/linear/writeback.js} +16 -17
  42. package/dist/lib/adapters/shell/factory.d.ts +1 -1
  43. package/dist/lib/adapters/shell/factory.d.ts.map +1 -1
  44. package/dist/lib/adapters/shell/factory.js +8 -4
  45. package/dist/lib/adapters/shell/invoke.d.ts +4 -7
  46. package/dist/lib/adapters/shell/invoke.d.ts.map +1 -1
  47. package/dist/lib/adapters/shell/invoke.js +46 -75
  48. package/dist/lib/adapters/shell/schema.d.ts +10 -0
  49. package/dist/lib/adapters/shell/schema.d.ts.map +1 -1
  50. package/dist/lib/adapters/shell/schema.js +9 -5
  51. package/dist/lib/board.d.ts.map +1 -1
  52. package/dist/lib/board.js +43 -4
  53. package/dist/lib/buildSources.d.ts +11 -0
  54. package/dist/lib/buildSources.d.ts.map +1 -1
  55. package/dist/lib/buildSources.js +41 -0
  56. package/dist/lib/repositoryValidation.d.ts +13 -0
  57. package/dist/lib/repositoryValidation.d.ts.map +1 -0
  58. package/dist/lib/repositoryValidation.js +20 -0
  59. package/dist/lib/testing/canonicalFixtures.d.ts +19 -0
  60. package/dist/lib/testing/canonicalFixtures.d.ts.map +1 -0
  61. package/dist/lib/testing/canonicalFixtures.js +62 -0
  62. package/dist/lib/ticketSource.d.ts +73 -3
  63. package/dist/lib/ticketSource.d.ts.map +1 -1
  64. package/dist/lib/ticketSource.js +31 -0
  65. package/dist/lib/util.d.ts +0 -20
  66. package/dist/lib/util.d.ts.map +1 -1
  67. package/dist/lib/util.js +0 -35
  68. package/package.json +1 -1
  69. package/dist/lib/boardSource.d.ts.map +0 -1
  70. package/dist/lib/linearIssueStatus.d.ts.map +0 -1
@@ -1,12 +1,18 @@
1
- import { log } from "./util.js";
1
+ import { log } from "../../util.js";
2
2
  export function createLinearIssueStatusUpdater(arguments_) {
3
3
  const { client } = arguments_;
4
- // The first matching workflow state per team is cached by teamId so the
5
- // status writeback doesn't re-query Linear on every dispatch. Teams that
6
- // genuinely lack an `started` workflow state stay in the negative cache
7
- // until the dispatcher resets it at the top of each tick.
4
+ // Positive cache only. Keyed by teamId because the workflow `state.type ===
5
+ // "started"` lookup yields a single stateId per team independent of which
6
+ // project the ticket belongs to. State ids don't change for misconfig
7
+ // reasons, so caching successful resolutions is safe across the process.
8
+ //
9
+ // No negative cache: a missing "started" workflow state is a Linear-side
10
+ // config issue the operator can correct mid-session, and a negative cache
11
+ // would mask that recovery until process restart. Slot count caps
12
+ // markInProgress calls per tick at 1-5, so re-fetching team states on
13
+ // every failing attempt costs at most a handful of extra Linear API calls
14
+ // per tick.
8
15
  const inProgressStateByTeam = new Map();
9
- let teamsMissingInProgress = new Set();
10
16
  async function getInProgressStateId(teamId) {
11
17
  if (teamId.length === 0) {
12
18
  return undefined;
@@ -15,17 +21,13 @@ export function createLinearIssueStatusUpdater(arguments_) {
15
21
  if (cached !== undefined) {
16
22
  return cached;
17
23
  }
18
- if (teamsMissingInProgress.has(teamId)) {
19
- return undefined;
20
- }
21
24
  const team = await client.team(teamId);
22
25
  const states = await team.states();
23
- // Use the workflow state's *type* — Linear standardises on
24
- // `started` for in-progress columns regardless of how the user renames
25
- // them, so this works without any per-team status-name configuration.
26
+ // Use the workflow state's `type` — Linear standardises on `started` for
27
+ // in-progress columns regardless of how the user renames them, so this
28
+ // works without any per-team status-name configuration.
26
29
  const inProgress = states.nodes.find((state) => state.type === "started");
27
30
  if (inProgress?.id === undefined) {
28
- teamsMissingInProgress.add(teamId);
29
31
  return undefined;
30
32
  }
31
33
  inProgressStateByTeam.set(teamId, inProgress.id);
@@ -39,8 +41,5 @@ export function createLinearIssueStatusUpdater(arguments_) {
39
41
  await client.updateIssue(issue.uuid, { stateId });
40
42
  log(`Marked ${issue.id} as in progress`);
41
43
  }
42
- function resetMissingInProgressCache() {
43
- teamsMissingInProgress = new Set();
44
- }
45
- return { markInProgress, resetMissingInProgressCache };
44
+ return { markInProgress };
46
45
  }
@@ -14,7 +14,7 @@
14
14
  * - `fetch` is required by the Zod schema.
15
15
  */
16
16
  import type { AdapterContext } from "../../adapterDefinition.ts";
17
- import type { Issue as CanonicalIssue, TicketSource } from "../../ticketSource.ts";
17
+ import { type Issue as CanonicalIssue, type TicketSource } from "../../ticketSource.ts";
18
18
  import { type ShellAdapterConfig, type ShellIssue } from "./schema.ts";
19
19
  export declare function toCanonicalIssue(shellIssue: ShellIssue, sourceName: string): CanonicalIssue;
20
20
  export declare function createShellTicketSource(config: ShellAdapterConfig, _context: AdapterContext): TicketSource;
@@ -1 +1 @@
1
- {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/shell/factory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,KAAK,EAEV,KAAK,IAAI,cAAc,EACvB,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,KAAK,kBAAkB,EAEvB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAyBrB,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,cAAc,CAoB3F;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,kBAAkB,EAC1B,QAAQ,EAAE,cAAc,GACvB,YAAY,CA+Ed"}
1
+ {"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/shell/factory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAGL,KAAK,KAAK,IAAI,cAAc,EAC5B,KAAK,YAAY,EAClB,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,KAAK,kBAAkB,EAEvB,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAC;AAyBrB,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,GAAG,cAAc,CAsB3F;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,kBAAkB,EAC1B,QAAQ,EAAE,cAAc,GACvB,YAAY,CAgFd"}
@@ -13,6 +13,7 @@
13
13
  * - `markInProgress` absent → silent no-op.
14
14
  * - `fetch` is required by the Zod schema.
15
15
  */
16
+ import { toCanonicalId, } from "../../ticketSource.js";
16
17
  import { invokeShellCommand } from "./invoke.js";
17
18
  import { shellFetchOutputSchema, shellIssueSchema, } from "./schema.js";
18
19
  const DEFAULT_TIMEOUTS = {
@@ -31,12 +32,14 @@ function mergeTimeouts(overrides) {
31
32
  }
32
33
  export function toCanonicalIssue(shellIssue, sourceName) {
33
34
  const blockers = shellIssue.blockers.map((b) => ({
34
- id: `${sourceName}:${b.id}`,
35
+ id: toCanonicalId(sourceName, b.id),
35
36
  title: b.title,
36
37
  status: b.status,
38
+ ...(b.statusReason !== undefined && { statusReason: b.statusReason }),
39
+ ...(b.nativeStatus !== undefined && { nativeStatus: b.nativeStatus }),
37
40
  }));
38
41
  return {
39
- id: `${sourceName}:${shellIssue.id}`,
42
+ id: toCanonicalId(sourceName, shellIssue.id),
40
43
  source: sourceName,
41
44
  title: shellIssue.title,
42
45
  description: shellIssue.description,
@@ -81,10 +84,11 @@ export function createShellTicketSource(config, _context) {
81
84
  },
82
85
  fetch: runFetch,
83
86
  async resolveOne(naturalId) {
87
+ const canonicalId = toCanonicalId(sourceName, naturalId);
84
88
  const resolveCommand = config.commands.resolveOne;
85
89
  if (resolveCommand === undefined) {
86
90
  const all = await runFetch();
87
- return all.find((i) => i.id === `${sourceName}:${naturalId}`);
91
+ return all.find((i) => i.id === canonicalId);
88
92
  }
89
93
  const result = await invokeShellCommand({
90
94
  command: resolveCommand,
@@ -93,7 +97,7 @@ export function createShellTicketSource(config, _context) {
93
97
  env: config.env,
94
98
  substitutions: {
95
99
  id: naturalId,
96
- canonicalId: `${sourceName}:${naturalId}`,
100
+ canonicalId,
97
101
  name: sourceName,
98
102
  },
99
103
  sourceName,
@@ -11,19 +11,12 @@
11
11
  * Exit code 0 = success; exit code 3 = "not found" (caller decides how to
12
12
  * interpret); any other nonzero exit throws.
13
13
  */
14
- export declare const SHELL_COMMAND_MAX_BUFFER_BYTES: number;
15
14
  export declare class ShellAdapterTimeoutError extends Error {
16
15
  constructor(arguments_: {
17
16
  command: string;
18
17
  timeoutMs: number;
19
18
  });
20
19
  }
21
- export declare class ShellAdapterOutputLimitError extends Error {
22
- constructor(arguments_: {
23
- command: string;
24
- maxBytes: number;
25
- });
26
- }
27
20
  interface InvokeArgs {
28
21
  command: string;
29
22
  timeoutMs: number;
@@ -33,11 +26,15 @@ interface InvokeArgs {
33
26
  substitutions?: Record<string, string> | undefined;
34
27
  /** Source name for log prefixing. */
35
28
  sourceName: string;
29
+ /** Override the default per-stream stdout/stderr cap (10 MB). Used by tests. */
30
+ maxOutputBytes?: number;
36
31
  }
37
32
  interface InvokeResult {
38
33
  stdout: string;
39
34
  stderr: string;
40
35
  exitCode: number;
36
+ /** True if either stream hit the byte cap and the rest was discarded. */
37
+ truncated: boolean;
41
38
  }
42
39
  export declare function applySubstitutions(command: string, subs: Record<string, string>): string;
43
40
  export declare function invokeShellCommand(args: InvokeArgs): Promise<InvokeResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"invoke.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/shell/invoke.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH,eAAO,MAAM,8BAA8B,QAAmB,CAAC;AAG/D,qBAAa,wBAAyB,SAAQ,KAAK;IACjD,YAAmB,UAAU,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAGpE;CACF;AAED,qBAAa,4BAA6B,SAAQ,KAAK;IACrD,YAAmB,UAAU,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAKnE;CACF;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IACnD,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAmBD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAMxF;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CA8HhF"}
1
+ {"version":3,"file":"invoke.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/shell/invoke.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAcH,qBAAa,wBAAyB,SAAQ,KAAK;IACjD,YAAmB,UAAU,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAGpE;CACF;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IACzC,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IACnD,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,UAAU,YAAY;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,SAAS,EAAE,OAAO,CAAC;CACpB;AAMD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAMxF;AAED,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CAkGhF"}
@@ -13,31 +13,22 @@
13
13
  */
14
14
  import { spawn } from "node:child_process";
15
15
  import { log } from "../../util.js";
16
- export const SHELL_COMMAND_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
17
- const TIMEOUT_SIGNAL = "SIGKILL";
16
+ /**
17
+ * Hard cap on captured stdout/stderr per stream. Misbehaving scripts that
18
+ * `yes | head -c <huge>` would otherwise exhaust memory. 10 MB is enough for
19
+ * any realistic JSON ticket payload; tests can override via InvokeArgs.maxOutputBytes
20
+ * to exercise the truncation path with a smaller fixture.
21
+ */
22
+ const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
18
23
  export class ShellAdapterTimeoutError extends Error {
19
24
  constructor(arguments_) {
20
25
  super(`Shell command timed out after ${arguments_.timeoutMs}ms: ${arguments_.command}`);
21
26
  this.name = "ShellAdapterTimeoutError";
22
27
  }
23
28
  }
24
- export class ShellAdapterOutputLimitError extends Error {
25
- constructor(arguments_) {
26
- super(`Shell command exceeded combined stdout/stderr maxBuffer of ${arguments_.maxBytes} bytes: ${arguments_.command}`);
27
- this.name = "ShellAdapterOutputLimitError";
28
- }
29
- }
30
29
  function shellQuote(value) {
31
30
  return `'${value.replaceAll("'", String.raw `'\''`)}'`;
32
31
  }
33
- function killChildProcess(child, signal, shouldUseProcessGroup) {
34
- /* v8 ignore next 4 @preserve -- fallback path is for Windows or a spawn failure before pid assignment */
35
- if (!shouldUseProcessGroup || child.pid === undefined) {
36
- child.kill(signal);
37
- return;
38
- }
39
- process.kill(-child.pid, signal);
40
- }
41
32
  export function applySubstitutions(command, subs) {
42
33
  let result = command;
43
34
  for (const [key, value] of Object.entries(subs)) {
@@ -49,92 +40,72 @@ export async function invokeShellCommand(args) {
49
40
  const command = args.substitutions === undefined
50
41
  ? args.command
51
42
  : applySubstitutions(args.command, args.substitutions);
43
+ const maxBytes = args.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
52
44
  return await new Promise((resolve, reject) => {
53
- const shouldUseProcessGroup = process.platform !== "win32";
54
45
  const child = spawn("sh", ["-c", command], {
55
46
  cwd: args.cwd,
56
- detached: shouldUseProcessGroup,
57
47
  // oxlint-disable-next-line node/no-process-env -- subprocess inherits the parent's full env by design; user-supplied vars layer on top
58
48
  env: { ...process.env, ...args.env },
59
49
  stdio: ["pipe", "pipe", "pipe"],
60
50
  });
61
- const stdoutChunks = [];
62
- const stderrChunks = [];
63
- let stdoutLength = 0;
64
- let stderrLength = 0;
51
+ let stdout = Buffer.alloc(0);
52
+ let stderr = Buffer.alloc(0);
53
+ let truncated = false;
65
54
  let settled = false;
66
- function cleanup() {
67
- clearTimeout(timer);
68
- }
69
- function killChild(signal) {
70
- try {
71
- killChildProcess(child, signal, shouldUseProcessGroup);
72
- }
73
- catch {
74
- // The child may have exited between timeout/output-limit handling and the kill request.
75
- }
76
- }
77
- function failAndKill(error) {
78
- /* v8 ignore next 3 @preserve -- timeout/output-limit races can call this after another terminal event; deterministic tests cover the first terminal path */
55
+ const timer = setTimeout(() => {
56
+ /* v8 ignore next 3 @preserve -- timer/close race: clearTimeout in the close handler should prevent this branch, but the guard is kept as defense-in-depth */
79
57
  if (settled) {
80
58
  return;
81
59
  }
82
60
  settled = true;
83
- cleanup();
84
- killChild(TIMEOUT_SIGNAL);
85
- reject(error);
86
- }
87
- function appendOutput(input) {
88
- /* v8 ignore next 3 @preserve -- streams may emit after timeout/output-limit settlement; this race guard is intentionally defensive */
89
- if (settled) {
90
- return input.currentLength;
61
+ child.kill("SIGKILL");
62
+ reject(new ShellAdapterTimeoutError({ command, timeoutMs: args.timeoutMs }));
63
+ }, args.timeoutMs);
64
+ // Buffer accumulators so the byte cap matches its name. A string-based
65
+ // comparison would measure UTF-16 code units against a byte budget,
66
+ // letting multibyte UTF-8 sneak past the cap and risking a mid-
67
+ // surrogate-pair slice on truncation.
68
+ const appendCapped = (current, chunk) => {
69
+ if (current.byteLength >= maxBytes) {
70
+ truncated = true;
71
+ return current;
91
72
  }
92
- const nextCombinedLength = stdoutLength + stderrLength + input.chunk.length;
93
- if (nextCombinedLength > SHELL_COMMAND_MAX_BUFFER_BYTES) {
94
- failAndKill(new ShellAdapterOutputLimitError({
95
- command,
96
- maxBytes: SHELL_COMMAND_MAX_BUFFER_BYTES,
97
- }));
98
- return input.currentLength;
73
+ const next = Buffer.concat([current, chunk]);
74
+ if (next.byteLength <= maxBytes) {
75
+ return next;
99
76
  }
100
- input.chunks.push(input.chunk);
101
- return input.currentLength + input.chunk.length;
102
- }
103
- const timer = setTimeout(() => {
104
- failAndKill(new ShellAdapterTimeoutError({ command, timeoutMs: args.timeoutMs }));
105
- }, args.timeoutMs);
77
+ truncated = true;
78
+ const clipped = next.subarray(0, maxBytes);
79
+ return Buffer.concat([
80
+ clipped,
81
+ Buffer.from(`\n[truncated: stream exceeded ${maxBytes} bytes]`),
82
+ ]);
83
+ };
106
84
  child.stdout.on("data", (chunk) => {
107
- stdoutLength = appendOutput({
108
- chunks: stdoutChunks,
109
- currentLength: stdoutLength,
110
- chunk,
111
- });
85
+ stdout = appendCapped(stdout, chunk);
112
86
  });
113
87
  child.stderr.on("data", (chunk) => {
114
- stderrLength = appendOutput({
115
- chunks: stderrChunks,
116
- currentLength: stderrLength,
117
- chunk,
118
- });
88
+ stderr = appendCapped(stderr, chunk);
119
89
  });
120
90
  child.on("close", (code) => {
91
+ /* v8 ignore next 3 @preserve -- timer/close race: when the timeout fires first it SIGKILLs and sets settled=true; the 'close' event still arrives and must no-op. No deterministic test exists — an orphaned grandchild (`sh -c "sleep N; ..."`) keeps the stdout pipe open, so 'close' doesn't arrive until the real timeout elapses; mirrors the ignored timer/error settle guards above. */
121
92
  if (settled) {
122
93
  return;
123
94
  }
124
95
  settled = true;
125
- cleanup();
126
- const stdout = Buffer.concat(stdoutChunks, stdoutLength).toString("utf8");
127
- const stderr = Buffer.concat(stderrChunks, stderrLength).toString("utf8");
128
- if (stderr.length > 0) {
129
- log(`[shell:${args.sourceName}] ${command}\n${stderr.trimEnd()}`);
96
+ clearTimeout(timer);
97
+ const stderrText = stderr.toString("utf8");
98
+ if (stderrText.length > 0) {
99
+ log(`[shell:${args.sourceName}] ${command}\n${stderrText.trimEnd()}`);
130
100
  }
131
- /* v8 ignore next @preserve -- `code` is null only when the process was killed by signal; timeout/output-limit paths settle before 'close' */
101
+ /* v8 ignore next @preserve -- `code` is null only when the process was killed by signal; the timeout path SIGKILLs but settles via the timer rather than 'close' */
132
102
  const exitCode = code ?? 1;
103
+ const stdoutText = stdout.toString("utf8");
133
104
  if (exitCode === 0 || exitCode === 3) {
134
- resolve({ stdout, stderr, exitCode });
105
+ resolve({ stdout: stdoutText, stderr: stderrText, exitCode, truncated });
135
106
  return;
136
107
  }
137
- reject(new Error(`Shell command for source "${args.sourceName}" failed with exit ${exitCode}: ${stderr.trim().length > 0 ? stderr.trim() : command}`));
108
+ reject(new Error(`Shell command for source "${args.sourceName}" failed with exit ${exitCode}: ${stderrText.trim().length > 0 ? stderrText.trim() : command}`));
138
109
  });
139
110
  /* v8 ignore next 8 @preserve -- spawn 'error' event fires only on exec failures (PATH miss, EACCES) which are hard to simulate in tests without polluting host PATH */
140
111
  child.on("error", (error) => {
@@ -142,7 +113,7 @@ export async function invokeShellCommand(args) {
142
113
  return;
143
114
  }
144
115
  settled = true;
145
- cleanup();
116
+ clearTimeout(timer);
146
117
  reject(error);
147
118
  });
148
119
  if (args.stdin !== undefined) {
@@ -34,6 +34,11 @@ export declare const shellIssueSchema: z.ZodObject<{
34
34
  other: "other";
35
35
  todo: "todo";
36
36
  }>;
37
+ statusReason: z.ZodOptional<z.ZodEnum<{
38
+ missing: "missing";
39
+ unmapped: "unmapped";
40
+ }>>;
41
+ nativeStatus: z.ZodOptional<z.ZodString>;
37
42
  }, z.core.$strip>>;
38
43
  hasMoreBlockers: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
39
44
  sourceRef: z.ZodUnknown;
@@ -64,6 +69,11 @@ export declare const shellFetchOutputSchema: z.ZodArray<z.ZodObject<{
64
69
  other: "other";
65
70
  todo: "todo";
66
71
  }>;
72
+ statusReason: z.ZodOptional<z.ZodEnum<{
73
+ missing: "missing";
74
+ unmapped: "unmapped";
75
+ }>>;
76
+ nativeStatus: z.ZodOptional<z.ZodString>;
67
77
  }, z.core.$strip>>;
68
78
  hasMoreBlockers: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
69
79
  sourceRef: z.ZodUnknown;
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/shell/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAWxB,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAA4B,CAAC;AAEhE,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;iBAqBnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../../../src/lib/adapters/shell/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAYxB,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAY3B,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBAA4B,CAAC;AAEhE,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;iBAwBnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC"}
@@ -10,11 +10,12 @@
10
10
  */
11
11
  import { z } from "zod";
12
12
  const canonicalStatusSchema = z.enum(["todo", "in-progress", "in-review", "done", "other"]);
13
- const timeoutSchema = z.number().int().positive();
14
13
  const shellBlockerSchema = z.object({
15
14
  id: z.string(),
16
15
  title: z.string(),
17
16
  status: canonicalStatusSchema,
17
+ statusReason: z.enum(["missing", "unmapped"]).optional(),
18
+ nativeStatus: z.string().optional(),
18
19
  });
19
20
  export const shellIssueSchema = z.object({
20
21
  id: z.string(),
@@ -44,10 +45,13 @@ export const shellAdapterConfigSchema = z.object({
44
45
  cwd: z.string().optional(),
45
46
  timeouts: z
46
47
  .object({
47
- verify: timeoutSchema.optional(),
48
- fetch: timeoutSchema.optional(),
49
- resolveOne: timeoutSchema.optional(),
50
- markInProgress: timeoutSchema.optional(),
48
+ // Per-method timeout in milliseconds. Must be a positive integer —
49
+ // zero, negative, and fractional values would either deadlock or
50
+ // misbehave inside setTimeout.
51
+ verify: z.number().int().positive().optional(),
52
+ fetch: z.number().int().positive().optional(),
53
+ resolveOne: z.number().int().positive().optional(),
54
+ markInProgress: z.number().int().positive().optional(),
51
55
  })
52
56
  .optional(),
53
57
  env: z.record(z.string(), z.string()).optional(),
@@ -1 +1 @@
1
- {"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,KAAK,EACV,KAAK,YAAY,EAClB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,KAAK;IACpB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7B;;;OAGG;IACH,UAAU,CAAC,oBAAoB,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC;IACrE,wFAAwF;IACxF,cAAc,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAcD,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,KAAK,CAqEnE"}
1
+ {"version":3,"file":"board.d.ts","sourceRoot":"","sources":["../../src/lib/board.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAEL,KAAK,UAAU,EACf,KAAK,KAAK,EAEV,KAAK,YAAY,EAClB,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,KAAK;IACpB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7B;;;OAGG;IACH,UAAU,CAAC,oBAAoB,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,GAAG,SAAS,CAAC,CAAC;IACrE,wFAAwF;IACxF,cAAc,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAqBD,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,KAAK,CAwGnE"}
package/dist/lib/board.js CHANGED
@@ -11,6 +11,12 @@ async function callVerify(source) {
11
11
  async function callFetch(source) {
12
12
  return await source.fetch();
13
13
  }
14
+ async function callFetchParentSkips(source) {
15
+ if (source.fetchParentSkips !== undefined) {
16
+ return await source.fetchParentSkips();
17
+ }
18
+ return [];
19
+ }
14
20
  async function callResolveOne(source, naturalId) {
15
21
  return await source.resolveOne(naturalId);
16
22
  }
@@ -38,8 +44,21 @@ export function createBoard(sources) {
38
44
  }
39
45
  },
40
46
  async fetch() {
41
- const issuesPerSource = await Promise.all(sources.map(callFetch));
42
- return { timestamp: new Date().toISOString(), issues: issuesPerSource.flat() };
47
+ // Per-source serialization: each source's callFetch must complete
48
+ // before its callFetchParentSkips so adapters that cache parent skips
49
+ // as a side effect of fetch() (e.g. Linear, which stores them on
50
+ // `lastParentSkips`) don't serve stale or empty data. Outer Promise.all
51
+ // keeps cross-source fan-out concurrent.
52
+ const perSource = await Promise.all(sources.map(async (source) => {
53
+ const issues = await callFetch(source);
54
+ const parentSkips = await callFetchParentSkips(source);
55
+ return { issues, parentSkips };
56
+ }));
57
+ return {
58
+ timestamp: new Date().toISOString(),
59
+ issues: perSource.flatMap((entry) => entry.issues),
60
+ parentSkips: perSource.flatMap((entry) => entry.parentSkips),
61
+ };
43
62
  },
44
63
  async resolveOne(idArgument) {
45
64
  const colonIndex = idArgument.indexOf(":");
@@ -52,9 +71,29 @@ export function createBoard(sources) {
52
71
  }
53
72
  return await callResolveOne(source, naturalId);
54
73
  }
55
- const results = await Promise.all(sources.map(async (s) => await callResolveOne(s, idArgument)));
56
- const matches = results.filter((r) => r !== undefined);
74
+ // Per-source resolveOne errors must not poison sibling resolutions.
75
+ // A source that rejects on a natural-id lookup is treated as "I don't
76
+ // have this ticket" (or "I can't say"). If any source resolved we use
77
+ // it; only when none resolved AND at least one rejected do we surface
78
+ // the rejection — so the user sees a real Linear/network error when
79
+ // there's no fallback, but a stray "not found" from one source doesn't
80
+ // mask a successful match from another.
81
+ const results = await Promise.allSettled(sources.map(async (s) => await callResolveOne(s, idArgument)));
82
+ const matches = [];
83
+ const rejections = [];
84
+ for (const result of results) {
85
+ if (result.status === "rejected") {
86
+ rejections.push(result.reason);
87
+ continue;
88
+ }
89
+ if (result.value !== undefined) {
90
+ matches.push(result.value);
91
+ }
92
+ }
57
93
  if (matches.length === 0) {
94
+ if (rejections.length > 0) {
95
+ throw rejections[0];
96
+ }
58
97
  return undefined;
59
98
  }
60
99
  if (matches.length === 1) {
@@ -6,6 +6,7 @@
6
6
  * directory-scanned `adapterRegistry`.
7
7
  */
8
8
  import type { AdapterContext, AdapterDefinition } from "./adapterDefinition.ts";
9
+ import type { ResolvedConfig } from "./config.ts";
9
10
  import type { TicketSource } from "./ticketSource.ts";
10
11
  /**
11
12
  * Production entry point. Awaits the directory-scanned registry, then dispatches.
@@ -16,4 +17,14 @@ export declare function buildSources(rawConfigs: readonly unknown[], context: Ad
16
17
  * import side effects.
17
18
  */
18
19
  export declare function buildSourcesWith(registry: Record<string, AdapterDefinition>, rawConfigs: readonly unknown[], context: AdapterContext): TicketSource[];
20
+ /**
21
+ * Build the runtime source list from a ResolvedConfig: synthesizes the
22
+ * implicit Linear source (Linear is always active under the post-#110
23
+ * model — viewer + agent-* label filtering happens at the GraphQL layer)
24
+ * and appends any user-declared `sources`. The implicit source is omitted
25
+ * when the user already declared a Linear source (by `kind` or by runtime
26
+ * name "linear") so they can override its `name` / construction without
27
+ * spawning a duplicate adapter.
28
+ */
29
+ export declare function sourcesFromConfig(config: ResolvedConfig): readonly unknown[];
19
30
  //# sourceMappingURL=buildSources.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"buildSources.d.ts","sourceRoot":"","sources":["../../src/lib/buildSources.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAEhF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAItD;;GAEG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,SAAS,OAAO,EAAE,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EAC3C,UAAU,EAAE,SAAS,OAAO,EAAE,EAC9B,OAAO,EAAE,cAAc,GACtB,YAAY,EAAE,CAchB"}
1
+ {"version":3,"file":"buildSources.d.ts","sourceRoot":"","sources":["../../src/lib/buildSources.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAItD;;GAEG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,SAAS,OAAO,EAAE,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,YAAY,EAAE,CAAC,CAGzB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,EAC3C,UAAU,EAAE,SAAS,OAAO,EAAE,EAC9B,OAAO,EAAE,cAAc,GACtB,YAAY,EAAE,CAchB;AA6BD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,cAAc,GAAG,SAAS,OAAO,EAAE,CAM5E"}
@@ -32,3 +32,44 @@ export function buildSourcesWith(registry, rawConfigs, context) {
32
32
  return adapter.create(config, context);
33
33
  });
34
34
  }
35
+ const sourceShape = z.looseObject({
36
+ name: z.string().optional(),
37
+ kind: z.string().optional(),
38
+ });
39
+ /**
40
+ * True when `raw` is an explicitly-declared Linear source. Matches either a
41
+ * `kind: "linear"` entry — regardless of any `name` override — or any entry
42
+ * whose resolved runtime name (explicit `name`, else `kind`) is "linear".
43
+ * The latter catches a non-Linear adapter the user named "linear", which
44
+ * would otherwise collide with the implicit Linear source.
45
+ *
46
+ * Used to suppress the synthesized implicit Linear source so a renamed Linear
47
+ * entry like `{ kind: "linear", name: "custom" }` doesn't spawn a duplicate
48
+ * adapter pointed at the same viewer. Returns false for malformed entries
49
+ * (no `kind`/`name`) — those get rejected by the per-adapter Zod schema
50
+ * downstream.
51
+ */
52
+ function isExplicitLinearSource(raw) {
53
+ const parsed = sourceShape.safeParse(raw);
54
+ /* v8 ignore next 3 @preserve -- looseObject() with all-optional fields only fails to parse non-object inputs (null, primitives); the same input would be rejected by the per-adapter Zod schema in buildSourcesWith, so this guard never fires in practice. */
55
+ if (!parsed.success) {
56
+ return false;
57
+ }
58
+ return parsed.data.kind === "linear" || (parsed.data.name ?? parsed.data.kind) === "linear";
59
+ }
60
+ /**
61
+ * Build the runtime source list from a ResolvedConfig: synthesizes the
62
+ * implicit Linear source (Linear is always active under the post-#110
63
+ * model — viewer + agent-* label filtering happens at the GraphQL layer)
64
+ * and appends any user-declared `sources`. The implicit source is omitted
65
+ * when the user already declared a Linear source (by `kind` or by runtime
66
+ * name "linear") so they can override its `name` / construction without
67
+ * spawning a duplicate adapter.
68
+ */
69
+ export function sourcesFromConfig(config) {
70
+ const hasExplicitLinear = config.sources.some(isExplicitLinearSource);
71
+ if (hasExplicitLinear) {
72
+ return [...config.sources];
73
+ }
74
+ return [{ kind: "linear" }, ...config.sources];
75
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Host-level repository validation for canonical Issues. Adapters produce
3
+ * Issue.repository based on their own signal (Linear: agent-* label parse;
4
+ * shell: script JSON output). The host (dispatcher) decides whether that
5
+ * repository is configured for this crew via workspace.knownRepositories.
6
+ *
7
+ * WARN+skip on unknown repo is a deliberate behavior choice (P-refined in
8
+ * the MVP-2 plan): one badly-labelled ticket should not throw and abort
9
+ * the tick across N sources.
10
+ */
11
+ import type { Issue } from "./ticketSource.ts";
12
+ export declare function dispatchableRepository(issue: Issue, knownRepositories: readonly string[], log: (message: string) => void): string | undefined;
13
+ //# sourceMappingURL=repositoryValidation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repositoryValidation.d.ts","sourceRoot":"","sources":["../../src/lib/repositoryValidation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAE/C,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,KAAK,EACZ,iBAAiB,EAAE,SAAS,MAAM,EAAE,EACpC,GAAG,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAC7B,MAAM,GAAG,SAAS,CAWpB"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Host-level repository validation for canonical Issues. Adapters produce
3
+ * Issue.repository based on their own signal (Linear: agent-* label parse;
4
+ * shell: script JSON output). The host (dispatcher) decides whether that
5
+ * repository is configured for this crew via workspace.knownRepositories.
6
+ *
7
+ * WARN+skip on unknown repo is a deliberate behavior choice (P-refined in
8
+ * the MVP-2 plan): one badly-labelled ticket should not throw and abort
9
+ * the tick across N sources.
10
+ */
11
+ export function dispatchableRepository(issue, knownRepositories, log) {
12
+ if (issue.repository === undefined) {
13
+ return undefined;
14
+ }
15
+ if (!knownRepositories.includes(issue.repository)) {
16
+ log(`issue ${issue.id} references unknown repository ${issue.repository}; configured workspace.knownRepositories: ${knownRepositories.join(", ") || "(none)"}`);
17
+ return undefined;
18
+ }
19
+ return issue.repository;
20
+ }
@@ -0,0 +1,19 @@
1
+ import { type Blocker, type Issue } from "../ticketSource.ts";
2
+ export declare function canonicalLinearIssue(overrides: Partial<Issue> & {
3
+ naturalId: string;
4
+ }): Issue;
5
+ export declare function canonicalBlocker(overrides: Partial<Blocker> & {
6
+ naturalId: string;
7
+ }): Blocker;
8
+ /**
9
+ * Canonical Issue fixture for a non-Linear source. Default source name is
10
+ * "shell-test"; override via `sourceName`. Mirrors `canonicalLinearIssue`'s
11
+ * defaults except `sourceRef` is an empty opaque object (no LinearSourceRef
12
+ * shape, since this is meant to stand in for any non-Linear adapter — the
13
+ * shell adapter, future Jira adapter, etc.).
14
+ */
15
+ export declare function canonicalShellIssue(overrides: Partial<Issue> & {
16
+ naturalId: string;
17
+ sourceName?: string;
18
+ }): Issue;
19
+ //# sourceMappingURL=canonicalFixtures.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canonicalFixtures.d.ts","sourceRoot":"","sources":["../../../src/lib/testing/canonicalFixtures.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,OAAO,EAAE,KAAK,KAAK,EAAiB,MAAM,oBAAoB,CAAC;AAE7E,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,KAAK,CA0B7F;AAED,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAQ7F;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,GACrE,KAAK,CAiBP"}