@ianwremmel/dispatch 0.32.1-bootstrap.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 (165) hide show
  1. package/.claude-plugin/plugin.json +59 -0
  2. package/.mcp.json +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +93 -0
  5. package/agents/.gitkeep +0 -0
  6. package/agents/build-graph.md +99 -0
  7. package/agents/milestone-reviewer.md +50 -0
  8. package/agents/pr-worker.md +172 -0
  9. package/agents/ticket-worker.md +97 -0
  10. package/bin/dispatch +101 -0
  11. package/bin/dispatch-mcp +19 -0
  12. package/bin/pr-status +931 -0
  13. package/commands/.gitkeep +0 -0
  14. package/commands/orchestrate.md +6 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/hooks/claim-guard.mts +98 -0
  17. package/hooks/hooks.json +15 -0
  18. package/package.json +46 -0
  19. package/skills/.gitkeep +0 -0
  20. package/skills/land/SKILL.md +238 -0
  21. package/skills/land/credentials-dedicated.md +33 -0
  22. package/skills/land/credentials-shared.md +76 -0
  23. package/skills/land/mode-solo.md +76 -0
  24. package/skills/land/mode-team.md +103 -0
  25. package/skills/land/reference.md +152 -0
  26. package/skills/land/ticket.md +94 -0
  27. package/skills/orchestrate/SKILL.md +87 -0
  28. package/skills/tracker-adapter-linear/SKILL.md +142 -0
  29. package/src/commands/CLAUDE.md +12 -0
  30. package/src/commands/claim/check.mts +88 -0
  31. package/src/commands/claim/guard.mts +95 -0
  32. package/src/commands/claim/status.mts +49 -0
  33. package/src/commands/edge/add.mts +44 -0
  34. package/src/commands/edge/rm.mts +44 -0
  35. package/src/commands/edge/set.mts +56 -0
  36. package/src/commands/greet.mts +34 -0
  37. package/src/commands/mcp/ack.mts +43 -0
  38. package/src/commands/mcp/ping.mts +61 -0
  39. package/src/commands/mcp/status.mts +89 -0
  40. package/src/commands/mcp.mts +155 -0
  41. package/src/commands/milestone/rm.mts +35 -0
  42. package/src/commands/milestone/set.mts +49 -0
  43. package/src/commands/outcome/rm.mts +36 -0
  44. package/src/commands/outcome/set.mts +86 -0
  45. package/src/commands/pr/rm.mts +33 -0
  46. package/src/commands/pr/set.mts +110 -0
  47. package/src/commands/pr/yield.mts +114 -0
  48. package/src/commands/project/rm.mts +33 -0
  49. package/src/commands/project/set.mts +50 -0
  50. package/src/commands/queue.mts +41 -0
  51. package/src/commands/refresh/done.mts +42 -0
  52. package/src/commands/refresh/status.mts +40 -0
  53. package/src/commands/refresh.mts +56 -0
  54. package/src/commands/review/record.mts +46 -0
  55. package/src/commands/review/release.mts +49 -0
  56. package/src/commands/status.mts +85 -0
  57. package/src/commands/ticket/missing.mts +31 -0
  58. package/src/commands/ticket/rm.mts +33 -0
  59. package/src/commands/ticket/set.mts +134 -0
  60. package/src/commands/worker/rm.mts +46 -0
  61. package/src/commands/worker/set.mts +63 -0
  62. package/src/lib/cli/CLAUDE.md +13 -0
  63. package/src/lib/cli/cli.mts +226 -0
  64. package/src/lib/cli/index.mts +1 -0
  65. package/src/lib/command/CLAUDE.md +26 -0
  66. package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
  67. package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
  68. package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
  69. package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
  70. package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
  71. package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
  72. package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
  73. package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
  74. package/src/lib/command/__fixtures__/commands/store.mts +26 -0
  75. package/src/lib/command/abstract-command.mts +104 -0
  76. package/src/lib/command/discovery.mts +100 -0
  77. package/src/lib/command/env.mts +19 -0
  78. package/src/lib/command/index.mts +6 -0
  79. package/src/lib/command/parse.mts +64 -0
  80. package/src/lib/command/test-support.mts +81 -0
  81. package/src/lib/command/transports.mts +17 -0
  82. package/src/lib/command/types.mts +53 -0
  83. package/src/lib/db/CLAUDE.md +13 -0
  84. package/src/lib/db/database.mts +160 -0
  85. package/src/lib/db/index.mts +4 -0
  86. package/src/lib/db/schema.mts +195 -0
  87. package/src/lib/db/time.mts +24 -0
  88. package/src/lib/db/with-database.mts +56 -0
  89. package/src/lib/errors/CLAUDE.md +18 -0
  90. package/src/lib/errors/command-error.mts +13 -0
  91. package/src/lib/errors/data-error.mts +12 -0
  92. package/src/lib/errors/definition-error.mts +6 -0
  93. package/src/lib/errors/dispatch-error.mts +27 -0
  94. package/src/lib/errors/ensure.mts +22 -0
  95. package/src/lib/errors/environment-error.mts +7 -0
  96. package/src/lib/errors/index.mts +8 -0
  97. package/src/lib/errors/json-rpc-error.mts +18 -0
  98. package/src/lib/errors/usage-error.mts +7 -0
  99. package/src/lib/graph/CLAUDE.md +17 -0
  100. package/src/lib/graph/anomalies.mts +110 -0
  101. package/src/lib/graph/derive.mts +96 -0
  102. package/src/lib/graph/index.mts +26 -0
  103. package/src/lib/graph/pipeline.mts +410 -0
  104. package/src/lib/graph/queries.mts +207 -0
  105. package/src/lib/graph/rows.mts +99 -0
  106. package/src/lib/graph/types.mts +137 -0
  107. package/src/lib/liveness/CLAUDE.md +14 -0
  108. package/src/lib/liveness/index.mts +10 -0
  109. package/src/lib/liveness/liveness.mts +147 -0
  110. package/src/lib/liveness/retire.mts +63 -0
  111. package/src/lib/logger/CLAUDE.md +12 -0
  112. package/src/lib/logger/index.mts +2 -0
  113. package/src/lib/logger/logger.mts +58 -0
  114. package/src/lib/logger/stream-sink.mts +23 -0
  115. package/src/lib/mcp/CLAUDE.md +21 -0
  116. package/src/lib/mcp/channel.mts +41 -0
  117. package/src/lib/mcp/dispatch.mts +60 -0
  118. package/src/lib/mcp/drain.mts +83 -0
  119. package/src/lib/mcp/index.mts +5 -0
  120. package/src/lib/mcp/mcp.mts +267 -0
  121. package/src/lib/mcp/tools.mts +77 -0
  122. package/src/lib/model/CLAUDE.md +8 -0
  123. package/src/lib/model/index.mts +3 -0
  124. package/src/lib/model/repo-caps.mts +95 -0
  125. package/src/lib/model/status.mts +91 -0
  126. package/src/lib/model/types.mts +83 -0
  127. package/src/lib/refresh/index.mts +2 -0
  128. package/src/lib/refresh/placeholders.mts +43 -0
  129. package/src/lib/refresh/refresh-service.mts +203 -0
  130. package/src/lib/schedule/CLAUDE.md +18 -0
  131. package/src/lib/schedule/caps.mts +113 -0
  132. package/src/lib/schedule/correlate.mts +69 -0
  133. package/src/lib/schedule/index.mts +7 -0
  134. package/src/lib/schedule/scheduler.mts +355 -0
  135. package/src/lib/schedule/tick.mts +266 -0
  136. package/src/lib/stores/CLAUDE.md +24 -0
  137. package/src/lib/stores/coordination.mts +359 -0
  138. package/src/lib/stores/cursor.mts +41 -0
  139. package/src/lib/stores/edge.mts +138 -0
  140. package/src/lib/stores/fetch-request.mts +346 -0
  141. package/src/lib/stores/index.mts +19 -0
  142. package/src/lib/stores/materialize.mts +69 -0
  143. package/src/lib/stores/milestone.mts +74 -0
  144. package/src/lib/stores/notice.mts +57 -0
  145. package/src/lib/stores/policy.mts +48 -0
  146. package/src/lib/stores/pr-event.mts +94 -0
  147. package/src/lib/stores/pr.mts +167 -0
  148. package/src/lib/stores/project.mts +79 -0
  149. package/src/lib/stores/refresh.mts +197 -0
  150. package/src/lib/stores/review.mts +113 -0
  151. package/src/lib/stores/session.mts +170 -0
  152. package/src/lib/stores/ticket.mts +246 -0
  153. package/src/lib/stores/watch.mts +360 -0
  154. package/src/lib/stores/worker.mts +121 -0
  155. package/src/lib/watch/adopt.mts +151 -0
  156. package/src/lib/watch/arm.mts +48 -0
  157. package/src/lib/watch/cadence.mts +45 -0
  158. package/src/lib/watch/diff.mts +274 -0
  159. package/src/lib/watch/index.mts +11 -0
  160. package/src/lib/watch/marker.mts +24 -0
  161. package/src/lib/watch/payload.mts +56 -0
  162. package/src/lib/watch/poll.mts +87 -0
  163. package/src/lib/watch/render.mts +61 -0
  164. package/src/lib/watch/snapshot.mts +312 -0
  165. package/src/main.mts +18 -0
