@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.7
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/CHANGELOG.md +39 -0
- package/README.md +15 -12
- package/agents/ci-watcher.md +27 -0
- package/agents/thermo-nuclear-code-quality-review.md +23 -0
- package/assets/logo.png +0 -0
- package/automations/benny/FOR_AGENTS.md +1 -1
- package/automations/benny/README.md +2 -2
- package/automations/benny/skills/setup-benny/SKILL.md +5 -5
- package/bin/pstack-routines-supervisor +9 -0
- package/docs/guide/07-overnight.md +1 -1
- package/docs/guide/09-make-it-yours.md +1 -1
- package/docs/guide/10-recipes-and-pitfalls.md +1 -1
- package/docs/native-gaps.md +11 -5
- package/docs/parity-reverse-engineering.md +94 -0
- package/package.json +41 -6
- package/packaging/install-supervisor-unit.sh +164 -0
- package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
- package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
- package/skills/architect/SKILL.md +1 -1
- package/skills/architect/references/runner-prompt.md +1 -1
- package/skills/arena/SKILL.md +3 -3
- package/skills/check-compiler-errors/SKILL.md +23 -0
- package/skills/create-skill/SKILL.md +515 -29
- package/skills/fix-ci/SKILL.md +29 -0
- package/skills/fix-merge-conflicts/SKILL.md +32 -0
- package/skills/get-pr-comments/SKILL.md +23 -0
- package/skills/how/SKILL.md +1 -1
- package/skills/ketchup-video/SKILL.md +2 -2
- package/skills/loop-on-ci/SKILL.md +50 -0
- package/skills/make-bot-ui/SKILL.md +2 -2
- package/skills/make-pr-easy-to-review/SKILL.md +59 -0
- package/skills/new-branch-and-pr/SKILL.md +29 -0
- package/skills/no-inline-imports/SKILL.md +12 -0
- package/skills/poteto-help/SKILL.md +4 -4
- package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
- package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
- package/skills/pr-review-canvas/SKILL.md +176 -0
- package/skills/pr-review-canvas/renderer.js +170 -0
- package/skills/pr-review-canvas/styles.css +96 -0
- package/skills/pr-review-canvas/template.html +18 -0
- package/skills/reflect/SKILL.md +1 -1
- package/skills/review-and-ship/SKILL.md +41 -0
- package/skills/run-smoke-tests/SKILL.md +42 -0
- package/skills/statechart/scripts/test/cli.test.ts +1 -1
- package/skills/swarm/SKILL.md +2 -2
- package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
- package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
- package/skills/verify-this/SKILL.md +74 -0
- package/skills/weekly-review/SKILL.md +32 -0
- package/skills/what-did-i-get-done/SKILL.md +31 -0
- package/skills/why/SKILL.md +1 -1
- package/skills/workflow-from-chats/SKILL.md +50 -0
- package/src/mode.ts +3 -3
- package/src/paths.ts +13 -0
- package/src/pi-command.ts +12 -0
- package/src/rule.ts +4 -4
- package/src/sections.ts +2 -1
- package/src/subscriptions/durable-store.ts +482 -0
- package/src/subscriptions/durable-types.ts +41 -0
- package/src/subscriptions/ensure-supervisor.ts +67 -0
- package/src/subscriptions/index.ts +231 -271
- package/src/subscriptions/supervisor-main.ts +88 -0
- package/src/subscriptions/supervisor.ts +271 -0
- package/src/subscriptions/webhook.ts +22 -9
- package/src/task/card.ts +29 -0
- package/src/task/records.ts +3 -8
- package/src/task/runner.ts +9 -14
- package/src/task/tool.ts +18 -21
- package/src/todo.ts +1 -2
- package/scripts/live-e2e.mjs +0 -257
- package/test/aliases.test.ts +0 -57
- package/test/ask.test.ts +0 -85
- package/test/autocomplete.test.ts +0 -39
- package/test/e2e/aliases.e2e.test.ts +0 -81
- package/test/e2e/ask.e2e.test.ts +0 -135
- package/test/e2e/harness.ts +0 -179
- package/test/e2e/host.e2e.test.ts +0 -193
- package/test/e2e/lifecycle.e2e.test.ts +0 -99
- package/test/e2e/mode.e2e.test.ts +0 -160
- package/test/e2e/package.e2e.test.ts +0 -67
- package/test/e2e/task.e2e.test.ts +0 -734
- package/test/e2e/todo.e2e.test.ts +0 -59
- package/test/fixtures/cursor-askquestion-calls.json +0 -806
- package/test/fixtures/fake-pi.mjs +0 -96
- package/test/fixtures/image-model.ts +0 -16
- package/test/fixtures/list-models.ts +0 -15
- package/test/fixtures/readonly-shell-exit.ts +0 -17
- package/test/fixtures/scripted-model.ts +0 -113
- package/test/loop.test.ts +0 -209
- package/test/mode.test.ts +0 -143
- package/test/plan.test.ts +0 -126
- package/test/readonly-shell-security.test.ts +0 -84
- package/test/readonly-shell.test.ts +0 -44
- package/test/records.test.ts +0 -143
- package/test/registry.test.ts +0 -18
- package/test/resources.test.ts +0 -162
- package/test/rule.test.ts +0 -92
- package/test/scripts.test.ts +0 -182
- package/test/sections.test.ts +0 -56
- package/test/subscriptions-cron.test.ts +0 -60
- package/test/subscriptions.test.ts +0 -598
- package/test/task-agents.test.ts +0 -49
- package/test/task-card.test.ts +0 -34
- package/test/task-fork.test.ts +0 -233
- package/test/task-format.test.ts +0 -65
- package/test/task-model.test.ts +0 -141
- package/test/task-runner.test.ts +0 -259
- package/test/task-schema.test.ts +0 -50
- package/test/task-status.test.ts +0 -155
- package/test/task-worktree.test.ts +0 -106
- package/test/todo.test.ts +0 -74
- package/test/visualize.test.ts +0 -152
- package/tsconfig.json +0 -5
- package/vitest.config.ts +0 -16
package/src/mode.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ThemeColor } from '@earendil-works/pi-coding-agent';
|
|
5
|
-
import { parseFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
5
|
+
import { parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
6
6
|
|
|
7
7
|
export const MODE_ENTRY = 'pstack/mode';
|
|
8
8
|
const STATUS_KEY = 'pstack-mode';
|
|
@@ -53,8 +53,8 @@ export function modeSection(mode: ActiveMode): string {
|
|
|
53
53
|
if (mode.path === undefined) return `Mode ${mode.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`;
|
|
54
54
|
if (!existsSync(mode.path)) return `Mode ${mode.skill} is on, but its skill file ${mode.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`;
|
|
55
55
|
// The frontmatter, including `reminder`, stays out of the model's context in a mode (MODE-004).
|
|
56
|
-
const
|
|
57
|
-
return [`Mode ${mode.skill} is on for every turn until the user runs /mode off.`, `<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body
|
|
56
|
+
const body = stripFrontmatter(readFileSync(mode.path, 'utf8')).trim();
|
|
57
|
+
return [`Mode ${mode.skill} is on for every turn until the user runs /mode off.`, `<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body, '</skill>'].join('\n');
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// A mode's badge shows the skill's `icon` (default: lightning) and `color`. Icon names map to terminal glyphs and
|
package/src/paths.ts
CHANGED
|
@@ -23,6 +23,19 @@ export function routinesDir(): string {
|
|
|
23
23
|
return join(stateDir(), 'routines');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Desired-state registry and supervisor runtime for one workspace. */
|
|
27
|
+
export function durableDir(cwd: string): string {
|
|
28
|
+
return join(stateDir(), 'durable', workspaceSlug(cwd));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function durableRegistryPath(cwd: string): string {
|
|
32
|
+
return join(durableDir(cwd), 'subscriptions.json');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function durableRuntimeDir(cwd: string): string {
|
|
36
|
+
return join(durableDir(cwd), 'runtime');
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/**
|
|
27
40
|
* Pi's rule for a session folder name, so per-workspace state lines up with `<agentDir>/sessions/<slug>`. The live
|
|
28
41
|
* `sessionManager.getSessionDir()` needs a session and names the current one, which is `<workspace>/subagents` in a
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Argv prefix for spawning Pi: the running script when available so children match
|
|
6
|
+
* the parent's Pi version, else `pi` on PATH. Same rule as task/tool.ts historically.
|
|
7
|
+
*/
|
|
8
|
+
export function piCommand(): string[] {
|
|
9
|
+
const script = process.argv[1];
|
|
10
|
+
if (script !== undefined && !script.startsWith('/$bunfs/') && existsSync(script)) return [process.execPath, script];
|
|
11
|
+
return /^(node|bun)(\.exe)?$/.test(basename(process.execPath).toLowerCase()) ? ['pi'] : [process.execPath];
|
|
12
|
+
}
|
package/src/rule.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { stripFrontmatter } from '@earendil-works/pi-coding-agent';
|
|
5
5
|
|
|
6
6
|
const RULE_SECTION_LIMIT = 8_000;
|
|
7
7
|
const LEGACY_RULE_NAME = 'pstack-models.mdc';
|
|
@@ -26,9 +26,9 @@ export function migrateLegacyRule(path: string): void {
|
|
|
26
26
|
if (errno(error) === 'ENOENT') return;
|
|
27
27
|
throw error;
|
|
28
28
|
}
|
|
29
|
-
const
|
|
29
|
+
const body = stripFrontmatter(text).trim();
|
|
30
30
|
try {
|
|
31
|
-
writeFileSync(path, `${body
|
|
31
|
+
writeFileSync(path, `${body}\n`, { flag: 'wx' });
|
|
32
32
|
} catch (error) {
|
|
33
33
|
if (errno(error) === 'EEXIST') return;
|
|
34
34
|
throw error;
|
|
@@ -50,7 +50,7 @@ export function migrateLegacyRule(path: string): void {
|
|
|
50
50
|
/** The rule inline and bounded. A stray frontmatter block is not part of the rule. */
|
|
51
51
|
export function modelsSection(ruleText: string | undefined, path: string): string {
|
|
52
52
|
if (ruleText === undefined) return `No pstack model rule exists at ${path}. Every role uses its skill default until /setup-pstack writes one.`;
|
|
53
|
-
const body =
|
|
53
|
+
const body = stripFrontmatter(ruleText).trim();
|
|
54
54
|
if (body.length <= RULE_SECTION_LIMIT) return `Path: ${path}\n${body}`;
|
|
55
55
|
return `Path: ${path}\n${body.slice(0, RULE_SECTION_LIMIT)}\n[truncated: the rule is ${body.length} characters; read ${path} for the rest]`;
|
|
56
56
|
}
|
package/src/sections.ts
CHANGED
|
@@ -83,13 +83,14 @@ export function hostSection(facts: HostFacts): string {
|
|
|
83
83
|
'- The user keeps a skill on every turn with `/mode <skill>` (exit `/mode off`) and plans without edits with `/plan` (exit `/plan off`).',
|
|
84
84
|
'- PR operations use the forge command the step gives, such as `gh pr create`. Bugbot comments on a GitHub PR are ordinary review threads; skip a step that starts, waits for, or configures Bugbot when the PR has no Bugbot check or comment.',
|
|
85
85
|
'- MCP tools are named mcp__<server>__<tool>; `pi mcp list` shows the servers.',
|
|
86
|
-
"- Media in a reply: Pi's chat renders no image or video inside a reply. To show
|
|
86
|
+
"- Media in a reply: Pi's chat renders no image or video inside a reply. To show a screenshot or a frame, read the image file with the read tool, which displays it inline in terminals that support images, and name its path. For a watchable catch-up video, open the local file with the system player (`open` on macOS, `xdg-open` on Linux), read one frame from each slide, and name the video path. Pi chat still does not play the video inline.",
|
|
87
87
|
`- pi-pstack's README (${join(facts.packageRoot, 'README.md')}) and guide (${join(facts.packageRoot, 'docs/guide')}) describe installing and using it.`,
|
|
88
88
|
...(facts.activeTools.includes('Task')
|
|
89
89
|
? [
|
|
90
90
|
"- Isolated workers (Task environment \"cloud\") run on this machine in their own git worktree on branch pstack/<agent id>, created from cloud_base_branch or this checkout's HEAD commit, so uncommitted changes do not reach them. They share this workspace's Projects, stores, and subagent records. The Task result names the worktree and branch; merge that branch to take the subagent's work. Pi never deletes these worktrees or branches; the worktree-cleanup playbook prunes them.",
|
|
91
91
|
]
|
|
92
92
|
: []),
|
|
93
|
+
'- Always-applied engineering rules (also `/no-inline-imports` and `/typescript-exhaustive-switch`): place imports at the top of a module, never inline in bodies or type positions unless a circular dependency forces it and is documented; in switches over discriminated unions or enums, use a `never` check in the default case so new variants fail at compile time until handled.',
|
|
93
94
|
].join('\n');
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
|
|
6
|
+
import { durableRegistryPath, durableRuntimeDir } from '../paths.ts';
|
|
7
|
+
import { parseCron } from './cron.ts';
|
|
8
|
+
import type { ArmStatus, DurableRegistry, DurableSubscription, PendingEvent, StoredSubscription, SupervisorLock } from './durable-types.ts';
|
|
9
|
+
import { routineSlug } from './webhook.ts';
|
|
10
|
+
|
|
11
|
+
export function canonicalPath(path: string): string {
|
|
12
|
+
try {
|
|
13
|
+
return realpathSync(path);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
16
|
+
const absolute = resolve(path);
|
|
17
|
+
return absolute === dirname(absolute) ? absolute : join(canonicalPath(dirname(absolute)), absolute.slice(dirname(absolute).length + 1));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function alive(pid: number): boolean {
|
|
22
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeAtomic(path: string, value: unknown): void {
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
33
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
34
|
+
try {
|
|
35
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
36
|
+
renameSync(temporary, path);
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(temporary, { force: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const runtime = (cwd: string) => durableRuntimeDir(canonicalPath(cwd));
|
|
43
|
+
export const supervisorLockPath = (cwd: string) => join(runtime(cwd), 'supervisor.lock');
|
|
44
|
+
const registryLockPath = (cwd: string) => join(runtime(cwd), 'registry.lock');
|
|
45
|
+
export const ownerLockPath = (cwd: string, id: string) => join(runtime(cwd), `owner-${createHash('sha256').update(id).digest('hex')}.lock`);
|
|
46
|
+
|
|
47
|
+
export type Lease = { token: string; release: () => void };
|
|
48
|
+
|
|
49
|
+
/** Publish a nonempty directory atomically. Recovery removes only the dead holder's token. */
|
|
50
|
+
export function acquireLease(path: string): Lease | undefined {
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
52
|
+
const token = `${process.pid}-${randomUUID()}`;
|
|
53
|
+
const temporary = `${path}.${token}.tmp`;
|
|
54
|
+
mkdirSync(temporary, { mode: 0o700 });
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(join(temporary, token), '', { mode: 0o600 });
|
|
57
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
58
|
+
try {
|
|
59
|
+
renameSync(temporary, path);
|
|
60
|
+
return {
|
|
61
|
+
token,
|
|
62
|
+
release: () => {
|
|
63
|
+
rmSync(join(path, token), { force: true });
|
|
64
|
+
try {
|
|
65
|
+
rmdirSync(path);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (!['ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
73
|
+
}
|
|
74
|
+
let holders: string[];
|
|
75
|
+
try {
|
|
76
|
+
holders = readdirSync(path);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
for (const holder of holders) {
|
|
82
|
+
const pid = Number(/^(\d+)-[a-f0-9-]+$/.exec(holder)?.[1]);
|
|
83
|
+
if (!Number.isInteger(pid)) throw new Error(`Invalid subscription lock ${path}. Retain it for diagnosis.`);
|
|
84
|
+
if (alive(pid)) return undefined;
|
|
85
|
+
rmSync(join(path, holder), { force: true });
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
rmdirSync(path);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function ownerIsLive(cwd: string, sessionId: string): boolean {
|
|
100
|
+
const path = ownerLockPath(cwd, sessionId);
|
|
101
|
+
try {
|
|
102
|
+
const holders = readdirSync(path);
|
|
103
|
+
for (const holder of holders) {
|
|
104
|
+
const pid = Number(/^(\d+)-[a-f0-9-]+$/.exec(holder)?.[1]);
|
|
105
|
+
if (Number.isInteger(pid) && alive(pid)) return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function withRegistryLock<T>(cwd: string, work: () => T): T {
|
|
114
|
+
const deadline = performance.now() + 5_000;
|
|
115
|
+
do {
|
|
116
|
+
const lease = acquireLease(registryLockPath(cwd));
|
|
117
|
+
if (lease !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
return work();
|
|
120
|
+
} finally {
|
|
121
|
+
lease.release();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
125
|
+
} while (performance.now() < deadline);
|
|
126
|
+
throw new Error(`Subscription registry is busy in ${cwd}. Retry when the live writer finishes.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validSubscription(value: unknown): value is DurableSubscription {
|
|
130
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
131
|
+
const sub = value as DurableSubscription;
|
|
132
|
+
if (typeof sub.id !== 'string' || !/^sub-[a-zA-Z0-9-]+$/.test(sub.id) || typeof sub.name !== 'string' || !sub.name.trim() || !Number.isFinite(sub.createdAt) || !Number.isFinite(sub.expiresAt)) return false;
|
|
133
|
+
if ((sub.ownerSessionId !== undefined && (typeof sub.ownerSessionId !== 'string' || !sub.ownerSessionId)) || (sub.ownerSessionFile !== undefined && typeof sub.ownerSessionFile !== 'string')) return false;
|
|
134
|
+
const c = sub.config;
|
|
135
|
+
if (typeof c !== 'object' || c === null) return false;
|
|
136
|
+
if (c.kind === 'github') {
|
|
137
|
+
const t = c.target;
|
|
138
|
+
return (
|
|
139
|
+
typeof t === 'object' &&
|
|
140
|
+
t !== null &&
|
|
141
|
+
(t.scope === 'pr'
|
|
142
|
+
? typeof t.prUrl === 'string' && /^https:\/\/[^\s/]+\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+\/?$/.test(t.prUrl)
|
|
143
|
+
: (t.scope === 'repo' || t.scope === 'author') &&
|
|
144
|
+
typeof t.repo === 'string' &&
|
|
145
|
+
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(t.repo) &&
|
|
146
|
+
(t.scope !== 'author' || (typeof t.author === 'string' && /^(?:@me|[A-Za-z0-9](?:[A-Za-z0-9-]|\[bot\]|\/)*)$/.test(t.author))))
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (typeof c.prompt !== 'string') return false;
|
|
150
|
+
if (c.kind === 'webhook') {
|
|
151
|
+
try {
|
|
152
|
+
return typeof c.slug === 'string' && routineSlug(sub.name) === c.slug;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (c.kind !== 'timer' || typeof c.once !== 'boolean' || !['wake_self', 'new_session'].includes(c.strategy)) return false;
|
|
158
|
+
if ((c.cron === undefined) === (c.delaySeconds === undefined)) return false;
|
|
159
|
+
if (c.cron !== undefined) {
|
|
160
|
+
try {
|
|
161
|
+
parseCron(c.cron);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return c.delaySeconds === undefined || (Number.isInteger(c.delaySeconds) && c.delaySeconds >= 1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function emptyRegistry(cwd: string): DurableRegistry {
|
|
170
|
+
return { version: 2, workspaceCwd: canonicalPath(cwd), revision: 0, subscriptions: [], pending: [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function migratedOwner(sub: DurableSubscription): string | undefined {
|
|
174
|
+
if (sub.ownerSessionId !== undefined) return sub.ownerSessionId;
|
|
175
|
+
if (sub.ownerSessionFile === undefined) return undefined;
|
|
176
|
+
try {
|
|
177
|
+
const header = JSON.parse(readFileSync(sub.ownerSessionFile, 'utf8').split('\n')[0] ?? '');
|
|
178
|
+
return header.type === 'session' && typeof header.id === 'string' && header.id ? header.id : undefined;
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function loadRegistry(cwd: string): DurableRegistry {
|
|
185
|
+
const path = durableRegistryPath(canonicalPath(cwd));
|
|
186
|
+
if (!existsSync(path)) return emptyRegistry(cwd);
|
|
187
|
+
try {
|
|
188
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
189
|
+
if (typeof raw !== 'object' || raw === null || ![1, 2].includes(raw.version) || !Array.isArray(raw.subscriptions) || !raw.subscriptions.every(validSubscription)) throw new Error('invalid subscriptions');
|
|
190
|
+
if (typeof raw.workspaceCwd !== 'string' || canonicalPath(raw.workspaceCwd) !== canonicalPath(cwd)) throw new Error('Registry belongs to a different workspace.');
|
|
191
|
+
if (raw.version === 1) {
|
|
192
|
+
return {
|
|
193
|
+
...emptyRegistry(cwd),
|
|
194
|
+
revision: 1,
|
|
195
|
+
subscriptions: raw.subscriptions.map((sub: DurableSubscription) => {
|
|
196
|
+
const { detachedDelivery: _legacy, ...config } = sub;
|
|
197
|
+
const ownerSessionId = migratedOwner(sub);
|
|
198
|
+
return { ...config, ...(ownerSessionId === undefined ? { lastError: { at: Date.now(), text: 'Owner session ID unavailable. Recreate this subscription.' } } : { ownerSessionId }), revision: 1, lifecycle: 'active' };
|
|
199
|
+
}),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (!Number.isInteger(raw.revision) || raw.revision < 0 || !Array.isArray(raw.pending)) throw new Error('invalid registry revision or pending events');
|
|
203
|
+
const ids = new Set<string>();
|
|
204
|
+
for (const sub of raw.subscriptions) {
|
|
205
|
+
if (ids.has(sub.id) || !Number.isInteger(sub.revision) || !['active', 'completed', 'cancelled', 'expired'].includes(sub.lifecycle)) throw new Error('invalid subscription lifecycle');
|
|
206
|
+
ids.add(sub.id);
|
|
207
|
+
if (sub.lastOccurrence !== undefined && typeof sub.lastOccurrence !== 'string') throw new Error('invalid occurrence');
|
|
208
|
+
for (const note of [sub.lastEvent, sub.lastError, sub.lastOutcome]) if (note !== undefined && (typeof note.text !== 'string' || !Number.isFinite(note.at))) throw new Error('invalid subscription status');
|
|
209
|
+
}
|
|
210
|
+
const eventIds = new Set<string>();
|
|
211
|
+
for (const event of raw.pending) {
|
|
212
|
+
if (
|
|
213
|
+
typeof event.id !== 'string' ||
|
|
214
|
+
eventIds.has(event.id) ||
|
|
215
|
+
!ids.has(event.subscriptionId) ||
|
|
216
|
+
!Number.isFinite(event.acceptedAt) ||
|
|
217
|
+
typeof event.content !== 'string' ||
|
|
218
|
+
(event.claim !== undefined && (!Number.isInteger(event.claim.pid) || event.claim.pid <= 0 || typeof event.claim.token !== 'string' || !['live', 'detached'].includes(event.claim.mode))) ||
|
|
219
|
+
(event.attempts !== undefined && (!Number.isInteger(event.attempts) || event.attempts < 0)) ||
|
|
220
|
+
(event.retryAt !== undefined && !Number.isFinite(event.retryAt))
|
|
221
|
+
)
|
|
222
|
+
throw new Error('invalid pending event');
|
|
223
|
+
eventIds.add(event.id);
|
|
224
|
+
}
|
|
225
|
+
return { version: 2, workspaceCwd: canonicalPath(cwd), revision: raw.revision, subscriptions: raw.subscriptions, pending: raw.pending };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new Error(`Cannot read authoritative subscription registry ${path}. Retain the file for diagnosis. ${error instanceof Error ? error.message : String(error)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function readRegistry(cwd: string): DurableRegistry {
|
|
232
|
+
const path = durableRegistryPath(canonicalPath(cwd));
|
|
233
|
+
const registry = loadRegistry(cwd);
|
|
234
|
+
if (existsSync(path) && JSON.parse(readFileSync(path, 'utf8')).version === 1) {
|
|
235
|
+
return withRegistryLock(cwd, () => {
|
|
236
|
+
const current = loadRegistry(cwd);
|
|
237
|
+
writeAtomic(path, current);
|
|
238
|
+
return current;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return registry;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function transaction<T>(cwd: string, change: (registry: DurableRegistry) => T): T {
|
|
245
|
+
return withRegistryLock(cwd, () => {
|
|
246
|
+
const registry = loadRegistry(cwd);
|
|
247
|
+
const before = JSON.stringify(registry);
|
|
248
|
+
const result = change(registry);
|
|
249
|
+
if (JSON.stringify(registry) !== before) {
|
|
250
|
+
registry.revision += 1;
|
|
251
|
+
writeAtomic(durableRegistryPath(canonicalPath(cwd)), registry);
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export const isDesired = (sub: StoredSubscription, now = Date.now()) => sub.lifecycle === 'active' && sub.expiresAt > now;
|
|
258
|
+
export function visibleSubscriptions(registry: DurableRegistry, now = Date.now()): StoredSubscription[] {
|
|
259
|
+
return registry.subscriptions.filter((sub) => sub.lifecycle !== 'cancelled' && (isDesired(sub, now) || registry.pending.some((event) => event.subscriptionId === sub.id) || sub.lastOutcome !== undefined));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function createDesired(cwd: string, sub: DurableSubscription): { created: boolean; subscription: StoredSubscription } {
|
|
263
|
+
if (!validSubscription(sub)) throw new Error('Invalid subscription configuration.');
|
|
264
|
+
return transaction(cwd, (registry) => {
|
|
265
|
+
const existing = registry.subscriptions.find((entry) => isDesired(entry) && entry.name === sub.name);
|
|
266
|
+
if (existing !== undefined) return { created: false, subscription: existing };
|
|
267
|
+
if (registry.subscriptions.some((entry) => entry.id === sub.id)) throw new Error(`Subscription identity ${sub.id} already exists.`);
|
|
268
|
+
const config = sub.config;
|
|
269
|
+
if (config.kind === 'webhook') {
|
|
270
|
+
const clash = registry.subscriptions.find((entry) => isDesired(entry) && entry.config.kind === 'webhook' && entry.config.slug === config.slug);
|
|
271
|
+
if (clash !== undefined) throw new Error(`Routine "${clash.name}" already uses the slug ${config.slug}.`);
|
|
272
|
+
}
|
|
273
|
+
const { detachedDelivery: _legacy, ...input } = sub;
|
|
274
|
+
const stored: StoredSubscription = { ...input, revision: registry.revision + 1, lifecycle: 'active' };
|
|
275
|
+
registry.subscriptions.push(stored);
|
|
276
|
+
return { created: true, subscription: stored };
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function cancelDesired(cwd: string, key: string): StoredSubscription | undefined {
|
|
281
|
+
return transaction(cwd, (registry) => {
|
|
282
|
+
const candidates = visibleSubscriptions(registry);
|
|
283
|
+
const sub = candidates.find((entry) => entry.id === key) ?? candidates.find((entry) => entry.name === key && isDesired(entry)) ?? candidates.find((entry) => entry.name === key);
|
|
284
|
+
if (sub === undefined) return undefined;
|
|
285
|
+
sub.lifecycle = 'cancelled';
|
|
286
|
+
delete sub.lastOutcome;
|
|
287
|
+
registry.pending = registry.pending.filter((event) => event.subscriptionId !== sub.id);
|
|
288
|
+
return sub;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function expireDesired(cwd: string, at = Date.now()): void {
|
|
293
|
+
transaction(cwd, (registry) => {
|
|
294
|
+
for (const sub of registry.subscriptions) if (sub.lifecycle === 'active' && sub.expiresAt <= at) sub.lifecycle = 'expired';
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const MAX_PENDING_PER_SUBSCRIPTION = 20;
|
|
299
|
+
|
|
300
|
+
export function acceptEvent(cwd: string, subscriptionId: string, occurrence: string, content: string, at = Date.now()): 'accepted' | 'duplicate' | 'inactive' {
|
|
301
|
+
return transaction(cwd, (registry) => {
|
|
302
|
+
const sub = registry.subscriptions.find((entry) => entry.id === subscriptionId);
|
|
303
|
+
if (sub === undefined || !isDesired(sub, at)) return 'inactive';
|
|
304
|
+
if (sub.lastOccurrence === occurrence || registry.pending.some((event) => event.id === occurrence)) return 'duplicate';
|
|
305
|
+
const forSub = registry.pending.filter((event) => event.subscriptionId === subscriptionId);
|
|
306
|
+
if (forSub.length >= MAX_PENDING_PER_SUBSCRIPTION) {
|
|
307
|
+
const oldest = forSub[0];
|
|
308
|
+
if (oldest !== undefined) {
|
|
309
|
+
registry.pending = registry.pending.filter((event) => event.id !== oldest.id);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
registry.pending.push({ id: occurrence, subscriptionId, acceptedAt: at, content });
|
|
313
|
+
sub.lastOccurrence = occurrence;
|
|
314
|
+
sub.lastEvent = { at, text: 'accepted' };
|
|
315
|
+
delete sub.lastError;
|
|
316
|
+
if (sub.config.kind === 'timer' && sub.config.once) sub.lifecycle = 'completed';
|
|
317
|
+
return 'accepted';
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function deliverOwner(cwd: string, ownerSessionId: string, eligible: () => boolean, send: (sub: StoredSubscription, event: PendingEvent) => void): void {
|
|
322
|
+
transaction(cwd, (registry) => {
|
|
323
|
+
for (const event of [...registry.pending]) {
|
|
324
|
+
const sub = registry.subscriptions.find((entry) => entry.id === event.subscriptionId);
|
|
325
|
+
if (sub === undefined || sub.ownerSessionId !== ownerSessionId || sub.lifecycle === 'cancelled' || (sub.config.kind === 'timer' && sub.config.strategy === 'new_session')) continue;
|
|
326
|
+
if (!eligible()) return;
|
|
327
|
+
// Pi enqueue and disk acknowledgement cannot commit atomically. A crash can replay this event.
|
|
328
|
+
send(sub, event);
|
|
329
|
+
registry.pending = registry.pending.filter((entry) => entry.id !== event.id);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function startFresh(
|
|
335
|
+
cwd: string,
|
|
336
|
+
eventId: string,
|
|
337
|
+
ownerSessionId: string | undefined,
|
|
338
|
+
eligible: () => boolean,
|
|
339
|
+
run: (sub: StoredSubscription, event: PendingEvent) => Promise<void> | void,
|
|
340
|
+
): { token: string; result: Promise<void> } | undefined {
|
|
341
|
+
const claim = transaction(cwd, (registry) => {
|
|
342
|
+
const event = registry.pending.find((entry) => entry.id === eventId);
|
|
343
|
+
const sub = registry.subscriptions.find((entry) => entry.id === event?.subscriptionId);
|
|
344
|
+
if (
|
|
345
|
+
event === undefined ||
|
|
346
|
+
sub === undefined ||
|
|
347
|
+
sub.lifecycle === 'cancelled' ||
|
|
348
|
+
sub.config.kind !== 'timer' ||
|
|
349
|
+
sub.config.strategy !== 'new_session' ||
|
|
350
|
+
sub.ownerSessionId === undefined ||
|
|
351
|
+
(event.claim !== undefined && alive(event.claim.pid)) ||
|
|
352
|
+
(event.retryAt !== undefined && Date.now() < event.retryAt) ||
|
|
353
|
+
Date.now() >= sub.expiresAt ||
|
|
354
|
+
!eligible()
|
|
355
|
+
)
|
|
356
|
+
return undefined;
|
|
357
|
+
if (ownerSessionId === undefined ? sub.ownerSessionId !== undefined && ownerIsLive(cwd, sub.ownerSessionId) : sub.ownerSessionId !== ownerSessionId) return undefined;
|
|
358
|
+
const token = randomUUID();
|
|
359
|
+
event.claim = { pid: process.pid, token, mode: ownerSessionId === undefined ? 'detached' : 'live' };
|
|
360
|
+
return { token, sub, event };
|
|
361
|
+
});
|
|
362
|
+
if (claim === undefined) return undefined;
|
|
363
|
+
let result: Promise<void>;
|
|
364
|
+
try {
|
|
365
|
+
result = Promise.resolve(run(claim.sub, claim.event));
|
|
366
|
+
} catch (error) {
|
|
367
|
+
result = Promise.reject(error);
|
|
368
|
+
}
|
|
369
|
+
return { token: claim.token, result };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function finishFresh(cwd: string, eventId: string, token: string, error?: unknown): void {
|
|
373
|
+
transaction(cwd, (registry) => {
|
|
374
|
+
const event = registry.pending.find((entry) => entry.id === eventId && entry.claim?.token === token);
|
|
375
|
+
if (event === undefined) return;
|
|
376
|
+
const sub = registry.subscriptions.find((entry) => entry.id === event.subscriptionId);
|
|
377
|
+
if (sub === undefined) return;
|
|
378
|
+
if (error !== undefined) {
|
|
379
|
+
const attempts = (event.attempts ?? 0) + 1;
|
|
380
|
+
const MAX_ATTEMPTS = 5;
|
|
381
|
+
sub.lastError = { at: Date.now(), text: error instanceof Error ? error.message : String(error) };
|
|
382
|
+
delete event.claim;
|
|
383
|
+
event.attempts = attempts;
|
|
384
|
+
if (attempts >= MAX_ATTEMPTS || Date.now() >= sub.expiresAt) {
|
|
385
|
+
registry.pending = registry.pending.filter((entry) => entry.id !== eventId);
|
|
386
|
+
} else {
|
|
387
|
+
const backoffMs = Math.min(60_000, 250 * 2 ** (attempts - 1));
|
|
388
|
+
event.retryAt = Date.now() + backoffMs;
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
sub.lastOutcome = { at: Date.now(), text: event.claim?.mode === 'live' ? 'live Task callback accepted' : 'detached run completed' };
|
|
392
|
+
delete sub.lastError;
|
|
393
|
+
registry.pending = registry.pending.filter((entry) => entry.id !== eventId);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function clearError(cwd: string, id: string): void {
|
|
399
|
+
transaction(cwd, (registry) => {
|
|
400
|
+
const sub = registry.subscriptions.find((entry) => entry.id === id);
|
|
401
|
+
if (sub !== undefined) delete sub.lastError;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function recordError(cwd: string, id: string, error: unknown): void {
|
|
406
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
407
|
+
transaction(cwd, (registry) => {
|
|
408
|
+
const sub = registry.subscriptions.find((entry) => entry.id === id);
|
|
409
|
+
if (sub !== undefined && sub.lastError?.text !== text) {
|
|
410
|
+
sub.lastError = { at: Date.now(), text };
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function readLock(cwd: string): SupervisorLock | undefined {
|
|
416
|
+
try {
|
|
417
|
+
const value = JSON.parse(readFileSync(supervisorLockPath(cwd), 'utf8'));
|
|
418
|
+
return Number.isInteger(value.pid) && value.pid > 0 && Number.isFinite(value.startedAt) && typeof value.epoch === 'string' && value.epoch !== '' ? { pid: value.pid, startedAt: value.startedAt, epoch: value.epoch } : undefined;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
421
|
+
throw new Error(`Cannot read supervisor lock ${supervisorLockPath(cwd)}. ${String(error)}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
export function isSupervisorLive(cwd: string): boolean {
|
|
425
|
+
const lock = readLock(cwd);
|
|
426
|
+
return lock !== undefined && alive(lock.pid);
|
|
427
|
+
}
|
|
428
|
+
export function acquireSupervisorLock(cwd: string, pid = process.pid, startedAt = Date.now()): { acquired: true } | { acquired: false; alreadyRunning: true; pid: number } {
|
|
429
|
+
return withRegistryLock(cwd, () => {
|
|
430
|
+
try {
|
|
431
|
+
const raw = JSON.parse(readFileSync(supervisorLockPath(cwd), 'utf8'));
|
|
432
|
+
if (Number.isInteger(raw.pid) && raw.pid > 0 && alive(raw.pid)) {
|
|
433
|
+
if (typeof raw.epoch === 'string' && raw.epoch !== '') {
|
|
434
|
+
return { acquired: false, alreadyRunning: true, pid: raw.pid };
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
process.kill(raw.pid, 'SIGTERM');
|
|
438
|
+
} catch {}
|
|
439
|
+
const deadline = Date.now() + 1_000;
|
|
440
|
+
while (Date.now() < deadline && alive(raw.pid)) {
|
|
441
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
442
|
+
}
|
|
443
|
+
if (alive(raw.pid)) {
|
|
444
|
+
return { acquired: false, alreadyRunning: true, pid: raw.pid };
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch (error) {
|
|
448
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
449
|
+
// Corrupted lock file: overwrite with clean lock
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
writeAtomic(supervisorLockPath(cwd), { pid, startedAt, epoch: randomUUID() });
|
|
453
|
+
return { acquired: true };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
export function releaseSupervisorLock(cwd: string, pid = process.pid, epoch?: string): void {
|
|
457
|
+
withRegistryLock(cwd, () => {
|
|
458
|
+
const lock = readLock(cwd);
|
|
459
|
+
if (lock?.pid === pid && (epoch === undefined || epoch === lock.epoch)) rmSync(supervisorLockPath(cwd), { force: true });
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const armPath = (cwd: string, id: string) => join(runtime(cwd), 'arms', `${createHash('sha256').update(id).digest('hex')}.json`);
|
|
464
|
+
export function writeArm(cwd: string, status: ArmStatus): void {
|
|
465
|
+
writeAtomic(armPath(cwd, status.subscriptionId), status);
|
|
466
|
+
}
|
|
467
|
+
export function readArm(cwd: string, sub: StoredSubscription): ArmStatus | undefined {
|
|
468
|
+
const lock = readLock(cwd);
|
|
469
|
+
if (lock === undefined || !alive(lock.pid)) return undefined;
|
|
470
|
+
try {
|
|
471
|
+
const status = JSON.parse(readFileSync(armPath(cwd, sub.id), 'utf8')) as ArmStatus;
|
|
472
|
+
return status.subscriptionId === sub.id &&
|
|
473
|
+
status.revision === sub.revision &&
|
|
474
|
+
status.epoch === lock.epoch &&
|
|
475
|
+
((status.state === 'error' && typeof status.message === 'string') || (status.state === 'ready' && (status.url === undefined || typeof status.url === 'string')))
|
|
476
|
+
? status
|
|
477
|
+
: undefined;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type Target = { scope: 'pr'; prUrl: string } | { scope: 'repo'; repo: string } | { scope: 'author'; author: string; repo: string };
|
|
2
|
+
export type Config = { kind: 'github'; target: Target } | { kind: 'timer'; prompt: string; cron?: string; delaySeconds?: number; once: boolean; strategy: 'wake_self' | 'new_session' } | { kind: 'webhook'; prompt: string; slug: string };
|
|
3
|
+
|
|
4
|
+
export type Subscription = { id: string; name: string; createdAt: number; expiresAt: number; config: Config };
|
|
5
|
+
export type DurableSubscription = Subscription & {
|
|
6
|
+
ownerSessionId?: string;
|
|
7
|
+
ownerSessionFile?: string;
|
|
8
|
+
/** Read only during v1 migration. Delivery is determined by config, never this flag. */
|
|
9
|
+
detachedDelivery?: 'spawn' | 'park';
|
|
10
|
+
};
|
|
11
|
+
export type Note = { at: number; text: string };
|
|
12
|
+
export type StoredSubscription = DurableSubscription & {
|
|
13
|
+
revision: number;
|
|
14
|
+
lifecycle: 'active' | 'completed' | 'cancelled' | 'expired';
|
|
15
|
+
lastOccurrence?: string;
|
|
16
|
+
lastEvent?: Note;
|
|
17
|
+
lastError?: Note;
|
|
18
|
+
lastOutcome?: Note;
|
|
19
|
+
};
|
|
20
|
+
export type PendingEvent = {
|
|
21
|
+
id: string;
|
|
22
|
+
subscriptionId: string;
|
|
23
|
+
acceptedAt: number;
|
|
24
|
+
content: string;
|
|
25
|
+
claim?: { pid: number; token: string; mode: 'live' | 'detached' };
|
|
26
|
+
attempts?: number;
|
|
27
|
+
retryAt?: number;
|
|
28
|
+
};
|
|
29
|
+
export type DurableRegistry = {
|
|
30
|
+
version: 2;
|
|
31
|
+
workspaceCwd: string;
|
|
32
|
+
revision: number;
|
|
33
|
+
subscriptions: StoredSubscription[];
|
|
34
|
+
pending: PendingEvent[];
|
|
35
|
+
};
|
|
36
|
+
export type SupervisorLock = { pid: number; startedAt: number; epoch: string };
|
|
37
|
+
export type ArmStatus = {
|
|
38
|
+
subscriptionId: string;
|
|
39
|
+
revision: number;
|
|
40
|
+
epoch: string;
|
|
41
|
+
} & ({ state: 'ready'; url?: string } | { state: 'error'; message: string });
|