omp-conductor 0.3.17 → 0.3.19
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/README.md +344 -179
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +136 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +211 -22
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +327 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { stateDir } from "./config.ts";
|
|
5
|
+
import type { ReleasePolicy } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const RELEASE_POLICY_AUDIT_FILE = "release-policy-blocks.jsonl";
|
|
8
|
+
|
|
9
|
+
export type ReleaseShape =
|
|
10
|
+
| "git-tag"
|
|
11
|
+
| "git-push-tags"
|
|
12
|
+
| "package-publish"
|
|
13
|
+
| "github-release"
|
|
14
|
+
| "deploy";
|
|
15
|
+
|
|
16
|
+
export interface ReleaseBlock {
|
|
17
|
+
project: string;
|
|
18
|
+
source: "worker" | "orchestrator";
|
|
19
|
+
shape: ReleaseShape;
|
|
20
|
+
at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ReleaseDecision = { block: true; reason: string };
|
|
24
|
+
|
|
25
|
+
const GIT_PUSH_TAG_SHAPE =
|
|
26
|
+
/(?:--tags\b|--follow-tags\b|refs\/tags\/|(?:^|\s)(?:tag\s+)?v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?(?=[:\s]|$))/;
|
|
27
|
+
|
|
28
|
+
function commandSegments(command: string): string[] {
|
|
29
|
+
return command
|
|
30
|
+
.split(/(?:&&|\|\||[;\n|])/)
|
|
31
|
+
.map((segment) => segment.trim())
|
|
32
|
+
.filter((segment) => segment.length > 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripCommandPrefix(segment: string): string {
|
|
36
|
+
return segment
|
|
37
|
+
.replace(/^env\s+/, "")
|
|
38
|
+
.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*/, "")
|
|
39
|
+
.replace(/^sudo\s+/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Recognise the explicit release/deploy command shapes this policy promises to gate. */
|
|
43
|
+
export function releaseShapeFromCommand(command: string): ReleaseShape | undefined {
|
|
44
|
+
for (const raw of commandSegments(command)) {
|
|
45
|
+
const segment = stripCommandPrefix(raw);
|
|
46
|
+
if (/^git(?:\s+-[Cc]\s+\S+)*\s+tag(?:\s|$)/.test(segment)) return "git-tag";
|
|
47
|
+
if (
|
|
48
|
+
/^git(?:\s+-[Cc]\s+\S+)*\s+push\b/.test(segment) &&
|
|
49
|
+
GIT_PUSH_TAG_SHAPE.test(segment)
|
|
50
|
+
) {
|
|
51
|
+
return "git-push-tags";
|
|
52
|
+
}
|
|
53
|
+
if (/^(?:npm|pnpm|bun)\s+(?:publish|stage\s+publish)\b/.test(segment)) {
|
|
54
|
+
return "package-publish";
|
|
55
|
+
}
|
|
56
|
+
if (/^yarn\s+npm\s+publish\b/.test(segment)) return "package-publish";
|
|
57
|
+
if (/^gh\s+release\s+create\b/.test(segment)) return "github-release";
|
|
58
|
+
if (
|
|
59
|
+
/^(?:kubectl\s+(?:apply|create|replace)|helm\s+(?:install|upgrade)|terraform\s+apply|pulumi\s+up|(?:fly|vercel|wrangler)\s+deploy|docker\s+push)\b/.test(
|
|
60
|
+
segment,
|
|
61
|
+
)
|
|
62
|
+
) {
|
|
63
|
+
return "deploy";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Release-shaped device/tool invocations that do not pass through a shell. */
|
|
70
|
+
export function releaseShapeFromTool(
|
|
71
|
+
toolName: string,
|
|
72
|
+
input: Record<string, unknown> | undefined,
|
|
73
|
+
): ReleaseShape | undefined {
|
|
74
|
+
if (input === undefined) return undefined;
|
|
75
|
+
if (toolName === "bash") {
|
|
76
|
+
return typeof input.command === "string" ? releaseShapeFromCommand(input.command) : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
80
|
+
if (toolName === "write" && path.startsWith("xd://")) {
|
|
81
|
+
if (/create[_-]release/i.test(path)) return "github-release";
|
|
82
|
+
if (/publish/i.test(path)) return "package-publish";
|
|
83
|
+
if (/deploy/i.test(path)) return "deploy";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const normalized = toolName.toLowerCase().replaceAll("-", "_");
|
|
87
|
+
if (/create_?release|release_?create/.test(normalized)) return "github-release";
|
|
88
|
+
if (/(?:^|_)publish(?:$|_)/.test(normalized)) return "package-publish";
|
|
89
|
+
if (/(?:^|_)deploy(?:$|_)/.test(normalized)) return "deploy";
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function releaseDecision(
|
|
94
|
+
policy: ReleasePolicy,
|
|
95
|
+
toolName: string,
|
|
96
|
+
input: Record<string, unknown>,
|
|
97
|
+
): { shape: ReleaseShape; decision: ReleaseDecision } | undefined {
|
|
98
|
+
if (policy !== "none") return undefined;
|
|
99
|
+
const shape = releaseShapeFromTool(toolName, input);
|
|
100
|
+
if (shape === undefined) return undefined;
|
|
101
|
+
return {
|
|
102
|
+
shape,
|
|
103
|
+
decision: {
|
|
104
|
+
block: true,
|
|
105
|
+
reason:
|
|
106
|
+
`Blocked by releasePolicy=none (${shape}). ` +
|
|
107
|
+
"Only a human may change the project to operator-brief before release or deploy tools can run.",
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface ReleasePolicyPi {
|
|
113
|
+
on(
|
|
114
|
+
event: "tool_call",
|
|
115
|
+
handler: (
|
|
116
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
117
|
+
ctx: unknown,
|
|
118
|
+
) => ReleaseDecision | undefined,
|
|
119
|
+
): void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Inline session extension used by workers and the embedded orchestrator. */
|
|
123
|
+
export function releasePolicyTripwire(
|
|
124
|
+
policy: ReleasePolicy,
|
|
125
|
+
onBlocked: (shape: ReleaseShape) => void = () => {},
|
|
126
|
+
): (pi: ReleasePolicyPi) => void {
|
|
127
|
+
return (pi) => {
|
|
128
|
+
pi.on("tool_call", (event) => {
|
|
129
|
+
const blocked = releaseDecision(policy, event.toolName, event.input);
|
|
130
|
+
if (blocked === undefined) return undefined;
|
|
131
|
+
try {
|
|
132
|
+
onBlocked(blocked.shape);
|
|
133
|
+
} catch {
|
|
134
|
+
// Audit is evidence, not the gate. A full disk must not turn a deny into allow.
|
|
135
|
+
}
|
|
136
|
+
return blocked.decision;
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function recordReleaseBlock(
|
|
142
|
+
project: string,
|
|
143
|
+
source: ReleaseBlock["source"],
|
|
144
|
+
shape: ReleaseShape,
|
|
145
|
+
root = stateDir(),
|
|
146
|
+
now = new Date(),
|
|
147
|
+
): void {
|
|
148
|
+
mkdirSync(root, { recursive: true });
|
|
149
|
+
const record: ReleaseBlock = { project, source, shape, at: now.toISOString() };
|
|
150
|
+
appendFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ReleaseDriftSummary {
|
|
154
|
+
count: number;
|
|
155
|
+
latest: ReleaseBlock;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Aggregate today's blocked attempts for the orchestrator's daily digest. */
|
|
159
|
+
export function releaseDriftToday(
|
|
160
|
+
project: string,
|
|
161
|
+
root = stateDir(),
|
|
162
|
+
now = new Date(),
|
|
163
|
+
): ReleaseDriftSummary | undefined {
|
|
164
|
+
let text: string;
|
|
165
|
+
try {
|
|
166
|
+
text = readFileSync(join(root, RELEASE_POLICY_AUDIT_FILE), "utf8");
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const day = now.toISOString().slice(0, 10);
|
|
171
|
+
let count = 0;
|
|
172
|
+
let latest: ReleaseBlock | undefined;
|
|
173
|
+
for (const line of text.split("\n")) {
|
|
174
|
+
if (line.length === 0) continue;
|
|
175
|
+
try {
|
|
176
|
+
const value = JSON.parse(line) as Partial<ReleaseBlock>;
|
|
177
|
+
if (
|
|
178
|
+
value.project === project &&
|
|
179
|
+
typeof value.at === "string" &&
|
|
180
|
+
value.at.startsWith(day) &&
|
|
181
|
+
(value.source === "worker" || value.source === "orchestrator") &&
|
|
182
|
+
typeof value.shape === "string"
|
|
183
|
+
) {
|
|
184
|
+
count += 1;
|
|
185
|
+
latest = value as ReleaseBlock;
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// One torn line does not hide later valid audit records.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return latest === undefined ? undefined : { count, latest };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function releaseDriftDigestLine(project: string, root = stateDir(), now = new Date()): string | undefined {
|
|
195
|
+
const drift = releaseDriftToday(project, root, now);
|
|
196
|
+
if (drift === undefined) return undefined;
|
|
197
|
+
return (
|
|
198
|
+
`Release-policy drift today: ${drift.count} release/deploy tool call(s) were blocked ` +
|
|
199
|
+
`(latest: ${drift.latest.source} ${drift.latest.shape} at ${drift.latest.at}). ` +
|
|
200
|
+
"Include this divergence from releasePolicy=none in today's digest."
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, userInfo } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { configPath, stateDir } from "./config.ts";
|
|
5
|
+
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
6
|
+
import { DEFAULT_FLEET_AGENT_NAME } from "./fleet.ts";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_PORT,
|
|
9
|
+
healthCheck,
|
|
10
|
+
livingDaemon,
|
|
11
|
+
startDaemon,
|
|
12
|
+
stopDaemon,
|
|
13
|
+
type DaemonRecord,
|
|
14
|
+
type StopResult,
|
|
15
|
+
} from "./lifecycle.ts";
|
|
16
|
+
import {
|
|
17
|
+
readTickConfig,
|
|
18
|
+
TICK_CONFIG_FILE,
|
|
19
|
+
type TickConfig,
|
|
20
|
+
} from "./orchestrator-tick.ts";
|
|
21
|
+
import type { Caps, ProjectConfig } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
24
|
+
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
25
|
+
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
26
|
+
|
|
27
|
+
export type PlannedWrite<T> = {
|
|
28
|
+
path: string;
|
|
29
|
+
action: "create" | "update" | "keep";
|
|
30
|
+
content: string;
|
|
31
|
+
value: T;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface HostRuntimePlan {
|
|
35
|
+
service: PlannedWrite<string>;
|
|
36
|
+
tick?: PlannedWrite<TickConfig>;
|
|
37
|
+
installCommands: readonly string[];
|
|
38
|
+
cliSource: "global" | "plugin";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ServiceRuntime {
|
|
42
|
+
username: string;
|
|
43
|
+
home: string;
|
|
44
|
+
path: string;
|
|
45
|
+
bun: string;
|
|
46
|
+
cli?: string;
|
|
47
|
+
packageCli: string;
|
|
48
|
+
conductorHome: string;
|
|
49
|
+
telegramStateDir: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function systemdQuote(value: string): string {
|
|
53
|
+
if (/\r|\n/.test(value)) throw new Error("systemd values cannot contain newlines");
|
|
54
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%")}"`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function shellQuote(value: string): string {
|
|
58
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function actionFor(path: string, content: string): PlannedWrite<string>["action"] {
|
|
62
|
+
if (!existsSync(path)) return "create";
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(path, "utf8") === content ? "keep" : "update";
|
|
65
|
+
} catch {
|
|
66
|
+
return "update";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultServiceRuntime(telegramStateDir: string): ServiceRuntime {
|
|
71
|
+
const home = homedir();
|
|
72
|
+
const bun = process.execPath;
|
|
73
|
+
const globalCli = Bun.which("omp-conductor");
|
|
74
|
+
const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
|
|
75
|
+
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
|
76
|
+
);
|
|
77
|
+
return {
|
|
78
|
+
username: userInfo().username,
|
|
79
|
+
home,
|
|
80
|
+
path: pathParts.join(":"),
|
|
81
|
+
bun,
|
|
82
|
+
...(globalCli === null ? {} : { cli: globalCli }),
|
|
83
|
+
packageCli: join(import.meta.dir, "cli.ts"),
|
|
84
|
+
conductorHome: dirname(configPath()),
|
|
85
|
+
telegramStateDir,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderDaemonService(
|
|
90
|
+
project: ProjectConfig,
|
|
91
|
+
caps: Caps,
|
|
92
|
+
runtime: ServiceRuntime,
|
|
93
|
+
): string {
|
|
94
|
+
const command =
|
|
95
|
+
runtime.cli === undefined
|
|
96
|
+
? [runtime.bun, runtime.packageCli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)]
|
|
97
|
+
: [runtime.cli, "daemon", "--project", project.name, "--port", String(DEFAULT_PORT)];
|
|
98
|
+
const memoryMax = caps.maxConcurrentWorkers <= 1 ? "3G" : "5G";
|
|
99
|
+
return [
|
|
100
|
+
"[Unit]",
|
|
101
|
+
"Description=omp-conductor dispatch daemon",
|
|
102
|
+
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
103
|
+
"After=network-online.target",
|
|
104
|
+
"Wants=network-online.target",
|
|
105
|
+
"",
|
|
106
|
+
"[Service]",
|
|
107
|
+
"Type=simple",
|
|
108
|
+
`User=${runtime.username}`,
|
|
109
|
+
`Environment=${systemdQuote(`HOME=${runtime.home}`)}`,
|
|
110
|
+
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
111
|
+
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
112
|
+
`Environment=${systemdQuote(`OMP_TELEGRAM_STATE_DIR=${runtime.telegramStateDir}`)}`,
|
|
113
|
+
`WorkingDirectory=${systemdQuote(stateDir())}`,
|
|
114
|
+
`ExecStart=${command.map(systemdQuote).join(" ")}`,
|
|
115
|
+
"Restart=on-failure",
|
|
116
|
+
"SuccessExitStatus=0 143",
|
|
117
|
+
"MemoryAccounting=yes",
|
|
118
|
+
`MemoryMax=${memoryMax}`,
|
|
119
|
+
"",
|
|
120
|
+
"[Install]",
|
|
121
|
+
"WantedBy=multi-user.target",
|
|
122
|
+
"",
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function tickSearchRoots(project: ProjectConfig): string[] {
|
|
127
|
+
const roots = [stateDir(), dirname(project.workspaceRoot), project.workspaceRoot];
|
|
128
|
+
return roots.filter((root, index) => roots.indexOf(root) === index);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
|
|
132
|
+
let existing: { path: string; config: TickConfig } | undefined;
|
|
133
|
+
for (const root of tickSearchRoots(project)) {
|
|
134
|
+
const result = readTickConfig(root);
|
|
135
|
+
if (result.kind === "invalid") {
|
|
136
|
+
throw new Error(`tick config invalid at ${result.path}: ${result.problem}; fix or remove it before setup`);
|
|
137
|
+
}
|
|
138
|
+
if (result.kind === "ok") {
|
|
139
|
+
existing = { path: result.path, config: result.config };
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const path = existing?.path ?? join(project.workspaceRoot, TICK_CONFIG_FILE);
|
|
145
|
+
const config: TickConfig = existing === undefined
|
|
146
|
+
? {
|
|
147
|
+
intervalSeconds: DEFAULT_TICK_INTERVAL_SECONDS,
|
|
148
|
+
armedFile: join(stateDir(), "armed"),
|
|
149
|
+
accessFile: join(telegramStateDir, "access.json"),
|
|
150
|
+
agentName: DEFAULT_FLEET_AGENT_NAME,
|
|
151
|
+
}
|
|
152
|
+
: {
|
|
153
|
+
...existing.config,
|
|
154
|
+
armedFile: existing.config.armedFile ?? join(stateDir(), "armed"),
|
|
155
|
+
accessFile: existing.config.accessFile ?? join(telegramStateDir, "access.json"),
|
|
156
|
+
};
|
|
157
|
+
const content = `${JSON.stringify(config, null, 2)}\n`;
|
|
158
|
+
return { path, action: actionFor(path, content), content, value: config };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function planHostRuntime(
|
|
162
|
+
project: ProjectConfig,
|
|
163
|
+
caps: Caps,
|
|
164
|
+
telegramStateDir: string,
|
|
165
|
+
runtime: ServiceRuntime = defaultServiceRuntime(telegramStateDir),
|
|
166
|
+
): HostRuntimePlan {
|
|
167
|
+
const servicePath = join(stateDir(), STAGED_SERVICE_NAME);
|
|
168
|
+
const serviceContent = renderDaemonService(project, caps, runtime);
|
|
169
|
+
const service: PlannedWrite<string> = {
|
|
170
|
+
path: servicePath,
|
|
171
|
+
action: actionFor(servicePath, serviceContent),
|
|
172
|
+
content: serviceContent,
|
|
173
|
+
value: serviceContent,
|
|
174
|
+
};
|
|
175
|
+
const installedPath = join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
|
|
176
|
+
return {
|
|
177
|
+
service,
|
|
178
|
+
...(project.escalation.orchestrator === "external"
|
|
179
|
+
? { tick: planTick(project, telegramStateDir) }
|
|
180
|
+
: {}),
|
|
181
|
+
installCommands: [
|
|
182
|
+
`sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
|
|
183
|
+
"sudo systemctl daemon-reload",
|
|
184
|
+
`sudo systemctl enable ${STAGED_SERVICE_NAME}`,
|
|
185
|
+
`sudo systemctl restart ${STAGED_SERVICE_NAME}`,
|
|
186
|
+
],
|
|
187
|
+
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
192
|
+
const lines = [
|
|
193
|
+
"host runtime",
|
|
194
|
+
` service ${plan.service.action} ${plan.service.path}`,
|
|
195
|
+
` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
|
|
196
|
+
];
|
|
197
|
+
if (plan.tick !== undefined) {
|
|
198
|
+
lines.push(
|
|
199
|
+
` heartbeat ${plan.tick.action} ${plan.tick.path}`,
|
|
200
|
+
` interval ${plan.tick.value.intervalSeconds}s`,
|
|
201
|
+
` arm gate ${plan.tick.value.armedFile}`,
|
|
202
|
+
` channel gate ${plan.tick.value.accessFile}`,
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
lines.push(" heartbeat embedded orchestrator — no external tick config");
|
|
206
|
+
}
|
|
207
|
+
lines.push(" install staged only; the final result prints the systemd install commands");
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function atomicWrite(path: string, content: string, mode: number): void {
|
|
212
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
213
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
214
|
+
try {
|
|
215
|
+
writeFileSync(tmp, content, { mode });
|
|
216
|
+
chmodSync(tmp, mode);
|
|
217
|
+
renameSync(tmp, path);
|
|
218
|
+
chmodSync(path, mode);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
rmSync(tmp, { force: true });
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function writeHostRuntime(plan: HostRuntimePlan): string[] {
|
|
226
|
+
const written: string[] = [];
|
|
227
|
+
if (plan.service.action !== "keep") {
|
|
228
|
+
atomicWrite(plan.service.path, plan.service.content, 0o644);
|
|
229
|
+
written.push(plan.service.path);
|
|
230
|
+
}
|
|
231
|
+
if (plan.tick !== undefined && plan.tick.action !== "keep") {
|
|
232
|
+
atomicWrite(plan.tick.path, plan.tick.content, 0o600);
|
|
233
|
+
written.push(plan.tick.path);
|
|
234
|
+
}
|
|
235
|
+
return written;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface SetupSmokeResult {
|
|
239
|
+
mode: "temporary" | "existing";
|
|
240
|
+
status: StatusSnapshot;
|
|
241
|
+
daemon: DaemonRecord;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface SetupSmokeDeps {
|
|
245
|
+
paused(): boolean;
|
|
246
|
+
runOnce(project: string): Promise<void>;
|
|
247
|
+
living(): DaemonRecord | undefined;
|
|
248
|
+
health(port: number): Promise<{ ok: boolean; body?: string }>;
|
|
249
|
+
start(project: string): Promise<DaemonRecord>;
|
|
250
|
+
stop(): Promise<StopResult>;
|
|
251
|
+
status(project: string): StatusSnapshot;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const DEFAULT_SMOKE_DEPS: SetupSmokeDeps = {
|
|
255
|
+
paused: isPaused,
|
|
256
|
+
runOnce: async (project) => await runDaemon({ once: true, project }),
|
|
257
|
+
living: livingDaemon,
|
|
258
|
+
health: healthCheck,
|
|
259
|
+
start: async (project) => await startDaemon({ project }),
|
|
260
|
+
stop: stopDaemon,
|
|
261
|
+
status: statusSnapshot,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export async function runSetupSmoke(
|
|
265
|
+
project: string,
|
|
266
|
+
deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
|
|
267
|
+
): Promise<SetupSmokeResult> {
|
|
268
|
+
if (!deps.paused()) throw new Error("setup smoke requires paused dispatch");
|
|
269
|
+
await deps.runOnce(project);
|
|
270
|
+
const existing = deps.living();
|
|
271
|
+
if (existing !== undefined) {
|
|
272
|
+
const health = await deps.health(existing.port);
|
|
273
|
+
if (!health.ok) throw new Error(`existing daemon on :${existing.port} did not answer /healthz`);
|
|
274
|
+
return { mode: "existing", status: deps.status(project), daemon: existing };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const daemon = await deps.start(project);
|
|
278
|
+
try {
|
|
279
|
+
const health = await deps.health(daemon.port);
|
|
280
|
+
if (!health.ok) throw new Error(`temporary daemon on :${daemon.port} did not answer /healthz`);
|
|
281
|
+
return { mode: "temporary", status: deps.status(project), daemon };
|
|
282
|
+
} finally {
|
|
283
|
+
await deps.stop();
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -29,20 +29,22 @@ import {
|
|
|
29
29
|
POLICY_BRIEF_NAME,
|
|
30
30
|
composeOrchestrator,
|
|
31
31
|
policyPathForRoot,
|
|
32
|
+
refreshComposedBrief,
|
|
32
33
|
renderBriefTemplate,
|
|
33
|
-
writeWithBackup,
|
|
34
34
|
} from "./brief-upgrade.ts";
|
|
35
|
-
import { configPath, resolveCaps, stateDir } from "./config.ts";
|
|
35
|
+
import { configPath, resolveCaps, resolveReleasePolicy, stateDir } from "./config.ts";
|
|
36
36
|
import { graphProjectPath, graphRepos } from "./graph.ts";
|
|
37
37
|
import {
|
|
38
38
|
CONFIG_VERSION,
|
|
39
39
|
DEFAULT_AUTHORITY,
|
|
40
40
|
DEFAULT_CAPS,
|
|
41
|
+
DEFAULT_RELEASE_POLICY,
|
|
41
42
|
DEFAULT_REPORT_SCOPE,
|
|
42
43
|
type Caps,
|
|
43
44
|
type ConductorConfig,
|
|
44
45
|
type OrchestratorMode,
|
|
45
46
|
type ProjectConfig,
|
|
47
|
+
type ReleasePolicy,
|
|
46
48
|
type ReportScope,
|
|
47
49
|
type RepoTarget,
|
|
48
50
|
} from "./types.ts";
|
|
@@ -71,6 +73,8 @@ export interface SetupAnswers {
|
|
|
71
73
|
fallbackToIssueComment: boolean;
|
|
72
74
|
/** How loud the supervising orchestrator session should be. */
|
|
73
75
|
reportScope: ReportScope;
|
|
76
|
+
/** Mechanical gate for release/deploy-shaped tool calls. */
|
|
77
|
+
releasePolicy: ReleasePolicy;
|
|
74
78
|
/**
|
|
75
79
|
* Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
|
|
76
80
|
* part of the config — the brief is the operator's file, and the conductor
|
|
@@ -131,6 +135,8 @@ export const SETUP_DEFAULTS = {
|
|
|
131
135
|
defaultBranch: "main",
|
|
132
136
|
/** Both authorities start with the human; the wizard asks to move each one. */
|
|
133
137
|
authority: DEFAULT_AUTHORITY,
|
|
138
|
+
/** Mechanical release/deploy gate stays closed until explicitly opened. */
|
|
139
|
+
releasePolicy: DEFAULT_RELEASE_POLICY,
|
|
134
140
|
/** The daemon runs its own triage session unless an operator already runs one. */
|
|
135
141
|
orchestratorMode: "embedded",
|
|
136
142
|
} as const;
|
|
@@ -465,6 +471,7 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
|
|
|
465
471
|
: {}),
|
|
466
472
|
escalation,
|
|
467
473
|
authority: { ...a.authority },
|
|
474
|
+
releasePolicy: a.releasePolicy,
|
|
468
475
|
reporting: { scope: a.reportScope },
|
|
469
476
|
// Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
|
|
470
477
|
// uninstall, and neither can land in a repo the daemon then tries to commit.
|
|
@@ -521,6 +528,7 @@ export function defaultAnswers(projectName: string): SetupAnswers {
|
|
|
521
528
|
caps: {},
|
|
522
529
|
fallbackToIssueComment: true,
|
|
523
530
|
authority: { ...SETUP_DEFAULTS.authority },
|
|
531
|
+
releasePolicy: SETUP_DEFAULTS.releasePolicy,
|
|
524
532
|
orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
|
|
525
533
|
reportScope: DEFAULT_REPORT_SCOPE,
|
|
526
534
|
writeOrchestratorBrief: false,
|
|
@@ -566,6 +574,7 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
|
|
|
566
574
|
caps: { ...p.caps },
|
|
567
575
|
fallbackToIssueComment: p.escalation.fallbackToIssueComment,
|
|
568
576
|
authority: { ...p.authority },
|
|
577
|
+
releasePolicy: resolveReleasePolicy(p),
|
|
569
578
|
orchestratorMode: p.escalation.orchestrator,
|
|
570
579
|
reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
|
|
571
580
|
writeOrchestratorBrief: false,
|
|
@@ -683,25 +692,16 @@ export function writeOrchestratorBrief(a: SetupAnswers): string {
|
|
|
683
692
|
* Returns false when POLICY.md is missing (caller should migrate or set up).
|
|
684
693
|
*/
|
|
685
694
|
export function refreshComposedBriefForProject(p: ProjectConfig): boolean {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
orchestratorPath,
|
|
692
|
-
composeOrchestrator(renderFloorForProject(p), readFileSync(policyPath, "utf8")),
|
|
693
|
-
);
|
|
694
|
-
return true;
|
|
695
|
+
return refreshComposedBrief({
|
|
696
|
+
policyPath: policyPathForProject(p),
|
|
697
|
+
orchestratorPath: briefPathForProject(p),
|
|
698
|
+
floor: renderFloorForProject(p),
|
|
699
|
+
});
|
|
695
700
|
}
|
|
696
701
|
|
|
697
702
|
/** @internal test helper — expose compose banner for assertions. */
|
|
698
703
|
export const BRIEF_COMPOSE_BANNER = COMPOSE_BANNER;
|
|
699
704
|
|
|
700
|
-
/** Backup-aware POLICY write used by migrate paths that already computed text. */
|
|
701
|
-
export function writePolicyFile(path: string, content: string): string | undefined {
|
|
702
|
-
return writeWithBackup(path, content);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
705
|
/**
|
|
706
706
|
* What can be told about an omp-telegram install without opening a socket.
|
|
707
707
|
*
|
|
@@ -910,6 +910,7 @@ export function summarisePlan(
|
|
|
910
910
|
lines.push(
|
|
911
911
|
"",
|
|
912
912
|
`authority merge=${a.authority.merge} release=${a.authority.release}`,
|
|
913
|
+
`tool gate releasePolicy=${a.releasePolicy}`,
|
|
913
914
|
delegated
|
|
914
915
|
? " the brief tells that session so, and it must spell the procedure out before acting"
|
|
915
916
|
: " humans do both; workers and the conductor stop at a green PR",
|
|
@@ -1013,7 +1014,7 @@ export const AMEND_AREAS: {
|
|
|
1013
1014
|
// The model rides with the caps because it is the other per-worker knob, and
|
|
1014
1015
|
// an area no menu offers is a setting only a full re-interview can reach.
|
|
1015
1016
|
name: "caps & worker model",
|
|
1016
|
-
asks: "concurrency, spend, turns, wall clock, attempts
|
|
1017
|
+
asks: "concurrency, spend, turns, wall clock, failed attempts, continuations — then the worker model",
|
|
1017
1018
|
describe: (p) => {
|
|
1018
1019
|
const c = resolveCaps(p, DEFAULT_CAPS);
|
|
1019
1020
|
const answered = Object.keys(p.caps).length > 0;
|
|
@@ -1022,7 +1023,9 @@ export const AMEND_AREAS: {
|
|
|
1022
1023
|
return (
|
|
1023
1024
|
`${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
|
|
1024
1025
|
`${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` +
|
|
1025
|
-
`${c.maxAttemptsPerIssue}
|
|
1026
|
+
`${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` +
|
|
1027
|
+
`${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` +
|
|
1028
|
+
`${answered ? "" : " (all defaults)"} — ` +
|
|
1026
1029
|
`${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}`
|
|
1027
1030
|
);
|
|
1028
1031
|
},
|
|
@@ -1039,8 +1042,9 @@ export const AMEND_AREAS: {
|
|
|
1039
1042
|
},
|
|
1040
1043
|
authority: {
|
|
1041
1044
|
name: "authority",
|
|
1042
|
-
asks: "who lands green PRs,
|
|
1043
|
-
describe: (p) =>
|
|
1045
|
+
asks: "who lands green PRs, who cuts releases, and whether release/deploy tools are mechanically open",
|
|
1046
|
+
describe: (p) =>
|
|
1047
|
+
`merge=${p.authority.merge}, release=${p.authority.release}, releasePolicy=${resolveReleasePolicy(p)}`,
|
|
1044
1048
|
},
|
|
1045
1049
|
escalation: {
|
|
1046
1050
|
name: "escalation & triage",
|