@@ -0,0 +1,155 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {hostname} from 'node:os';
3
+
4
+ import {AbstractCommand, discover} from '../lib/command/index.mts';
5
+ import type {ParsedOptions, CommandContext} from '../lib/command/index.mts';
6
+ import {nowIso, withDatabase} from '../lib/db/index.mts';
7
+ import {DEFAULT_STALE_AFTER_SECONDS} from '../lib/graph/index.mts';
8
+ import {processStartIso, retireNonLive} from '../lib/liveness/index.mts';
9
+ import {runMcpServer} from '../lib/mcp/index.mts';
10
+ import {
11
+ assertCapLimit,
12
+ DEFAULT_MAX_IN_FLIGHT_BUILDS,
13
+ DEFAULT_MAX_OPEN_PRS,
14
+ parseRepoLimits,
15
+ } from '../lib/model/index.mts';
16
+ import {createTickState, runServerTick} from '../lib/schedule/index.mts';
17
+ import {PolicyStore, SessionStore} from '../lib/stores/index.mts';
18
+
19
+ const options = {
20
+ 'max-parallel': {
21
+ type: 'number',
22
+ description:
23
+ 'Cap on agents running at once, across every session sharing this database.',
24
+ positional: false,
25
+ required: false,
26
+ },
27
+ 'max-open-prs': {
28
+ type: 'number',
29
+ description:
30
+ 'Cap on PRs open at once in one repo, for every repo without an override.',
31
+ positional: false,
32
+ required: false,
33
+ default: DEFAULT_MAX_OPEN_PRS,
34
+ },
35
+ 'max-open-prs-by-repo': {
36
+ type: 'string',
37
+ description:
38
+ 'Per-repo overrides of --max-open-prs, as owner/repo=<number>, comma separated.',
39
+ positional: false,
40
+ required: false,
41
+ },
42
+ 'max-in-flight-builds': {
43
+ type: 'number',
44
+ description:
45
+ "Cap on one repo's PRs with CI running at once, for every repo without an override.",
46
+ positional: false,
47
+ required: false,
48
+ default: DEFAULT_MAX_IN_FLIGHT_BUILDS,
49
+ },
50
+ 'max-in-flight-builds-by-repo': {
51
+ type: 'string',
52
+ description:
53
+ 'Per-repo overrides of --max-in-flight-builds, as owner/repo=<number>, comma separated.',
54
+ positional: false,
55
+ required: false,
56
+ },
57
+ 'tick-seconds': {
58
+ type: 'number',
59
+ description: 'Scheduler tick interval.',
60
+ positional: false,
61
+ required: false,
62
+ default: 15,
63
+ },
64
+ } as const;
65
+
66
+ export class Command extends AbstractCommand {
67
+ readonly name = 'mcp';
68
+ readonly summary = 'Start the MCP server on stdio.';
69
+ readonly env = [];
70
+ readonly options = options;
71
+ override readonly transports = {mcp: false};
72
+
73
+ async run(
74
+ parsed: ParsedOptions<typeof options>,
75
+ ctx: CommandContext
76
+ ): Promise<void> {
77
+ // Registration precedes everything else, command discovery included — it
78
+ // needs only the DB, and a skill invoked on the session's first turn
79
+ // would otherwise correlate to nothing. `started_at` records this
80
+ // process's own start: the identity a later liveness probe verifies the
81
+ // registered pid against.
82
+ const registryId = randomUUID();
83
+ const registeredAt = nowIso();
84
+ const claudeSessionId = ctx.env.CLAUDE_CODE_SESSION_ID ?? null;
85
+ // Parsed before anything is written: a malformed override list is a usage
86
+ // error, not a server that starts and then enforces half a policy.
87
+ const repoCaps = {
88
+ openPrs: assertCapLimit(parsed['max-open-prs'], 'max-open-prs'),
89
+ inFlightBuilds: assertCapLimit(
90
+ parsed['max-in-flight-builds'],
91
+ 'max-in-flight-builds'
92
+ ),
93
+ openPrsByRepo: parseRepoLimits(
94
+ parsed['max-open-prs-by-repo'] ?? '',
95
+ 'max-open-prs-by-repo'
96
+ ),
97
+ inFlightBuildsByRepo: parseRepoLimits(
98
+ parsed['max-in-flight-builds-by-repo'] ?? '',
99
+ 'max-in-flight-builds-by-repo'
100
+ ),
101
+ };
102
+ await withDatabase(undefined, ctx.env, async (db) => {
103
+ // The caps bound the host's shared resources, so the policy lives in the
104
+ // database where every reader — this server's scheduler, and `dispatch
105
+ // status` in another process — sees the same one.
106
+ await new PolicyStore(db).setRepoCaps(repoCaps);
107
+ await new SessionStore(db).register({
108
+ id: registryId,
109
+ host: hostname(),
110
+ pid: process.pid,
111
+ claudeSessionId,
112
+ startedAt: processStartIso(),
113
+ heartbeatAt: registeredAt,
114
+ });
115
+ // Rows for this session whose server died without cleanup (a plugin
116
+ // reload) would otherwise hold their claims and slots until their
117
+ // heartbeat ages out; retire them now. Live rows stay — two live
118
+ // servers under one session id fail closed as ambiguous rather than
119
+ // being resolved by whichever registered last.
120
+ if (claudeSessionId !== null) {
121
+ await retireNonLive(db, {
122
+ claudeSessionId,
123
+ keep: registryId,
124
+ now: registeredAt,
125
+ staleAfterSeconds: DEFAULT_STALE_AFTER_SECONDS,
126
+ });
127
+ }
128
+ });
129
+
130
+ // Everything past registration runs under the finally: a startup
131
+ // failure — discovery included — must retire the row it just created,
132
+ // not strand it until the heartbeat sweep.
133
+ try {
134
+ const tree = await discover(new URL('./', import.meta.url));
135
+ const state = createTickState(registryId, parsed['max-parallel']);
136
+ await runMcpServer({
137
+ tree,
138
+ stdin: process.stdin,
139
+ stdout: process.stdout,
140
+ stderr: process.stderr,
141
+ env: ctx.env,
142
+ tick: {
143
+ intervalMs: parsed['tick-seconds'] * 1_000,
144
+ run: (channel) => runServerTick(channel, ctx.env, state),
145
+ },
146
+ });
147
+ } finally {
148
+ // The session dies with its server; claims cascade so another
149
+ // server can pick the work up through stale-free reclamation.
150
+ await withDatabase(undefined, ctx.env, async (db) =>
151
+ new SessionStore(db).close(registryId)
152
+ ).catch(() => undefined);
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,35 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
4
+ import {RefreshService} from '../../lib/refresh/index.mts';
5
+ import {MilestoneStore} from '../../lib/stores/index.mts';
6
+
7
+ const options = {
8
+ id: {
9
+ type: 'string',
10
+ description: 'Tracker identifier for the milestone.',
11
+ positional: false,
12
+ required: true,
13
+ },
14
+ db: DB_OPTION,
15
+ } as const;
16
+
17
+ export class Command extends AbstractCommand {
18
+ readonly name = 'rm';
19
+ readonly summary = 'Delete one milestone.';
20
+ readonly env = [];
21
+ readonly options = options;
22
+
23
+ async run(
24
+ parsed: ParsedOptions<typeof options>,
25
+ ctx: CommandContext
26
+ ): Promise<void> {
27
+ await withDatabase(parsed.db, ctx.env, async (db) => {
28
+ const existed = await new MilestoneStore(db).removeMilestone(parsed.id);
29
+ await new RefreshService(db).reconcile();
30
+ ctx.io.write(
31
+ `removed milestone ${parsed.id} existed=${String(existed)}\n`
32
+ );
33
+ });
34
+ }
35
+ }
@@ -0,0 +1,49 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
4
+ import {RefreshService} from '../../lib/refresh/index.mts';
5
+ import {MilestoneStore} from '../../lib/stores/index.mts';
6
+
7
+ const options = {
8
+ id: {
9
+ type: 'string',
10
+ description: 'Tracker identifier for the milestone.',
11
+ positional: false,
12
+ required: true,
13
+ },
14
+ project: {
15
+ type: 'string',
16
+ description: 'Project the milestone belongs to.',
17
+ positional: false,
18
+ required: true,
19
+ },
20
+ name: {
21
+ type: 'string',
22
+ description: 'Human-readable milestone name.',
23
+ positional: false,
24
+ required: true,
25
+ },
26
+ db: DB_OPTION,
27
+ } as const;
28
+
29
+ export class Command extends AbstractCommand {
30
+ readonly name = 'set';
31
+ readonly summary = 'Create or update one milestone.';
32
+ readonly env = [];
33
+ readonly options = options;
34
+
35
+ async run(
36
+ parsed: ParsedOptions<typeof options>,
37
+ ctx: CommandContext
38
+ ): Promise<void> {
39
+ await withDatabase(parsed.db, ctx.env, async (db) => {
40
+ await new MilestoneStore(db).upsertMilestone({
41
+ id: parsed.id,
42
+ project: parsed.project,
43
+ name: parsed.name,
44
+ });
45
+ await new RefreshService(db).reconcile();
46
+ ctx.io.write(`milestone ${parsed.id}\n`);
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,36 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
4
+ import {nowIso} from '../../lib/db/time.mts';
5
+ import {DEFAULT_STALE_AFTER_SECONDS} from '../../lib/graph/index.mts';
6
+ import {CoordinationStore} from '../../lib/stores/index.mts';
7
+
8
+ const options = {
9
+ id: {
10
+ type: 'string',
11
+ description: 'The node whose recorded outcome should be dropped.',
12
+ positional: false,
13
+ required: true,
14
+ },
15
+ db: DB_OPTION,
16
+ } as const;
17
+
18
+ export class Command extends AbstractCommand {
19
+ readonly name = 'rm';
20
+ readonly summary = 'Drop a recorded outcome, requeueing surfaced work.';
21
+ readonly env = [];
22
+ readonly options = options;
23
+
24
+ async run(
25
+ parsed: ParsedOptions<typeof options>,
26
+ ctx: CommandContext
27
+ ): Promise<void> {
28
+ await withDatabase(parsed.db, ctx.env, async (db) => {
29
+ const existed = await new CoordinationStore(db).removeOutcome(parsed.id, {
30
+ now: nowIso(),
31
+ staleAfterSeconds: DEFAULT_STALE_AFTER_SECONDS,
32
+ });
33
+ ctx.io.write(`removed outcome ${parsed.id} existed=${String(existed)}\n`);
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,86 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
4
+ import {OUTCOMES} from '../../lib/model/status.mts';
5
+ import {correlateSession} from '../../lib/schedule/index.mts';
6
+ import {CoordinationStore} from '../../lib/stores/index.mts';
7
+
8
+ const options = {
9
+ id: {
10
+ type: 'string',
11
+ description: 'The node (ticket or PR item) this outcome reports on.',
12
+ positional: false,
13
+ required: true,
14
+ },
15
+ outcome: {
16
+ type: 'string',
17
+ description: 'The final report.',
18
+ positional: false,
19
+ required: true,
20
+ choices: OUTCOMES,
21
+ },
22
+ retryable: {
23
+ type: 'boolean',
24
+ description: 'A failed run worth re-dispatching (failed only).',
25
+ positional: false,
26
+ required: false,
27
+ },
28
+ detail: {
29
+ type: 'string',
30
+ description: 'One line of context for whoever reads the report.',
31
+ positional: false,
32
+ required: false,
33
+ },
34
+ force: {
35
+ type: 'boolean',
36
+ description:
37
+ 'Record even though no claim is held. For an operator resolving a node by hand; a dispatched worker always holds its claim and never needs this.',
38
+ positional: false,
39
+ required: false,
40
+ },
41
+ db: DB_OPTION,
42
+ } as const;
43
+
44
+ export class Command extends AbstractCommand {
45
+ readonly name = 'set';
46
+ readonly summary = 'Record a final report on a node, releasing its claim.';
47
+ readonly env = [];
48
+ readonly options = options;
49
+
50
+ async run(
51
+ parsed: ParsedOptions<typeof options>,
52
+ ctx: CommandContext
53
+ ): Promise<void> {
54
+ await withDatabase(parsed.db, ctx.env, async (db) => {
55
+ // Identity comes from the environment, never from a flag. An id a
56
+ // caller supplies is an assertion, not evidence, and the holders are
57
+ // printed by `dispatch claim status` — so an unclaimed worker could
58
+ // name one and pass.
59
+ //
60
+ // Holding the claim is what authorizes a report: an outcome is a report
61
+ // from work that was dispatched. The check runs inside the write's own
62
+ // transaction, because a claim read out here can be swept, released, or
63
+ // taken by another session before the write lands.
64
+ //
65
+ // `correlateSession` returning null cannot tell an operator at a
66
+ // terminal from a worker whose server just died, so it is not treated
67
+ // as authority — that would leave the guard bypassable exactly when the
68
+ // system is unhealthy, which is when it matters. An operator resolving
69
+ // a node by hand passes --force and says so.
70
+ const session = await correlateSession(db, ctx.env, undefined);
71
+ await new CoordinationStore(db).recordOutcome(
72
+ {
73
+ node: parsed.id,
74
+ outcome: parsed.outcome,
75
+ // The parser defaults an absent boolean to false; only a failure
76
+ // carries a meaningful retryable, and the store enforces that.
77
+ retryable: parsed.outcome === 'failed' ? parsed.retryable : null,
78
+ detail: parsed.detail ?? null,
79
+ recordedAt: nowIso(),
80
+ },
81
+ {session: session ?? '', requireClaim: !parsed.force}
82
+ );
83
+ ctx.io.write(`outcome ${parsed.id} ${parsed.outcome}\n`);
84
+ });
85
+ }
86
+ }
@@ -0,0 +1,33 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
4
+ import {RefreshService} from '../../lib/refresh/index.mts';
5
+ import {PrStore} from '../../lib/stores/index.mts';
6
+
7
+ const options = {
8
+ id: {
9
+ type: 'string',
10
+ description: 'Identifier for the PR item.',
11
+ positional: false,
12
+ required: true,
13
+ },
14
+ db: DB_OPTION,
15
+ } as const;
16
+
17
+ export class Command extends AbstractCommand {
18
+ readonly name = 'rm';
19
+ readonly summary = 'Delete one PR work item.';
20
+ readonly env = [];
21
+ readonly options = options;
22
+
23
+ async run(
24
+ parsed: ParsedOptions<typeof options>,
25
+ ctx: CommandContext
26
+ ): Promise<void> {
27
+ await withDatabase(parsed.db, ctx.env, async (db) => {
28
+ const existed = await new PrStore(db).removePr(parsed.id);
29
+ await new RefreshService(db).reconcile();
30
+ ctx.io.write(`removed pr ${parsed.id} existed=${String(existed)}\n`);
31
+ });
32
+ }
33
+ }
@@ -0,0 +1,110 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
4
+ import {PR_ORIGINS} from '../../lib/model/status.mts';
5
+ import {RefreshService} from '../../lib/refresh/index.mts';
6
+ import {PrStore} from '../../lib/stores/index.mts';
7
+
8
+ const options = {
9
+ id: {
10
+ type: 'string',
11
+ description: 'Identifier for the PR item, e.g. owner/repo#7.',
12
+ positional: false,
13
+ required: true,
14
+ },
15
+ ticket: {
16
+ type: 'string',
17
+ description: 'The originating ticket; omit for a bare PR or prompt item.',
18
+ positional: false,
19
+ required: false,
20
+ },
21
+ origin: {
22
+ type: 'string',
23
+ description: 'How the item entered the graph.',
24
+ positional: false,
25
+ required: false,
26
+ choices: PR_ORIGINS,
27
+ },
28
+ repo: {
29
+ type: 'string',
30
+ description: 'Repository as owner/repo.',
31
+ positional: false,
32
+ required: false,
33
+ },
34
+ 'pr-number': {
35
+ type: 'number',
36
+ description: 'Pull request number, once one exists.',
37
+ positional: false,
38
+ required: false,
39
+ },
40
+ url: {
41
+ type: 'string',
42
+ description: 'Pull request URL.',
43
+ positional: false,
44
+ required: false,
45
+ },
46
+ branch: {
47
+ type: 'string',
48
+ description: 'Head branch.',
49
+ positional: false,
50
+ required: false,
51
+ },
52
+ title: {
53
+ type: 'string',
54
+ description: 'One-line description of the work.',
55
+ positional: false,
56
+ required: false,
57
+ },
58
+ injected: {
59
+ type: 'boolean',
60
+ description: 'Rank this item to the top of the frontier.',
61
+ positional: false,
62
+ required: false,
63
+ },
64
+ priority: {
65
+ type: 'number',
66
+ description: 'Lower is more urgent.',
67
+ positional: false,
68
+ required: false,
69
+ },
70
+ db: DB_OPTION,
71
+ } as const;
72
+
73
+ export class Command extends AbstractCommand {
74
+ readonly name = 'set';
75
+ readonly summary = 'Create or update one PR work item.';
76
+ readonly env = [];
77
+ readonly options = options;
78
+
79
+ async run(
80
+ parsed: ParsedOptions<typeof options>,
81
+ ctx: CommandContext
82
+ ): Promise<void> {
83
+ await withDatabase(parsed.db, ctx.env, async (db) => {
84
+ // Only what the caller named. A worker keeping the URL and PR number
85
+ // current names nothing else, and must not thereby unlink the item from
86
+ // its ticket or blank its title. An explicit empty string still clears a
87
+ // field — omission is what preserves. `injected` is the exception the
88
+ // parser forces: an absent boolean is indistinguishable from `--no`, so
89
+ // it is carried only when set.
90
+ await new PrStore(db).patchPr({
91
+ id: parsed.id,
92
+ ...(parsed.ticket === undefined
93
+ ? {}
94
+ : {ticket: parsed.ticket === '' ? null : parsed.ticket}),
95
+ ...(parsed.origin === undefined ? {} : {origin: parsed.origin}),
96
+ ...(parsed.repo === undefined ? {} : {repo: parsed.repo}),
97
+ ...(parsed['pr-number'] === undefined
98
+ ? {}
99
+ : {prNumber: parsed['pr-number']}),
100
+ ...(parsed.url === undefined ? {} : {url: parsed.url}),
101
+ ...(parsed.branch === undefined ? {} : {branch: parsed.branch}),
102
+ ...(parsed.title === undefined ? {} : {title: parsed.title}),
103
+ ...(parsed.injected ? {injected: true} : {}),
104
+ ...(parsed.priority === undefined ? {} : {priority: parsed.priority}),
105
+ });
106
+ await new RefreshService(db).reconcile();
107
+ ctx.io.write(`pr ${parsed.id}\n`);
108
+ });
109
+ }
110
+ }
@@ -0,0 +1,114 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, nowIso, withDatabase} from '../../lib/db/index.mts';
4
+ import {DataError, ensure} from '../../lib/errors/index.mts';
5
+ import {correlateSession} from '../../lib/schedule/index.mts';
6
+ import {CoordinationStore, PrStore} from '../../lib/stores/index.mts';
7
+ import {armWatch, githubSnapshot} from '../../lib/watch/index.mts';
8
+
9
+ const options = {
10
+ id: {
11
+ type: 'string',
12
+ description: 'The PR item this worker is handing back.',
13
+ positional: false,
14
+ required: true,
15
+ },
16
+ session: {
17
+ type: 'string',
18
+ description:
19
+ 'Registry id whose claim the handoff releases; defaults to the session correlated from the environment.',
20
+ positional: false,
21
+ required: false,
22
+ },
23
+ db: DB_OPTION,
24
+ } as const;
25
+
26
+ /**
27
+ * Yield an item back to the server: the worker is no longer working on it.
28
+ *
29
+ * Deliberately says nothing about what the worker is waiting for. A worker
30
+ * may be waiting on several things at once, and whatever it declared would go
31
+ * stale the moment the PR moved; the server reads the PR's own state and
32
+ * derives both what changed and how often to look.
33
+ *
34
+ * What the server cannot infer is this call itself. A worker that finished
35
+ * its turn and one that was killed leave the same row — a claim that goes
36
+ * stale — so without an explicit yield the server cannot tell a graceful
37
+ * handoff from a crash, and would have to choose between never resuming a
38
+ * crashed run and re-dispatching a healthy one forever.
39
+ *
40
+ * Releasing the claim gives back the compute grant, so the wait costs nothing
41
+ * while it lasts.
42
+ */
43
+ export class Command extends AbstractCommand {
44
+ readonly name = 'yield';
45
+ readonly summary = 'Hand a PR item back to the server, releasing the claim.';
46
+ readonly env = [];
47
+ readonly options = options;
48
+
49
+ async run(
50
+ parsed: ParsedOptions<typeof options>,
51
+ ctx: CommandContext
52
+ ): Promise<void> {
53
+ await withDatabase(parsed.db, ctx.env, async (db) => {
54
+ const pr = await new PrStore(db).getPr(parsed.id);
55
+ ensure(
56
+ pr !== null,
57
+ () =>
58
+ new DataError(`no PR item "${parsed.id}" to watch`, {
59
+ hint: 'register the item with `dispatch pr set` first.',
60
+ })
61
+ );
62
+ ensure(
63
+ pr.repo !== null && pr.prNumber !== null,
64
+ () =>
65
+ new DataError(`PR item "${parsed.id}" has no PR to poll`, {
66
+ hint: 'record it with `dispatch pr set --repo <owner>/<name> --pr-number <n>` once the PR exists, then watch.',
67
+ })
68
+ );
69
+
70
+ const coordination = new CoordinationStore(db);
71
+ ensure(
72
+ (await coordination.getOutcome(parsed.id)) === null,
73
+ () =>
74
+ new DataError(`PR item "${parsed.id}" already has an outcome`, {
75
+ hint: 'a concluded item is not waiting; remove the outcome first if it must re-run.',
76
+ })
77
+ );
78
+
79
+ const caller = await correlateSession(db, ctx.env, parsed.session);
80
+
81
+ // Release before arming, and refuse the handoff if the claim does not
82
+ // actually go. A watching item is never queued, so a watch armed over
83
+ // a claim still held would consume capacity that nothing can ever
84
+ // reclaim — the item reads as waiting and as in flight at once.
85
+ const released =
86
+ caller === null
87
+ ? 'absent'
88
+ : await coordination.release(parsed.id, caller);
89
+ ensure(
90
+ released === 'released' || released === 'absent',
91
+ () =>
92
+ new DataError(
93
+ `the claim on "${parsed.id}" belongs to another session`,
94
+ {
95
+ hint: 'this worker was superseded; return without arming a watch, and let the session that holds the claim finish.',
96
+ }
97
+ )
98
+ );
99
+
100
+ await armWatch(db, {
101
+ node: parsed.id,
102
+ repo: pr.repo,
103
+ prNumber: pr.prNumber,
104
+ at: nowIso(),
105
+ snapshot: githubSnapshot,
106
+ session: caller,
107
+ releaseClaimFor: caller,
108
+ log: ctx.log,
109
+ });
110
+
111
+ ctx.io.write(`yield ${parsed.id}\n`);
112
+ });
113
+ }
114
+ }
@@ -0,0 +1,33 @@
1
+ import {AbstractCommand} from '../../lib/command/index.mts';
2
+ import type {CommandContext, ParsedOptions} from '../../lib/command/index.mts';
3
+ import {DB_OPTION, withDatabase} from '../../lib/db/index.mts';
4
+ import {RefreshService} from '../../lib/refresh/index.mts';
5
+ import {ProjectStore} from '../../lib/stores/index.mts';
6
+
7
+ const options = {
8
+ id: {
9
+ type: 'string',
10
+ description: 'Tracker identifier for the project.',
11
+ positional: false,
12
+ required: true,
13
+ },
14
+ db: DB_OPTION,
15
+ } as const;
16
+
17
+ export class Command extends AbstractCommand {
18
+ readonly name = 'rm';
19
+ readonly summary = 'Delete one project.';
20
+ readonly env = [];
21
+ readonly options = options;
22
+
23
+ async run(
24
+ parsed: ParsedOptions<typeof options>,
25
+ ctx: CommandContext
26
+ ): Promise<void> {
27
+ await withDatabase(parsed.db, ctx.env, async (db) => {
28
+ const existed = await new ProjectStore(db).removeProject(parsed.id);
29
+ await new RefreshService(db).reconcile();
30
+ ctx.io.write(`removed project ${parsed.id} existed=${String(existed)}\n`);
31
+ });
32
+ }
33
+ }