mirab 0.0.0-stage → 0.2.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/LICENSE +201 -0
- package/README.md +226 -2
- package/package.json +58 -4
- package/src/agent/approved.ts +261 -0
- package/src/agent/define.ts +235 -0
- package/src/agent/guest.ts +138 -0
- package/src/agent/init.ts +150 -0
- package/src/agent/manifest.ts +141 -0
- package/src/agent/modules.ts +23 -0
- package/src/agent/runtime.ts +239 -0
- package/src/agent-profiles.ts +327 -0
- package/src/api/bootstrap-contract.ts +95 -0
- package/src/api/channel-contract.ts +19 -0
- package/src/api/client.ts +100 -0
- package/src/api/contract.ts +2454 -0
- package/src/api/events-contract.ts +103 -0
- package/src/api/import-contract.ts +22 -0
- package/src/api/orchestrator-contract.ts +73 -0
- package/src/api/runner-contract.ts +25 -0
- package/src/api/scopes.ts +41 -0
- package/src/api/sessions-contract.ts +71 -0
- package/src/api/settings-contract.ts +50 -0
- package/src/channel/bridge.ts +97 -0
- package/src/channel/local.ts +154 -0
- package/src/channel/state.ts +11 -0
- package/src/channel/wire.ts +104 -0
- package/src/dev-sync.ts +206 -0
- package/src/dev.ts +453 -0
- package/src/events-tail.ts +75 -0
- package/src/images.ts +498 -0
- package/src/index.ts +21 -0
- package/src/invocation.ts +34 -0
- package/src/isolation/anchored-fs.ts +284 -0
- package/src/isolation/colima.ts +159 -0
- package/src/isolation/container.ts +189 -0
- package/src/isolation/firecracker.ts +414 -0
- package/src/isolation/guest-protocol.json +1 -0
- package/src/isolation/host-user.ts +247 -0
- package/src/isolation/image.ts +336 -0
- package/src/isolation/index.ts +477 -0
- package/src/isolation/multipass.ts +541 -0
- package/src/isolation/network.ts +167 -0
- package/src/isolation/policy.ts +86 -0
- package/src/isolation/session.ts +385 -0
- package/src/isolation/sync.ts +329 -0
- package/src/isolation/vz-network-setup.ts +247 -0
- package/src/isolation/vz.ts +312 -0
- package/src/mirab-main.ts +536 -0
- package/src/mirab.ts +13 -0
- package/src/runner/define.ts +187 -0
- package/src/runner/image.ts +239 -0
- package/src/runner/index.ts +37 -0
- package/src/runner/serve.ts +491 -0
- package/src/runner/toolsets.ts +205 -0
- package/src/spawn-adapters/claude.ts +112 -0
- package/src/spawn-adapters/codex-logs.ts +130 -0
- package/src/spawn-adapters/codex-notify.ts +73 -0
- package/src/spawn-adapters/codex.ts +89 -0
- package/src/spawn-adapters/index.ts +40 -0
- package/src/spawn-adapters/types.ts +55 -0
- package/src/spawn-attach.ts +243 -0
- package/src/spawn-binary.ts +90 -0
- package/src/spawn-bridge.ts +504 -0
- package/src/spawn-bundle.ts +16 -0
- package/src/spawn-detach.ts +301 -0
- package/src/spawn-events.ts +35 -0
- package/src/spawn-hook.ts +86 -0
- package/src/spawn-input.ts +107 -0
- package/src/spawn-native.ts +49 -0
- package/src/spawn-nudge-worker.ts +61 -0
- package/src/spawn-nudge.ts +446 -0
- package/src/spawn-output.ts +45 -0
- package/src/spawn-queue.ts +135 -0
- package/src/spawn-quiet.ts +69 -0
- package/src/spawn-report.ts +114 -0
- package/src/spawn-sessions.ts +166 -0
- package/src/spawn-vm.ts +356 -0
- package/src/spawn-worker.ts +766 -0
- package/src/spawn.ts +468 -0
- package/src/station/config.ts +89 -0
- package/src/station/gateway.ts +94 -0
- package/src/station/vault.ts +96 -0
- package/src/test-cli.ts +90 -0
- package/src/tools.ts +3 -0
- package/types/agent/define.d.ts +76 -0
- package/types/agent/manifest.d.ts +53 -0
- package/types/agent/modules.d.ts +1 -0
- package/types/images.d.ts +124 -0
- package/types/index.d.ts +6 -0
- package/types/isolation/colima.d.ts +37 -0
- package/types/isolation/container.d.ts +25 -0
- package/types/isolation/firecracker.d.ts +55 -0
- package/types/isolation/host-user.d.ts +47 -0
- package/types/isolation/image.d.ts +158 -0
- package/types/isolation/index.d.ts +128 -0
- package/types/isolation/network.d.ts +33 -0
- package/types/isolation/policy.d.ts +29 -0
- package/types/isolation/session.d.ts +99 -0
- package/types/isolation/vz.d.ts +63 -0
- package/types/runner/define.d.ts +101 -0
- package/types/runner/image.d.ts +52 -0
- package/types/runner/index.d.ts +13 -0
- package/types/runner/serve.d.ts +69 -0
- package/types/runner/toolsets.d.ts +35 -0
- package/types/tools.d.ts +2 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { GildClient } from './api/client'
|
|
4
|
+
import { tailEvents, waitForEvents } from './events-tail'
|
|
5
|
+
import { parseTrigger, type AgentTrigger } from './agent-profiles'
|
|
6
|
+
import type { ChannelStatus } from './spawn-sessions'
|
|
7
|
+
|
|
8
|
+
/** The agent identity and server a session listens as. It reaches the worker
|
|
9
|
+
* over IPC, never argv or env, the same rule spawn-report follows. */
|
|
10
|
+
export type BridgeTarget = { server: string; token: string; agent: string }
|
|
11
|
+
|
|
12
|
+
export type MentionStage = 'received' | 'queued' | 'delivered' | 'failed'
|
|
13
|
+
/** Local events-stream records. They never change the agent's busy/idle state. */
|
|
14
|
+
export type BridgeEvent =
|
|
15
|
+
| {
|
|
16
|
+
type: 'mention'
|
|
17
|
+
stage: MentionStage
|
|
18
|
+
session: string
|
|
19
|
+
repo: string
|
|
20
|
+
message: string
|
|
21
|
+
id: string
|
|
22
|
+
author: string
|
|
23
|
+
ts: string
|
|
24
|
+
error?: string
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
type: 'trigger'
|
|
28
|
+
stage: MentionStage
|
|
29
|
+
session: string
|
|
30
|
+
repo: string
|
|
31
|
+
/** The spec that fired, e.g. issues.labeled:triage. */
|
|
32
|
+
trigger: string
|
|
33
|
+
event: string
|
|
34
|
+
action: string
|
|
35
|
+
/** Stream event id; namespaced before persisting. */
|
|
36
|
+
id: string
|
|
37
|
+
number?: number
|
|
38
|
+
title?: string
|
|
39
|
+
label?: string
|
|
40
|
+
actor: string
|
|
41
|
+
ts: string
|
|
42
|
+
error?: string
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
type: 'channel'
|
|
46
|
+
session: string
|
|
47
|
+
repo: string
|
|
48
|
+
state: ChannelStatus['state']
|
|
49
|
+
error?: string
|
|
50
|
+
ts: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const mention = z.object({
|
|
54
|
+
repository: z.object({ full_name: z.string() }),
|
|
55
|
+
message: z.object({
|
|
56
|
+
cursor: z.string(),
|
|
57
|
+
id: z.string(),
|
|
58
|
+
body: z.string(),
|
|
59
|
+
author: z.object({ name: z.string() }),
|
|
60
|
+
}),
|
|
61
|
+
agent: z.string(),
|
|
62
|
+
})
|
|
63
|
+
type Mention = z.output<typeof mention>
|
|
64
|
+
|
|
65
|
+
// GitHub-shaped webhook payloads (gild-site lib/events/payloads.ts). Only the
|
|
66
|
+
// fields a trigger needs are parsed; everything else passes through unseen.
|
|
67
|
+
const subject = z.object({
|
|
68
|
+
number: z.number(),
|
|
69
|
+
title: z.string(),
|
|
70
|
+
labels: z.array(z.object({ name: z.string() })),
|
|
71
|
+
user: z.object({ login: z.string() }).optional(),
|
|
72
|
+
})
|
|
73
|
+
const triggerPayload = z.object({
|
|
74
|
+
action: z.string().optional(),
|
|
75
|
+
sender: z.object({ login: z.string() }).optional(),
|
|
76
|
+
label: z.object({ name: z.string() }).optional(),
|
|
77
|
+
issue: subject.optional(),
|
|
78
|
+
pull_request: subject.optional(),
|
|
79
|
+
})
|
|
80
|
+
type TriggerPayload = z.output<typeof triggerPayload>
|
|
81
|
+
|
|
82
|
+
const SEEN_LIMIT = 500
|
|
83
|
+
|
|
84
|
+
/** One short prompt; the agent reads the channel history itself. */
|
|
85
|
+
export function mentionPrompt(
|
|
86
|
+
repo: string,
|
|
87
|
+
label: string,
|
|
88
|
+
m: Mention,
|
|
89
|
+
gild = 'gild',
|
|
90
|
+
) {
|
|
91
|
+
const { cursor, body, author } = m.message
|
|
92
|
+
return [
|
|
93
|
+
`[gild] @${author.name} mentioned you in ${repo} (message ${cursor}):`,
|
|
94
|
+
body,
|
|
95
|
+
`Context: ${gild} chat history ${repo} --agent ${label} --before ${Number(cursor) + 1} --limit 30`,
|
|
96
|
+
`Reply: ${gild} chat send ${repo} --agent ${label} --reply-to ${cursor} "<your reply>"`,
|
|
97
|
+
MENTION_ETIQUETTE,
|
|
98
|
+
workflowPrompt(repo, label, gild),
|
|
99
|
+
].join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every @tag wakes that agent, so a courtesy tag ("@bob says 42") makes it
|
|
103
|
+
* answer again and two agents can ping-pong. The rehearsal on 10 Oct did. */
|
|
104
|
+
export const MENTION_ETIQUETTE =
|
|
105
|
+
'Note: an @tag wakes that agent. Tag only whoever must act next; name others without @. If this needs nothing from you, do not reply.'
|
|
106
|
+
|
|
107
|
+
/** One short prompt per fired trigger; names the read and hand-off commands. */
|
|
108
|
+
export function triggerPrompt(
|
|
109
|
+
repo: string,
|
|
110
|
+
trigger: AgentTrigger,
|
|
111
|
+
issue: { number: number; title: string },
|
|
112
|
+
actor: string,
|
|
113
|
+
label: string | undefined,
|
|
114
|
+
gild: string,
|
|
115
|
+
agent: string,
|
|
116
|
+
) {
|
|
117
|
+
const kind =
|
|
118
|
+
trigger.event === 'pull_request'
|
|
119
|
+
? 'pull request'
|
|
120
|
+
: trigger.event === 'issue_comment'
|
|
121
|
+
? 'issue comment'
|
|
122
|
+
: 'issue'
|
|
123
|
+
const what = label ? `${trigger.action} ${label}` : trigger.action
|
|
124
|
+
return [
|
|
125
|
+
`[gild] ${kind} #${issue.number} "${issue.title}" ${what} in ${repo} by ${actor}`,
|
|
126
|
+
`Read: ${gild} ${trigger.event === 'pull_request' ? 'pr' : 'issue'} view ${repo}#${issue.number} --agent ${agent}`,
|
|
127
|
+
`Post: ${gild} chat send ${repo} --agent ${agent} "@<agent> <message>"`,
|
|
128
|
+
workflowPrompt(repo, agent, gild),
|
|
129
|
+
].join('\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function workflowPrompt(repo: string, agent: string, gild: string) {
|
|
133
|
+
return `Work: ${gild} clone ${repo} --agent ${agent}; git switch -c <branch>, commit, push; ${gild} issue create|comment|close|label and ${gild} pr create|list|view|diff|checks|comment|review|merge. Use --agent ${agent} on every forge command; reads support --json.`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type Saved = { cursors: Record<string, string>; delivered: string[] }
|
|
137
|
+
const savedSchema = z.object({
|
|
138
|
+
cursors: z.record(z.string(), z.string()).default({}),
|
|
139
|
+
delivered: z.array(z.string()).default([]),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
type StreamRecord = {
|
|
143
|
+
id: string
|
|
144
|
+
cursor: string
|
|
145
|
+
event: string
|
|
146
|
+
payload: unknown
|
|
147
|
+
}
|
|
148
|
+
type Trigger = { spec: string } & AgentTrigger
|
|
149
|
+
|
|
150
|
+
/** Turns `channel.mention` events and profile triggers for one agent into
|
|
151
|
+
* queued prompts, once each, over the same events subscription. */
|
|
152
|
+
export class MentionBridge {
|
|
153
|
+
readonly channels: ChannelStatus[]
|
|
154
|
+
private readonly watched: ChannelStatus[]
|
|
155
|
+
private saved: Saved = { cursors: {}, delivered: [] }
|
|
156
|
+
private readonly seen = new Set<string>()
|
|
157
|
+
private readonly inflight = new Map<string, number>()
|
|
158
|
+
private readonly head = new Map<string, string>()
|
|
159
|
+
private readonly triggers: Trigger[]
|
|
160
|
+
private writing: Promise<void> = Promise.resolve()
|
|
161
|
+
constructor(
|
|
162
|
+
private readonly opts: {
|
|
163
|
+
client: Pick<GildClient, 'request'>
|
|
164
|
+
/** sponsor/label, as the server addresses the mention. */
|
|
165
|
+
agent: string
|
|
166
|
+
/** The local profile label used in `--agent <label>`. */
|
|
167
|
+
label: string
|
|
168
|
+
/** The gild that spawned this session; a `gild` on PATH may be older. */
|
|
169
|
+
gild?: string
|
|
170
|
+
session: string
|
|
171
|
+
repos: string[]
|
|
172
|
+
/** More repos to read events from without delivering their mentions. */
|
|
173
|
+
watch?: string[]
|
|
174
|
+
/** Sees every page of every repo before mentions are handled. */
|
|
175
|
+
observe?: (
|
|
176
|
+
repo: string,
|
|
177
|
+
events: { event: string; payload: unknown }[],
|
|
178
|
+
) => void
|
|
179
|
+
/** Trigger specs from the profile (`--on`), matched on the same repos. */
|
|
180
|
+
triggers?: string[]
|
|
181
|
+
file: string
|
|
182
|
+
enqueue: (text: string, typed: () => void) => void
|
|
183
|
+
emit: (event: BridgeEvent) => void
|
|
184
|
+
pause?: typeof waitForEvents
|
|
185
|
+
/** Waits between reconnects; tests pass an instant one. */
|
|
186
|
+
backoff?: (ms: number, signal: AbortSignal) => Promise<void>
|
|
187
|
+
},
|
|
188
|
+
) {
|
|
189
|
+
this.channels = opts.repos.map((repo) => ({ repo, state: 'connecting' }))
|
|
190
|
+
const own = new Set(opts.repos.map((r) => r.toLowerCase()))
|
|
191
|
+
this.watched = [...new Set(opts.watch ?? [])]
|
|
192
|
+
.filter((repo) => !own.has(repo.toLowerCase()))
|
|
193
|
+
.map((repo) => ({ repo, state: 'connecting' }))
|
|
194
|
+
this.triggers = (opts.triggers ?? [])
|
|
195
|
+
.map((spec) => ({ spec, ...parseTrigger(spec) }))
|
|
196
|
+
.filter((t): t is Trigger => t.event !== undefined)
|
|
197
|
+
}
|
|
198
|
+
async start(signal: AbortSignal) {
|
|
199
|
+
try {
|
|
200
|
+
this.saved = savedSchema.parse(
|
|
201
|
+
JSON.parse(await readFile(this.opts.file, 'utf8')),
|
|
202
|
+
)
|
|
203
|
+
} catch {
|
|
204
|
+
// No file yet, or an unreadable one: start from now rather than replay.
|
|
205
|
+
}
|
|
206
|
+
for (const id of this.saved.delivered) this.seen.add(id)
|
|
207
|
+
void this.backlog().catch(() => {})
|
|
208
|
+
await Promise.all(
|
|
209
|
+
[...this.channels, ...this.watched].map((channel) =>
|
|
210
|
+
this.run(channel, signal),
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
private set(
|
|
215
|
+
channel: ChannelStatus,
|
|
216
|
+
state: ChannelStatus['state'],
|
|
217
|
+
error?: string,
|
|
218
|
+
) {
|
|
219
|
+
if (channel.state === state && channel.error === error) return
|
|
220
|
+
channel.state = state
|
|
221
|
+
if (error) channel.error = error
|
|
222
|
+
else delete channel.error
|
|
223
|
+
this.opts.emit({
|
|
224
|
+
type: 'channel',
|
|
225
|
+
session: this.opts.session,
|
|
226
|
+
repo: channel.repo,
|
|
227
|
+
state,
|
|
228
|
+
...(error ? { error } : {}),
|
|
229
|
+
ts: new Date().toISOString(),
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
private async run(channel: ChannelStatus, signal: AbortSignal) {
|
|
233
|
+
// A listener never gives up while the agent runs: any failure (a network
|
|
234
|
+
// drop, a 5xx, or a read check answered while the repo's access settings
|
|
235
|
+
// were briefly pending) shows in status, then it resumes from the last
|
|
236
|
+
// page it saw. Before 10 Oct one transient 404 silenced an agent for good.
|
|
237
|
+
for (let attempt = 0; !signal.aborted; attempt++) {
|
|
238
|
+
try {
|
|
239
|
+
await tailEvents(
|
|
240
|
+
this.opts.client,
|
|
241
|
+
{
|
|
242
|
+
repo: channel.repo,
|
|
243
|
+
since:
|
|
244
|
+
this.head.get(channel.repo) ?? this.saved.cursors[channel.repo],
|
|
245
|
+
onPage: (page) => {
|
|
246
|
+
attempt = 0
|
|
247
|
+
this.page(channel, page)
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
signal,
|
|
251
|
+
() => {},
|
|
252
|
+
() => {},
|
|
253
|
+
this.opts.pause,
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
} catch (error) {
|
|
257
|
+
if (signal.aborted) return
|
|
258
|
+
this.set(
|
|
259
|
+
channel,
|
|
260
|
+
'error',
|
|
261
|
+
error instanceof Error ? error.message : String(error),
|
|
262
|
+
)
|
|
263
|
+
await (this.opts.backoff ?? sleep)(
|
|
264
|
+
Math.min(30_000, 1_000 * 2 ** Math.min(attempt, 5)),
|
|
265
|
+
signal,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
private stage(stage: MentionStage, repo: string, m: Mention, error?: string) {
|
|
271
|
+
this.opts.emit({
|
|
272
|
+
type: 'mention',
|
|
273
|
+
stage,
|
|
274
|
+
session: this.opts.session,
|
|
275
|
+
repo,
|
|
276
|
+
message: m.message.cursor,
|
|
277
|
+
id: m.message.id,
|
|
278
|
+
author: m.message.author.name,
|
|
279
|
+
ts: new Date().toISOString(),
|
|
280
|
+
...(error ? { error } : {}),
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
private stageTrigger(
|
|
284
|
+
stage: MentionStage,
|
|
285
|
+
repo: string,
|
|
286
|
+
t: {
|
|
287
|
+
spec: string
|
|
288
|
+
event: string
|
|
289
|
+
action: string
|
|
290
|
+
id: string
|
|
291
|
+
number?: number
|
|
292
|
+
title?: string
|
|
293
|
+
label?: string
|
|
294
|
+
actor: string
|
|
295
|
+
},
|
|
296
|
+
error?: string,
|
|
297
|
+
) {
|
|
298
|
+
this.opts.emit({
|
|
299
|
+
type: 'trigger',
|
|
300
|
+
stage,
|
|
301
|
+
session: this.opts.session,
|
|
302
|
+
repo,
|
|
303
|
+
trigger: t.spec,
|
|
304
|
+
event: t.event,
|
|
305
|
+
action: t.action,
|
|
306
|
+
id: t.id,
|
|
307
|
+
...(t.number !== undefined ? { number: t.number } : {}),
|
|
308
|
+
...(t.title !== undefined ? { title: t.title } : {}),
|
|
309
|
+
...(t.label !== undefined ? { label: t.label } : {}),
|
|
310
|
+
actor: t.actor,
|
|
311
|
+
ts: new Date().toISOString(),
|
|
312
|
+
...(error ? { error } : {}),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
private page(
|
|
316
|
+
channel: ChannelStatus,
|
|
317
|
+
page: { events: StreamRecord[]; cursor: string },
|
|
318
|
+
) {
|
|
319
|
+
this.set(channel, 'listening')
|
|
320
|
+
const repo = channel.repo
|
|
321
|
+
this.opts.observe?.(repo, page.events)
|
|
322
|
+
if (this.watched.includes(channel)) return this.save(repo, page.cursor)
|
|
323
|
+
for (const event of page.events) {
|
|
324
|
+
if (event.event === 'channel.mention') this.mention(repo, event)
|
|
325
|
+
else this.trigger(repo, event)
|
|
326
|
+
}
|
|
327
|
+
this.head.set(repo, page.cursor)
|
|
328
|
+
// A cursor may only pass an enqueued prompt once it has been typed, so a
|
|
329
|
+
// crash replays it; delivered ids keep typed ones from repeating.
|
|
330
|
+
if (!this.inflight.get(repo)) this.save(repo, page.cursor)
|
|
331
|
+
}
|
|
332
|
+
private mention(repo: string, record: StreamRecord) {
|
|
333
|
+
const parsed = mention.safeParse(record.payload)
|
|
334
|
+
if (!parsed.success) return
|
|
335
|
+
const m = parsed.data
|
|
336
|
+
// The server already addresses mentions; still never type someone else's.
|
|
337
|
+
if (
|
|
338
|
+
m.agent.toLowerCase() !== this.opts.agent.toLowerCase() ||
|
|
339
|
+
m.repository.full_name.toLowerCase() !== repo.toLowerCase() ||
|
|
340
|
+
this.seen.has(m.message.id)
|
|
341
|
+
)
|
|
342
|
+
return
|
|
343
|
+
this.seen.add(m.message.id)
|
|
344
|
+
this.stage('received', repo, m)
|
|
345
|
+
this.inflight.set(repo, (this.inflight.get(repo) ?? 0) + 1)
|
|
346
|
+
try {
|
|
347
|
+
this.opts.enqueue(
|
|
348
|
+
mentionPrompt(repo, this.opts.label, m, this.opts.gild),
|
|
349
|
+
() => {
|
|
350
|
+
this.stage('delivered', repo, m)
|
|
351
|
+
this.settle(repo, m.message.id)
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
this.stage('queued', repo, m)
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// A full queue drops this mention; say so rather than lose it silently.
|
|
357
|
+
this.seen.delete(m.message.id)
|
|
358
|
+
this.inflight.set(repo, this.inflight.get(repo)! - 1)
|
|
359
|
+
this.stage('failed', repo, m, (error as Error).message)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Open issues already carrying a `labeled:<label>` trigger's label are work
|
|
363
|
+
* the agent missed while offline: deliver each once, like a fresh label. */
|
|
364
|
+
private async backlog() {
|
|
365
|
+
const repos = [
|
|
366
|
+
...new Set([...this.channels, ...this.watched].map((c) => c.repo)),
|
|
367
|
+
]
|
|
368
|
+
for (const t of this.triggers) {
|
|
369
|
+
if (t.event !== 'issues' || t.action !== 'labeled' || !t.label) continue
|
|
370
|
+
for (const repo of repos) {
|
|
371
|
+
const [owner, name] = repo.split('/')
|
|
372
|
+
const issues = (await this.opts.client.request(
|
|
373
|
+
'issues' as never,
|
|
374
|
+
{ owner, repo: name } as never,
|
|
375
|
+
undefined as never,
|
|
376
|
+
{ state: 'open', labels: t.label, per_page: 50 } as never,
|
|
377
|
+
)) as unknown as {
|
|
378
|
+
number: number
|
|
379
|
+
title: string
|
|
380
|
+
labels: { name: string }[]
|
|
381
|
+
user: { login: string }
|
|
382
|
+
}[]
|
|
383
|
+
for (const issue of issues.slice().reverse())
|
|
384
|
+
this.trigger(repo, {
|
|
385
|
+
event: 'issues',
|
|
386
|
+
id: `backlog:${repo}#${issue.number}`,
|
|
387
|
+
payload: {
|
|
388
|
+
action: 'labeled',
|
|
389
|
+
label: { name: t.label },
|
|
390
|
+
issue,
|
|
391
|
+
sender: { login: issue.user.login },
|
|
392
|
+
},
|
|
393
|
+
} as never)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
private matchTrigger(
|
|
398
|
+
event: string,
|
|
399
|
+
p: TriggerPayload,
|
|
400
|
+
): (Trigger & { labelHit?: string }) | null {
|
|
401
|
+
for (const t of this.triggers) {
|
|
402
|
+
// `labeled:<label>` means "a subject carrying this label exists": it also
|
|
403
|
+
// fires when one is opened or reopened already carrying the label.
|
|
404
|
+
const carries =
|
|
405
|
+
t.action === 'labeled' &&
|
|
406
|
+
t.label !== undefined &&
|
|
407
|
+
(p.action === 'opened' || p.action === 'reopened')
|
|
408
|
+
if (t.event !== event || (t.action !== p.action && !carries)) continue
|
|
409
|
+
// `labeled`/`unlabeled` carry the label on the event itself; other
|
|
410
|
+
// actions match against the subject's current labels.
|
|
411
|
+
if (t.label !== undefined) {
|
|
412
|
+
const names =
|
|
413
|
+
(t.action === 'labeled' || t.action === 'unlabeled') && !carries
|
|
414
|
+
? [p.label?.name]
|
|
415
|
+
: ((p.issue ?? p.pull_request)?.labels.map((l) => l.name) ?? [])
|
|
416
|
+
if (!names.includes(t.label)) continue
|
|
417
|
+
}
|
|
418
|
+
return { ...t, labelHit: t.label ?? p.label?.name }
|
|
419
|
+
}
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
private trigger(repo: string, record: StreamRecord) {
|
|
423
|
+
if (!this.triggers.length) return
|
|
424
|
+
const parsed = triggerPayload.safeParse(record.payload)
|
|
425
|
+
if (!parsed.success) return
|
|
426
|
+
const p = parsed.data
|
|
427
|
+
const match = this.matchTrigger(record.event, p)
|
|
428
|
+
if (!match) return
|
|
429
|
+
const subject = p.issue ?? p.pull_request
|
|
430
|
+
// Events without a subject (push, workflow_run) name no issue to read.
|
|
431
|
+
if (!subject) return
|
|
432
|
+
// Namespaced so a trigger id can never collide with a mention message id.
|
|
433
|
+
// Label backlog subscriptions intentionally represent one assignment per
|
|
434
|
+
// subject. Other subscriptions represent individual durable stream events.
|
|
435
|
+
const key =
|
|
436
|
+
match.action === 'labeled'
|
|
437
|
+
? `trigger:${repo}#${subject.number}:${match.spec}`
|
|
438
|
+
: `trigger:${repo}:${record.event}:${record.id}:${match.spec}`
|
|
439
|
+
if (this.seen.has(key)) return
|
|
440
|
+
this.seen.add(key)
|
|
441
|
+
const t = {
|
|
442
|
+
spec: match.spec,
|
|
443
|
+
event: match.event,
|
|
444
|
+
action: match.action,
|
|
445
|
+
id: record.id,
|
|
446
|
+
number: subject.number,
|
|
447
|
+
title: subject.title,
|
|
448
|
+
actor: p.sender?.login ?? 'someone',
|
|
449
|
+
...(match.labelHit !== undefined ? { label: match.labelHit } : {}),
|
|
450
|
+
}
|
|
451
|
+
this.stageTrigger('received', repo, t)
|
|
452
|
+
this.inflight.set(repo, (this.inflight.get(repo) ?? 0) + 1)
|
|
453
|
+
try {
|
|
454
|
+
this.opts.enqueue(
|
|
455
|
+
triggerPrompt(
|
|
456
|
+
repo,
|
|
457
|
+
match,
|
|
458
|
+
subject,
|
|
459
|
+
t.actor,
|
|
460
|
+
match.labelHit,
|
|
461
|
+
this.opts.gild ?? 'gild',
|
|
462
|
+
this.opts.label,
|
|
463
|
+
),
|
|
464
|
+
() => {
|
|
465
|
+
this.stageTrigger('delivered', repo, t)
|
|
466
|
+
this.settle(repo, key)
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
this.stageTrigger('queued', repo, t)
|
|
470
|
+
} catch (error) {
|
|
471
|
+
this.seen.delete(key)
|
|
472
|
+
this.inflight.set(repo, this.inflight.get(repo)! - 1)
|
|
473
|
+
this.stageTrigger('failed', repo, t, (error as Error).message)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
private settle(repo: string, id: string) {
|
|
477
|
+
this.inflight.set(repo, this.inflight.get(repo)! - 1)
|
|
478
|
+
this.saved.delivered = [...this.saved.delivered, id].slice(-SEEN_LIMIT)
|
|
479
|
+
this.save(repo, this.inflight.get(repo) ? undefined : this.head.get(repo))
|
|
480
|
+
}
|
|
481
|
+
private save(repo: string, cursor?: string) {
|
|
482
|
+
if (cursor) this.saved.cursors[repo] = cursor
|
|
483
|
+
const text = JSON.stringify(this.saved)
|
|
484
|
+
const partial = `${this.opts.file}.${process.pid}.tmp`
|
|
485
|
+
this.writing = this.writing
|
|
486
|
+
.then(async () => {
|
|
487
|
+
await writeFile(partial, text, { mode: 0o600 })
|
|
488
|
+
await rename(partial, this.opts.file)
|
|
489
|
+
})
|
|
490
|
+
.catch(() => {})
|
|
491
|
+
}
|
|
492
|
+
flush() {
|
|
493
|
+
return this.writing
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function sleep(ms: number, signal: AbortSignal) {
|
|
498
|
+
return new Promise<void>((done) => {
|
|
499
|
+
const timer = setTimeout(done, ms)
|
|
500
|
+
signal.addEventListener('abort', () => (clearTimeout(timer), done()), {
|
|
501
|
+
once: true,
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Bundle the Node companion into the standalone CLI, without native addons. */
|
|
2
|
+
export async function spawnWorkerSource(): Promise<string> {
|
|
3
|
+
const result = Bun.spawnSync([
|
|
4
|
+
process.execPath,
|
|
5
|
+
'build',
|
|
6
|
+
new URL('./spawn-worker.ts', import.meta.url).pathname,
|
|
7
|
+
'--target=node',
|
|
8
|
+
// rixse's gateway runs only in the VM worker, which Bun runs; inline its
|
|
9
|
+
// source (the `bun` export) rather than the stub it gives Node.
|
|
10
|
+
'--conditions=bun',
|
|
11
|
+
'--format=esm',
|
|
12
|
+
])
|
|
13
|
+
if (result.exitCode !== 0)
|
|
14
|
+
throw new Error(`PTY companion build failed: ${result.stderr.toString()}`)
|
|
15
|
+
return result.stdout.toString()
|
|
16
|
+
}
|