@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.
- package/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- 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
|
+
}
|