agent-tempo 1.7.0-beta.0 → 1.7.0-beta.1
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/dashboard/package.json +1 -1
- package/dist/cli/commands.js +44 -149
- package/dist/cli/ensure-infra.d.ts +80 -0
- package/dist/cli/ensure-infra.js +234 -0
- package/dist/cli/help-text.js +1 -0
- package/dist/cli.js +18 -0
- package/dist/http/catalog.js +10 -2
- package/dist/http/writes.d.ts +1 -1
- package/dist/http/writes.js +18 -0
- package/dist/pi/install.d.ts +45 -0
- package/dist/pi/install.js +107 -0
- package/dist/pi/mission-control/actions.d.ts +28 -0
- package/dist/pi/mission-control/actions.js +23 -0
- package/dist/pi/mission-control/extension.d.ts +35 -1
- package/dist/pi/mission-control/extension.js +112 -1
- package/package.json +7 -1
package/dashboard/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo-dashboard",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.7.0-beta.
|
|
4
|
+
"version": "1.7.0-beta.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
|
|
7
7
|
"scripts": {
|
package/dist/cli/commands.js
CHANGED
|
@@ -80,6 +80,8 @@ const agent_types_1 = require("../ensemble/agent-types");
|
|
|
80
80
|
const validation_1 = require("../utils/validation");
|
|
81
81
|
const search_attributes_1 = require("../utils/search-attributes");
|
|
82
82
|
const daemon_1 = require("./daemon");
|
|
83
|
+
// #700 P1 — infra bootstrap moved to a shared helper (CLI `up` + `/ensemble-up`).
|
|
84
|
+
const ensure_infra_1 = require("./ensure-infra");
|
|
83
85
|
const client_2 = require("../client");
|
|
84
86
|
const constants_1 = require("../constants");
|
|
85
87
|
const recall_format_1 = require("../utils/recall-format");
|
|
@@ -902,30 +904,9 @@ function initProject(dir) {
|
|
|
902
904
|
out.log(` 2. Start conductor: ${out.dim('agent-tempo conduct')}`);
|
|
903
905
|
}
|
|
904
906
|
// --- Temporal server management ---
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
const sa_preflight_1 = require("./sa-preflight");
|
|
909
|
-
async function isTemporalReachable(config) {
|
|
910
|
-
try {
|
|
911
|
-
const conn = await (0, connection_1.createTemporalConnection)(config);
|
|
912
|
-
try {
|
|
913
|
-
// Verify namespace is ready — a gRPC connection alone doesn't guarantee the server can serve requests
|
|
914
|
-
const client = new client_1.Client({ connection: conn, namespace: config.temporalNamespace || 'default' });
|
|
915
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
916
|
-
for await (const _ of client.workflow.list({ query: 'WorkflowId = "__readiness_probe__"' })) {
|
|
917
|
-
break;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
finally {
|
|
921
|
-
await conn.close();
|
|
922
|
-
}
|
|
923
|
-
return true;
|
|
924
|
-
}
|
|
925
|
-
catch {
|
|
926
|
-
return false;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
907
|
+
// #700 P1 — DEFAULT_DB_PATH, isTemporalReachable, and registerSearchAttributes
|
|
908
|
+
// MOVED to ./ensure-infra (the shared infra home; imported back below) so the CLI
|
|
909
|
+
// `up` path and the mission-control `/ensemble-up` extension call ONE bootstrap.
|
|
929
910
|
function temporalCliExists() {
|
|
930
911
|
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
931
912
|
try {
|
|
@@ -936,52 +917,6 @@ function temporalCliExists() {
|
|
|
936
917
|
return false;
|
|
937
918
|
}
|
|
938
919
|
}
|
|
939
|
-
function registerSearchAttributes(temporalAddress, namespace = 'default') {
|
|
940
|
-
let failed = 0;
|
|
941
|
-
let permissionBlocked = 0;
|
|
942
|
-
for (const attr of sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES) {
|
|
943
|
-
const r = (0, sa_preflight_1.registerSearchAttribute)(attr, temporalAddress, namespace);
|
|
944
|
-
switch (r.status) {
|
|
945
|
-
case 'created':
|
|
946
|
-
out.success(`Registered search attribute: ${attr.name}`);
|
|
947
|
-
break;
|
|
948
|
-
case 'already-exists':
|
|
949
|
-
out.dim(` ${attr.name} (already registered)`);
|
|
950
|
-
break;
|
|
951
|
-
case 'failed':
|
|
952
|
-
// A PERMISSION error (Temporal Cloud namespace API keys can't reach the
|
|
953
|
-
// operator service) means we can't tell whether the SA exists — NOT that
|
|
954
|
-
// it's missing. Don't print a scary per-attr "Failed to register" or count
|
|
955
|
-
// it as a failure; collapse to ONE soft line below and PROCEED. Reserve
|
|
956
|
-
// the per-attr warning + hard "will fail" conclusion for DEFINITIVE
|
|
957
|
-
// failures (e.g. the SQLite dev server's 10-Keyword-per-namespace cap).
|
|
958
|
-
if ((0, sa_preflight_1.isPermissionError)(r.detail)) {
|
|
959
|
-
permissionBlocked++;
|
|
960
|
-
}
|
|
961
|
-
else {
|
|
962
|
-
failed++;
|
|
963
|
-
out.warn(`Failed to register ${attr.name}: ${r.detail}`);
|
|
964
|
-
}
|
|
965
|
-
break;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
// Permission-blocked (normal on Temporal Cloud): one accurate, non-alarming
|
|
969
|
-
// line — we couldn't manage the SAs, but that doesn't mean they're missing.
|
|
970
|
-
if (permissionBlocked > 0) {
|
|
971
|
-
const saList = sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.map((a) => `${a.name}:${a.type}`).join(', ');
|
|
972
|
-
out.warn(`Couldn't verify search attributes — this credential lacks permission to manage them ` +
|
|
973
|
-
`(normal on Temporal Cloud, where search attributes are managed via the Cloud UI or tcld). ` +
|
|
974
|
-
`If workflow starts fail with "search attribute ... is not defined", create these ` +
|
|
975
|
-
`${sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.length} via the Cloud UI / tcld: ${saList}. ` +
|
|
976
|
-
`Otherwise this is safe to ignore.`);
|
|
977
|
-
}
|
|
978
|
-
// DEFINITIVE failures genuinely block — keep the hard, actionable conclusion.
|
|
979
|
-
if (failed > 0) {
|
|
980
|
-
out.warn(`${failed} search attribute${failed === 1 ? '' : 's'} not registered — ` +
|
|
981
|
-
`workflow starts will fail. Resolve the errors above before continuing.`);
|
|
982
|
-
}
|
|
983
|
-
return { failed };
|
|
984
|
-
}
|
|
985
920
|
async function server(opts) {
|
|
986
921
|
const config = (0, config_1.getConfig)(opts);
|
|
987
922
|
if (!temporalCliExists()) {
|
|
@@ -990,11 +925,11 @@ async function server(opts) {
|
|
|
990
925
|
process.exit(1);
|
|
991
926
|
}
|
|
992
927
|
// Check if already running
|
|
993
|
-
const alreadyRunning = await isTemporalReachable(config);
|
|
928
|
+
const alreadyRunning = await (0, ensure_infra_1.isTemporalReachable)(config);
|
|
994
929
|
if (alreadyRunning) {
|
|
995
930
|
out.success(`Temporal already running at ${config.temporalAddress}`);
|
|
996
931
|
out.log(' Registering search attributes...');
|
|
997
|
-
registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
|
|
932
|
+
(0, ensure_infra_1.registerSearchAttributes)(config.temporalAddress, config.temporalNamespace);
|
|
998
933
|
return;
|
|
999
934
|
}
|
|
1000
935
|
// Ensure data directory exists
|
|
@@ -1003,10 +938,10 @@ async function server(opts) {
|
|
|
1003
938
|
const args = [
|
|
1004
939
|
'server', 'start-dev',
|
|
1005
940
|
'--port', port,
|
|
1006
|
-
'--db-filename', DEFAULT_DB_PATH,
|
|
941
|
+
'--db-filename', ensure_infra_1.DEFAULT_DB_PATH,
|
|
1007
942
|
];
|
|
1008
943
|
out.log(`Starting Temporal dev server on port ${port}...`);
|
|
1009
|
-
out.log(` Data: ${out.dim(DEFAULT_DB_PATH)}`);
|
|
944
|
+
out.log(` Data: ${out.dim(ensure_infra_1.DEFAULT_DB_PATH)}`);
|
|
1010
945
|
if (opts.background) {
|
|
1011
946
|
const child = (0, child_process_1.spawn)('temporal', args, {
|
|
1012
947
|
detached: true,
|
|
@@ -1017,7 +952,7 @@ async function server(opts) {
|
|
|
1017
952
|
// Wait for it to be ready
|
|
1018
953
|
for (let i = 0; i < 20; i++) {
|
|
1019
954
|
await new Promise(r => setTimeout(r, 500));
|
|
1020
|
-
if (await isTemporalReachable(config))
|
|
955
|
+
if (await (0, ensure_infra_1.isTemporalReachable)(config))
|
|
1021
956
|
break;
|
|
1022
957
|
}
|
|
1023
958
|
}
|
|
@@ -1030,10 +965,10 @@ async function server(opts) {
|
|
|
1030
965
|
const waitForReady = async () => {
|
|
1031
966
|
for (let i = 0; i < 20; i++) {
|
|
1032
967
|
await new Promise(r => setTimeout(r, 500));
|
|
1033
|
-
if (await isTemporalReachable(config)) {
|
|
968
|
+
if (await (0, ensure_infra_1.isTemporalReachable)(config)) {
|
|
1034
969
|
out.success(`Temporal running at ${config.temporalAddress}`);
|
|
1035
970
|
out.log(' Registering search attributes...');
|
|
1036
|
-
registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
|
|
971
|
+
(0, ensure_infra_1.registerSearchAttributes)(config.temporalAddress, config.temporalNamespace);
|
|
1037
972
|
out.log(`\n ${out.dim('Press Ctrl+C to stop')}\n`);
|
|
1038
973
|
return;
|
|
1039
974
|
}
|
|
@@ -1059,7 +994,7 @@ async function server(opts) {
|
|
|
1059
994
|
// Register search attributes (for background mode — foreground does it inline)
|
|
1060
995
|
if (opts.background) {
|
|
1061
996
|
out.log(' Registering search attributes...');
|
|
1062
|
-
registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
|
|
997
|
+
(0, ensure_infra_1.registerSearchAttributes)(config.temporalAddress, config.temporalNamespace);
|
|
1063
998
|
out.success('Temporal ready');
|
|
1064
999
|
}
|
|
1065
1000
|
}
|
|
@@ -1077,75 +1012,35 @@ async function up(opts) {
|
|
|
1077
1012
|
process.exit(1);
|
|
1078
1013
|
}
|
|
1079
1014
|
out.check('temporal CLI installed', true);
|
|
1080
|
-
//
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
if (!ready) {
|
|
1105
|
-
out.error('Temporal did not start within 10 seconds');
|
|
1106
|
-
process.exit(1);
|
|
1107
|
-
}
|
|
1108
|
-
out.check('Temporal started', true, `pid ${child.pid}, data in ~/.agent-tempo/`);
|
|
1109
|
-
}
|
|
1110
|
-
// Step 3: Register search attributes
|
|
1111
|
-
registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
|
|
1112
|
-
// Step 3.5: Install shipped agent types to ~/.claude/agents/ (if not already there)
|
|
1113
|
-
const userAgentsDir = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'agents');
|
|
1114
|
-
const shippedAgentsPath = (0, path_1.join)(PACKAGE_ROOT, 'examples', 'agents');
|
|
1115
|
-
if ((0, fs_1.existsSync)(shippedAgentsPath)) {
|
|
1116
|
-
(0, fs_1.mkdirSync)(userAgentsDir, { recursive: true });
|
|
1117
|
-
const shipped = (0, fs_1.readdirSync)(shippedAgentsPath).filter(f => f.endsWith('.md'));
|
|
1118
|
-
let installed = 0;
|
|
1119
|
-
for (const file of shipped) {
|
|
1120
|
-
const dest = (0, path_1.join)(userAgentsDir, file);
|
|
1121
|
-
if (!(0, fs_1.existsSync)(dest)) {
|
|
1122
|
-
(0, fs_1.copyFileSync)((0, path_1.join)(shippedAgentsPath, file), dest);
|
|
1123
|
-
installed++;
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
if (installed > 0) {
|
|
1127
|
-
out.success(`Installed ${installed} agent type${installed !== 1 ? 's' : ''} to ~/.claude/agents/`);
|
|
1128
|
-
}
|
|
1129
|
-
else {
|
|
1130
|
-
out.dim(` Agent types already installed (${shipped.length} in ~/.claude/agents/)`);
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
// Step 3.7: Start worker daemon if not already running
|
|
1134
|
-
if ((0, daemon_1.isDaemonRunning)()) {
|
|
1135
|
-
const daemonStatus = (0, daemon_1.getDaemonStatus)();
|
|
1136
|
-
out.check('Worker daemon running', true, `pid ${daemonStatus.pid}`);
|
|
1015
|
+
// Steps 2–3.7: bring infra up via the SHARED ensureInfra — the same path
|
|
1016
|
+
// `/ensemble-up` uses, so CLI + extension can't drift (#700 P1). Order:
|
|
1017
|
+
// Temporal → search attributes → agent types → daemon (SA BEFORE daemon — the
|
|
1018
|
+
// daemon refuses to boot without them). `onStep` renders the same out.check
|
|
1019
|
+
// sequence `up()` showed before the extraction.
|
|
1020
|
+
try {
|
|
1021
|
+
await (0, ensure_infra_1.ensureInfra)({
|
|
1022
|
+
config,
|
|
1023
|
+
onStep: (p) => {
|
|
1024
|
+
if (p.step === 'temporal') {
|
|
1025
|
+
out.check(p.status === 'ok' ? 'Temporal running' : 'Temporal started', true, p.detail);
|
|
1026
|
+
}
|
|
1027
|
+
else if (p.step === 'agent-types') {
|
|
1028
|
+
if (p.detail?.startsWith('installed'))
|
|
1029
|
+
out.success(`Agent types: ${p.detail} → ~/.claude/agents/`);
|
|
1030
|
+
else if (p.detail)
|
|
1031
|
+
out.dim(` Agent types already installed (${p.detail})`);
|
|
1032
|
+
}
|
|
1033
|
+
else if (p.step === 'daemon') {
|
|
1034
|
+
out.check(p.status === 'ok' ? 'Worker daemon running' : 'Worker daemon started', true, p.detail);
|
|
1035
|
+
}
|
|
1036
|
+
// 'search-attributes' logs per-attribute internally via registerSearchAttributes.
|
|
1037
|
+
},
|
|
1038
|
+
});
|
|
1137
1039
|
}
|
|
1138
|
-
|
|
1139
|
-
out.
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
out.check('Worker daemon started', true, `pid ${daemonPid}`);
|
|
1143
|
-
}
|
|
1144
|
-
catch (err) {
|
|
1145
|
-
out.error(`Failed to start worker daemon: ${err.message || err}`);
|
|
1146
|
-
out.log(` ${out.dim('You can start it manually: agent-tempo daemon start')}`);
|
|
1147
|
-
process.exit(1);
|
|
1148
|
-
}
|
|
1040
|
+
catch (err) {
|
|
1041
|
+
out.error(`Infra startup failed: ${err?.message || err}`);
|
|
1042
|
+
out.log(` ${out.dim('You can start it manually: agent-tempo daemon start')}`);
|
|
1043
|
+
process.exit(1);
|
|
1149
1044
|
}
|
|
1150
1045
|
// Step 4: Register MCP server if needed
|
|
1151
1046
|
if ((0, mcp_1.isMcpConfigured)(process.cwd())) {
|
|
@@ -1628,14 +1523,14 @@ function stopTemporalServer(opts) {
|
|
|
1628
1523
|
async function startTemporalForDestroy(config, deps = {}) {
|
|
1629
1524
|
const attempts = deps.attempts ?? 20;
|
|
1630
1525
|
const pollDelayMs = deps.pollDelayMs ?? 500;
|
|
1631
|
-
const isReachable = deps.isReachable ?? (() => isTemporalReachable(config));
|
|
1526
|
+
const isReachable = deps.isReachable ?? (() => (0, ensure_infra_1.isTemporalReachable)(config));
|
|
1632
1527
|
const spawn = deps.spawn ?? (() => {
|
|
1633
1528
|
(0, fs_1.mkdirSync)(config_1.AGENT_TEMPO_HOME, { recursive: true });
|
|
1634
1529
|
const port = config.temporalAddress.split(':')[1] || '7233';
|
|
1635
1530
|
return (0, child_process_1.spawn)('temporal', [
|
|
1636
1531
|
'server', 'start-dev',
|
|
1637
1532
|
'--port', port,
|
|
1638
|
-
'--db-filename', DEFAULT_DB_PATH,
|
|
1533
|
+
'--db-filename', ensure_infra_1.DEFAULT_DB_PATH,
|
|
1639
1534
|
], { detached: true, stdio: 'ignore' });
|
|
1640
1535
|
});
|
|
1641
1536
|
const child = spawn();
|
|
@@ -1662,7 +1557,7 @@ async function down(opts) {
|
|
|
1662
1557
|
: ` Stopping daemon + Temporal. Workflows stay parked for the next ${out.dim('agent-tempo up')}.`);
|
|
1663
1558
|
// Step 1 (destroy mode only): enumerate + terminate workflows across every
|
|
1664
1559
|
// ensemble, after a typed confirmation showing the user what's at stake.
|
|
1665
|
-
let temporalUp = await isTemporalReachable(config);
|
|
1560
|
+
let temporalUp = await (0, ensure_infra_1.isTemporalReachable)(config);
|
|
1666
1561
|
// `--destroy` can only terminate workflows while Temporal is reachable.
|
|
1667
1562
|
// Workflow state lives durably on disk in ~/.agent-tempo/, so if Temporal
|
|
1668
1563
|
// happens to be down when the user runs `down --destroy`, skipping the
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Config } from '../config';
|
|
2
|
+
/** SQLite db file for the bundled Temporal dev server (same path `up()` uses). */
|
|
3
|
+
export declare const DEFAULT_DB_PATH: string;
|
|
4
|
+
/**
|
|
5
|
+
* Probe whether Temporal is reachable AND its namespace can serve requests (a
|
|
6
|
+
* gRPC connect alone doesn't prove readiness — issue a cheap visibility query).
|
|
7
|
+
* MOVED from commands.ts; the CLI's other temporal-start sites import it back.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isTemporalReachable(config: {
|
|
10
|
+
temporalAddress: string;
|
|
11
|
+
temporalNamespace?: string;
|
|
12
|
+
temporalApiKey?: string;
|
|
13
|
+
temporalTlsCertPath?: string;
|
|
14
|
+
temporalTlsKeyPath?: string;
|
|
15
|
+
}): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Register all {@link REQUIRED_SEARCH_ATTRIBUTES}. Permission errors (Temporal
|
|
18
|
+
* Cloud namespace keys can't reach the operator service) are NOT counted as
|
|
19
|
+
* failures — collapsed to one soft line (#686). Definitive failures (e.g. the
|
|
20
|
+
* SQLite 10-Keyword cap) keep the hard "will fail" conclusion. MOVED from
|
|
21
|
+
* commands.ts; re-imported there.
|
|
22
|
+
*/
|
|
23
|
+
export declare function registerSearchAttributes(temporalAddress: string, namespace?: string): {
|
|
24
|
+
failed: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Copy the shipped agent-type definitions into `~/.claude/agents/` if absent, so
|
|
28
|
+
* recruiting TYPED lineup players resolves. Idempotent (skips existing). MOVED
|
|
29
|
+
* from `up()` step 3.5 — folded into ensureInfra so `/ensemble-up` gets it too.
|
|
30
|
+
*/
|
|
31
|
+
export declare function installAgentTypes(): {
|
|
32
|
+
installed: number;
|
|
33
|
+
total: number;
|
|
34
|
+
};
|
|
35
|
+
/** A bootstrap-progress event, surfaced to the caller's UI (out.check / ctx.ui). */
|
|
36
|
+
export interface InfraProgress {
|
|
37
|
+
step: 'temporal' | 'search-attributes' | 'agent-types' | 'daemon';
|
|
38
|
+
status: 'ok' | 'started' | 'done';
|
|
39
|
+
detail?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface EnsureInfraResult {
|
|
42
|
+
config: Config;
|
|
43
|
+
temporal: 'up' | 'started';
|
|
44
|
+
daemon: 'up' | 'started';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Injectable seam (tests only). Defaults to the real functions; a test overrides
|
|
48
|
+
* to assert ordering (SA-before-daemon), connect-only, and explicit-config
|
|
49
|
+
* propagation without spawning anything.
|
|
50
|
+
*/
|
|
51
|
+
export interface EnsureInfraDeps {
|
|
52
|
+
isTemporalReachable: (config: Config) => Promise<boolean>;
|
|
53
|
+
startTemporalDevServer: (config: Config) => Promise<{
|
|
54
|
+
pid?: number;
|
|
55
|
+
}>;
|
|
56
|
+
registerSearchAttributes: (addr: string, ns: string) => {
|
|
57
|
+
failed: number;
|
|
58
|
+
};
|
|
59
|
+
installAgentTypes: () => {
|
|
60
|
+
installed: number;
|
|
61
|
+
total: number;
|
|
62
|
+
};
|
|
63
|
+
isDaemonRunning: () => boolean;
|
|
64
|
+
startDaemon: (config: Config) => Promise<number>;
|
|
65
|
+
getDaemonStatus: () => {
|
|
66
|
+
pid?: number;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Bring local infra up (CONNECT-ONLY — never registers MCP). Order: Temporal →
|
|
71
|
+
* search attributes → agent types → daemon (SA MUST precede the daemon, which
|
|
72
|
+
* refuses to boot without them). `onStep` lets the caller render progress
|
|
73
|
+
* (`up()` → out.check; the Pi extension → ctx.ui.notify). Throws if Temporal
|
|
74
|
+
* can't be reached/started; the caller decides how to surface that.
|
|
75
|
+
*/
|
|
76
|
+
export declare function ensureInfra(opts?: {
|
|
77
|
+
config?: Config;
|
|
78
|
+
onStep?: (p: InfraProgress) => void;
|
|
79
|
+
deps?: Partial<EnsureInfraDeps>;
|
|
80
|
+
}): Promise<EnsureInfraResult>;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_DB_PATH = void 0;
|
|
37
|
+
exports.isTemporalReachable = isTemporalReachable;
|
|
38
|
+
exports.registerSearchAttributes = registerSearchAttributes;
|
|
39
|
+
exports.installAgentTypes = installAgentTypes;
|
|
40
|
+
exports.ensureInfra = ensureInfra;
|
|
41
|
+
/**
|
|
42
|
+
* Shared infrastructure bootstrap (#700 P1 / command-center).
|
|
43
|
+
*
|
|
44
|
+
* `ensureInfra()` is the ONE path that brings the local agent-tempo infra up —
|
|
45
|
+
* Temporal dev server, the AgentTempo* search attributes, the shipped agent
|
|
46
|
+
* types, and the worker daemon — so BOTH the CLI (`agent-tempo up`) and the
|
|
47
|
+
* mission-control Pi extension (`/ensemble-up`) call identical logic and can't
|
|
48
|
+
* drift. Extracted from `up()` (the Temporal-start + SA + agent-types + daemon
|
|
49
|
+
* steps) per the command-center design §2/§7A.
|
|
50
|
+
*
|
|
51
|
+
* CONNECT-ONLY: this never registers the MCP server (`init()`) — that's a
|
|
52
|
+
* CLI-only step and is explicitly cut from the bootstrap path (the Pi extension
|
|
53
|
+
* registers tools natively, no MCP).
|
|
54
|
+
*
|
|
55
|
+
* ORDERING IS LOAD-BEARING: search attributes MUST be registered BEFORE the
|
|
56
|
+
* daemon starts — the daemon refuses to boot (process.exit) if the SAs are
|
|
57
|
+
* missing (see `sa-preflight` + `daemon.ts` boot gate). `up()` did SA at step 3,
|
|
58
|
+
* daemon at step 3.7; ensureInfra preserves that order.
|
|
59
|
+
*
|
|
60
|
+
* `isTemporalReachable` + `registerSearchAttributes` MOVED here from
|
|
61
|
+
* `commands.ts` (their natural infra home); `commands.ts` re-imports them. This
|
|
62
|
+
* keeps ensure-infra a LEAF (imports only connection / sa-preflight / daemon /
|
|
63
|
+
* config / output) — no `commands.ts ↔ ensure-infra` cycle.
|
|
64
|
+
*/
|
|
65
|
+
const child_process_1 = require("child_process");
|
|
66
|
+
const fs_1 = require("fs");
|
|
67
|
+
const path_1 = require("path");
|
|
68
|
+
const os_1 = require("os");
|
|
69
|
+
const client_1 = require("@temporalio/client");
|
|
70
|
+
const config_1 = require("../config");
|
|
71
|
+
const connection_1 = require("../connection");
|
|
72
|
+
const sa_preflight_1 = require("./sa-preflight");
|
|
73
|
+
const daemon_1 = require("./daemon");
|
|
74
|
+
const out = __importStar(require("./output"));
|
|
75
|
+
/** SQLite db file for the bundled Temporal dev server (same path `up()` uses). */
|
|
76
|
+
exports.DEFAULT_DB_PATH = (0, path_1.join)(config_1.AGENT_TEMPO_HOME, 'temporal-data.db');
|
|
77
|
+
/** Package root (dist/cli/ensure-infra.js → dist → pkgRoot) — holds examples/agents. */
|
|
78
|
+
const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
|
|
79
|
+
/**
|
|
80
|
+
* Probe whether Temporal is reachable AND its namespace can serve requests (a
|
|
81
|
+
* gRPC connect alone doesn't prove readiness — issue a cheap visibility query).
|
|
82
|
+
* MOVED from commands.ts; the CLI's other temporal-start sites import it back.
|
|
83
|
+
*/
|
|
84
|
+
async function isTemporalReachable(config) {
|
|
85
|
+
try {
|
|
86
|
+
const conn = await (0, connection_1.createTemporalConnection)(config);
|
|
87
|
+
try {
|
|
88
|
+
const client = new client_1.Client({ connection: conn, namespace: config.temporalNamespace || 'default' });
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
90
|
+
for await (const _ of client.workflow.list({ query: 'WorkflowId = "__readiness_probe__"' })) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await conn.close();
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Register all {@link REQUIRED_SEARCH_ATTRIBUTES}. Permission errors (Temporal
|
|
105
|
+
* Cloud namespace keys can't reach the operator service) are NOT counted as
|
|
106
|
+
* failures — collapsed to one soft line (#686). Definitive failures (e.g. the
|
|
107
|
+
* SQLite 10-Keyword cap) keep the hard "will fail" conclusion. MOVED from
|
|
108
|
+
* commands.ts; re-imported there.
|
|
109
|
+
*/
|
|
110
|
+
function registerSearchAttributes(temporalAddress, namespace = 'default') {
|
|
111
|
+
let failed = 0;
|
|
112
|
+
let permissionBlocked = 0;
|
|
113
|
+
for (const attr of sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES) {
|
|
114
|
+
const r = (0, sa_preflight_1.registerSearchAttribute)(attr, temporalAddress, namespace);
|
|
115
|
+
switch (r.status) {
|
|
116
|
+
case 'created':
|
|
117
|
+
out.success(`Registered search attribute: ${attr.name}`);
|
|
118
|
+
break;
|
|
119
|
+
case 'already-exists':
|
|
120
|
+
out.dim(` ${attr.name} (already registered)`);
|
|
121
|
+
break;
|
|
122
|
+
case 'failed':
|
|
123
|
+
if ((0, sa_preflight_1.isPermissionError)(r.detail)) {
|
|
124
|
+
permissionBlocked++;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
failed++;
|
|
128
|
+
out.warn(`Failed to register ${attr.name}: ${r.detail}`);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (permissionBlocked > 0) {
|
|
134
|
+
const saList = sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.map((a) => `${a.name}:${a.type}`).join(', ');
|
|
135
|
+
out.warn(`Couldn't verify search attributes — this credential lacks permission to manage them ` +
|
|
136
|
+
`(normal on Temporal Cloud, where search attributes are managed via the Cloud UI or tcld). ` +
|
|
137
|
+
`If workflow starts fail with "search attribute ... is not defined", create these ` +
|
|
138
|
+
`${sa_preflight_1.REQUIRED_SEARCH_ATTRIBUTES.length} via the Cloud UI / tcld: ${saList}. ` +
|
|
139
|
+
`Otherwise this is safe to ignore.`);
|
|
140
|
+
}
|
|
141
|
+
if (failed > 0) {
|
|
142
|
+
out.warn(`${failed} search attribute${failed === 1 ? '' : 's'} not registered — ` +
|
|
143
|
+
`workflow starts will fail. Resolve the errors above before continuing.`);
|
|
144
|
+
}
|
|
145
|
+
return { failed };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Copy the shipped agent-type definitions into `~/.claude/agents/` if absent, so
|
|
149
|
+
* recruiting TYPED lineup players resolves. Idempotent (skips existing). MOVED
|
|
150
|
+
* from `up()` step 3.5 — folded into ensureInfra so `/ensemble-up` gets it too.
|
|
151
|
+
*/
|
|
152
|
+
function installAgentTypes() {
|
|
153
|
+
const userAgentsDir = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'agents');
|
|
154
|
+
const shippedAgentsPath = (0, path_1.join)(PACKAGE_ROOT, 'examples', 'agents');
|
|
155
|
+
if (!(0, fs_1.existsSync)(shippedAgentsPath))
|
|
156
|
+
return { installed: 0, total: 0 };
|
|
157
|
+
(0, fs_1.mkdirSync)(userAgentsDir, { recursive: true });
|
|
158
|
+
const shipped = (0, fs_1.readdirSync)(shippedAgentsPath).filter((f) => f.endsWith('.md'));
|
|
159
|
+
let installed = 0;
|
|
160
|
+
for (const file of shipped) {
|
|
161
|
+
const dest = (0, path_1.join)(userAgentsDir, file);
|
|
162
|
+
if (!(0, fs_1.existsSync)(dest)) {
|
|
163
|
+
(0, fs_1.copyFileSync)((0, path_1.join)(shippedAgentsPath, file), dest);
|
|
164
|
+
installed++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { installed, total: shipped.length };
|
|
168
|
+
}
|
|
169
|
+
/** Default Temporal dev-server start (detached spawn + readiness poll). Throws on timeout. */
|
|
170
|
+
async function startTemporalDevServer(config) {
|
|
171
|
+
(0, fs_1.mkdirSync)(config_1.AGENT_TEMPO_HOME, { recursive: true });
|
|
172
|
+
const port = config.temporalAddress.split(':')[1] || '7233';
|
|
173
|
+
const child = (0, child_process_1.spawn)('temporal', ['server', 'start-dev', '--port', port, '--db-filename', exports.DEFAULT_DB_PATH], {
|
|
174
|
+
detached: true,
|
|
175
|
+
stdio: 'ignore',
|
|
176
|
+
});
|
|
177
|
+
child.unref();
|
|
178
|
+
for (let i = 0; i < 20; i++) {
|
|
179
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
180
|
+
if (await isTemporalReachable(config))
|
|
181
|
+
return { pid: child.pid };
|
|
182
|
+
}
|
|
183
|
+
throw new Error('Temporal did not start within 10 seconds');
|
|
184
|
+
}
|
|
185
|
+
const defaultDeps = {
|
|
186
|
+
isTemporalReachable,
|
|
187
|
+
startTemporalDevServer,
|
|
188
|
+
registerSearchAttributes,
|
|
189
|
+
installAgentTypes,
|
|
190
|
+
isDaemonRunning: daemon_1.isDaemonRunning,
|
|
191
|
+
startDaemon: daemon_1.startDaemon,
|
|
192
|
+
getDaemonStatus: daemon_1.getDaemonStatus,
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* Bring local infra up (CONNECT-ONLY — never registers MCP). Order: Temporal →
|
|
196
|
+
* search attributes → agent types → daemon (SA MUST precede the daemon, which
|
|
197
|
+
* refuses to boot without them). `onStep` lets the caller render progress
|
|
198
|
+
* (`up()` → out.check; the Pi extension → ctx.ui.notify). Throws if Temporal
|
|
199
|
+
* can't be reached/started; the caller decides how to surface that.
|
|
200
|
+
*/
|
|
201
|
+
async function ensureInfra(opts = {}) {
|
|
202
|
+
const config = opts.config ?? (0, config_1.getConfig)();
|
|
203
|
+
const onStep = opts.onStep ?? (() => { });
|
|
204
|
+
const d = { ...defaultDeps, ...opts.deps };
|
|
205
|
+
// 1. Temporal — reachable, or auto-start the dev server.
|
|
206
|
+
let temporal;
|
|
207
|
+
if (await d.isTemporalReachable(config)) {
|
|
208
|
+
temporal = 'up';
|
|
209
|
+
onStep({ step: 'temporal', status: 'ok', detail: config.temporalAddress });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
const { pid } = await d.startTemporalDevServer(config);
|
|
213
|
+
temporal = 'started';
|
|
214
|
+
onStep({ step: 'temporal', status: 'started', detail: pid != null ? `pid ${pid}` : undefined });
|
|
215
|
+
}
|
|
216
|
+
// 2. Search attributes — BEFORE the daemon (it refuses to boot without them).
|
|
217
|
+
const sa = d.registerSearchAttributes(config.temporalAddress, config.temporalNamespace);
|
|
218
|
+
onStep({ step: 'search-attributes', status: 'done', detail: sa.failed > 0 ? `${sa.failed} failed` : undefined });
|
|
219
|
+
// 3. Agent types — so recruiting typed lineup players resolves.
|
|
220
|
+
const at = d.installAgentTypes();
|
|
221
|
+
onStep({ step: 'agent-types', status: 'done', detail: at.installed > 0 ? `installed ${at.installed}` : `${at.total} present` });
|
|
222
|
+
// 4. Worker daemon (detached; reused verbatim from the CLI path).
|
|
223
|
+
let daemon;
|
|
224
|
+
if (d.isDaemonRunning()) {
|
|
225
|
+
daemon = 'up';
|
|
226
|
+
onStep({ step: 'daemon', status: 'ok', detail: d.getDaemonStatus().pid != null ? `pid ${d.getDaemonStatus().pid}` : undefined });
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const pid = await d.startDaemon(config);
|
|
230
|
+
daemon = 'started';
|
|
231
|
+
onStep({ step: 'daemon', status: 'started', detail: `pid ${pid}` });
|
|
232
|
+
}
|
|
233
|
+
return { config, temporal, daemon };
|
|
234
|
+
}
|
package/dist/cli/help-text.js
CHANGED
|
@@ -89,6 +89,7 @@ ${out.bold('Commands:')}
|
|
|
89
89
|
${out.cyan('upgrade')} [version] Upgrade agent-tempo to latest (or specific version)
|
|
90
90
|
${out.cyan('config')} Configure Temporal connection settings
|
|
91
91
|
${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
|
|
92
|
+
${out.cyan('install-pi')} Install the Pi extensions into Pi settings (or --project for .pi/settings.json)
|
|
92
93
|
${out.cyan('preflight')} Run preflight checks only
|
|
93
94
|
${out.cyan('help')} Show this help message
|
|
94
95
|
|
package/dist/cli.js
CHANGED
|
@@ -616,6 +616,24 @@ async function main() {
|
|
|
616
616
|
case 'init':
|
|
617
617
|
await init({ dir: args.dir, project: args.project });
|
|
618
618
|
break;
|
|
619
|
+
case 'install-pi': {
|
|
620
|
+
// #700 P1 — install agent-tempo's two Pi extensions (player +
|
|
621
|
+
// command-center) the normal `.pi` way: an idempotent, install-by-reference
|
|
622
|
+
// merge of their absolute dist paths into Pi's settings.json. Pure fs
|
|
623
|
+
// (no Temporal) — dynamic-imported so it doesn't bloat the crash-proof
|
|
624
|
+
// top-level module graph. `--project` writes `.pi/settings.json` instead
|
|
625
|
+
// of the global `~/.pi/agent/settings.json`.
|
|
626
|
+
const { installPiExtensions } = await Promise.resolve().then(() => __importStar(require('./pi/install')));
|
|
627
|
+
const result = installPiExtensions({ project: args.project });
|
|
628
|
+
out.success(`Pi extensions installed → ${result.settingsPath}`);
|
|
629
|
+
for (const p of result.added)
|
|
630
|
+
out.log(` ${out.green('+')} ${p}`);
|
|
631
|
+
for (const p of result.alreadyPresent)
|
|
632
|
+
out.log(out.dim(` · ${p} (already installed)`));
|
|
633
|
+
out.log('');
|
|
634
|
+
out.log(' Restart `pi` to load the agent-tempo extensions.');
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
619
637
|
case 'migrate-from-claude-tempo': {
|
|
620
638
|
// PR-2 of the v1.0 rebrand — one-shot copy of `~/.agent-tempo/` →
|
|
621
639
|
// `~/.agent-tempo/`. Crash-proof (no Temporal deps). The `--dev`
|
package/dist/http/catalog.js
CHANGED
|
@@ -77,7 +77,7 @@ async function handleCreateEnsemble(req, res, client) {
|
|
|
77
77
|
const parsed = parseBody(body);
|
|
78
78
|
if ('error' in parsed)
|
|
79
79
|
return (0, responses_1.errorResponse)(res, 400, parsed.error);
|
|
80
|
-
const { name, lineup, host, startMode, conductorInstructions } = parsed.body;
|
|
80
|
+
const { name, lineup, host, startMode, conductorInstructions, conductorAgent: conductorAgentOverride } = parsed.body;
|
|
81
81
|
// 409 — ensemble already exists. `listEnsembles()` returns live
|
|
82
82
|
// ensembles by Temporal workflow; matches what the dashboard's
|
|
83
83
|
// `/v1/ensembles` GET sees. Note: TOCTOU between this read and the
|
|
@@ -127,7 +127,11 @@ async function handleCreateEnsemble(req, res, client) {
|
|
|
127
127
|
// spawn target either way.
|
|
128
128
|
const conductorBlock = resolved?.conductor;
|
|
129
129
|
const conductorName = conductorBlock?.name ?? 'conductor';
|
|
130
|
-
|
|
130
|
+
// #700 P1: a body-supplied `conductorAgent` wins; fall back to the lineup's
|
|
131
|
+
// conductor agent. Each candidate is run through `pickAgent` (allowed-only),
|
|
132
|
+
// so an invalid override is dropped and we still fall back to the lineup —
|
|
133
|
+
// and OMITTING `conductorAgent` reproduces today's behavior exactly.
|
|
134
|
+
const conductorAgent = pickAgent(conductorAgentOverride, allowed) ?? pickAgent(conductorBlock?.agent, allowed);
|
|
131
135
|
try {
|
|
132
136
|
await client.recruit(name, {
|
|
133
137
|
name: conductorName,
|
|
@@ -198,6 +202,9 @@ function parseBody(body) {
|
|
|
198
202
|
return { error: { error: 'invalid-start-mode', allowed: ALLOWED_START_MODES } };
|
|
199
203
|
}
|
|
200
204
|
const conductorInstructions = (0, body_1.stringField)(body, 'conductorInstructions', { requireNonEmpty: true });
|
|
205
|
+
// #700 P1 — additive optional override (validated against allowed agents at
|
|
206
|
+
// recruit time via pickAgent, like the lineup's conductor agent).
|
|
207
|
+
const conductorAgent = (0, body_1.stringField)(body, 'conductorAgent', { requireNonEmpty: true });
|
|
201
208
|
// Lightweight host-name shape check (matches the daemon's existing
|
|
202
209
|
// hostname rules used by recruit). Skips when omitted.
|
|
203
210
|
if (host !== undefined && (0, validation_1.validatePlayerName)(host) !== null) {
|
|
@@ -210,6 +217,7 @@ function parseBody(body) {
|
|
|
210
217
|
...(host !== undefined && { host }),
|
|
211
218
|
...(startMode !== undefined && { startMode: startMode }),
|
|
212
219
|
...(conductorInstructions !== undefined && { conductorInstructions }),
|
|
220
|
+
...(conductorAgent !== undefined && { conductorAgent }),
|
|
213
221
|
},
|
|
214
222
|
};
|
|
215
223
|
}
|
package/dist/http/writes.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export { WRITE_BODY_MAX };
|
|
|
42
42
|
* mutations. Bodies are uniform `{ playerId, reason? }` (plus per-action
|
|
43
43
|
* extras); the ensemble lives in the URL.
|
|
44
44
|
*/
|
|
45
|
-
export declare const WRITE_ACTIONS: readonly ["cue", "pause", "play", "release", "recruit", "restart", "reset", "destroy", "detach", "recall"];
|
|
45
|
+
export declare const WRITE_ACTIONS: readonly ["cue", "pause", "play", "release", "recruit", "restart", "reset", "destroy", "detach", "recall", "shutdown"];
|
|
46
46
|
export type WriteAction = (typeof WRITE_ACTIONS)[number];
|
|
47
47
|
/** Type guard — narrows an arbitrary string to a known `WriteAction`. */
|
|
48
48
|
export declare function isWriteAction(s: string): s is WriteAction;
|
package/dist/http/writes.js
CHANGED
|
@@ -30,6 +30,7 @@ exports.WRITE_ACTIONS = [
|
|
|
30
30
|
'destroy',
|
|
31
31
|
'detach',
|
|
32
32
|
'recall',
|
|
33
|
+
'shutdown',
|
|
33
34
|
];
|
|
34
35
|
/** Type guard — narrows an arbitrary string to a known `WriteAction`. */
|
|
35
36
|
function isWriteAction(s) {
|
|
@@ -67,6 +68,7 @@ async function handleWriteRoute(req, res, client, ensemble, action) {
|
|
|
67
68
|
case 'destroy': return await handleDestroy(res, client, ensemble, body);
|
|
68
69
|
case 'detach': return await handleDetach(res, client, ensemble, body);
|
|
69
70
|
case 'recall': return await handleRecall(res, client, ensemble, body);
|
|
71
|
+
case 'shutdown': return await handleShutdown(res, client, ensemble, body);
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
catch (err) {
|
|
@@ -235,6 +237,22 @@ async function handleRecall(res, client, ensemble, body) {
|
|
|
235
237
|
const messages = result.received.length + result.sent.length;
|
|
236
238
|
(0, responses_1.jsonResponse)(res, 200, { ok: true, ensemble, playerId, messages });
|
|
237
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Ensemble teardown (#700 P1) — the HTTP verb the command-center `/ensemble-down`
|
|
242
|
+
* needs (the CLI `down` + the `shutdown` MCP tool cover this, but neither was
|
|
243
|
+
* HTTP). Graceful by default: `client.shutdown(ensemble)` fans out detach + pauses
|
|
244
|
+
* the scheduler + maestro; workflows survive in `detached` (pair with `restore`).
|
|
245
|
+
* `{ destroy: true }` (`/ensemble-down --destroy`) escalates to the ensemble-scope
|
|
246
|
+
* `client.destroy(ensemble)` (no `playerId`) — terminate, not graceful.
|
|
247
|
+
*/
|
|
248
|
+
async function handleShutdown(res, client, ensemble, body) {
|
|
249
|
+
if (body.destroy === true) {
|
|
250
|
+
const summary = await client.destroy(ensemble);
|
|
251
|
+
return (0, responses_1.jsonResponse)(res, 202, { ok: true, ensemble, mode: 'destroy', summary: summary ?? null });
|
|
252
|
+
}
|
|
253
|
+
const summary = await client.shutdown(ensemble);
|
|
254
|
+
(0, responses_1.jsonResponse)(res, 202, { ok: true, ensemble, mode: 'shutdown', summary });
|
|
255
|
+
}
|
|
238
256
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
239
257
|
/**
|
|
240
258
|
* Map a thrown error from a TempoClient call to an HTTP response.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute paths to the two shipped Pi extension entry points.
|
|
3
|
+
*
|
|
4
|
+
* `__dirname` is `<pkg>/dist/pi` (this module compiles to `dist/pi/install.js`),
|
|
5
|
+
* so both entry points are resolved relative to it directly (MUST-FIX 1).
|
|
6
|
+
*/
|
|
7
|
+
export declare function piExtensionPaths(): {
|
|
8
|
+
player: string;
|
|
9
|
+
missionControl: string;
|
|
10
|
+
};
|
|
11
|
+
export interface InstallPiOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Install into the per-project `.pi/settings.json` (under `cwd`) instead of
|
|
14
|
+
* the global `~/.pi/agent/settings.json`.
|
|
15
|
+
*/
|
|
16
|
+
project?: boolean;
|
|
17
|
+
/** Base dir for project-scope install. Defaults to `process.cwd()`. */
|
|
18
|
+
cwd?: string;
|
|
19
|
+
/** Override the home dir (tests). Defaults to `os.homedir()`. */
|
|
20
|
+
home?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface InstallPiResult {
|
|
23
|
+
/** The settings.json file that was read/written. */
|
|
24
|
+
settingsPath: string;
|
|
25
|
+
/** Extension paths added by THIS run (empty on a repeat run — idempotent). */
|
|
26
|
+
added: string[];
|
|
27
|
+
/** Extension paths already present before this run. */
|
|
28
|
+
alreadyPresent: string[];
|
|
29
|
+
/** The final `extensions` array written to settings.json. */
|
|
30
|
+
extensions: string[];
|
|
31
|
+
}
|
|
32
|
+
/** Resolve the Pi settings.json path for the chosen scope. */
|
|
33
|
+
export declare function piSettingsPath(opts?: InstallPiOptions): string;
|
|
34
|
+
/**
|
|
35
|
+
* Idempotently merge the two agent-tempo Pi extension absolute paths into Pi's
|
|
36
|
+
* `settings.json` `"extensions"` array. Re-running is a no-op (no duplicates, no
|
|
37
|
+
* write when nothing changed). Never copies any extension file — install by
|
|
38
|
+
* reference only (see file header).
|
|
39
|
+
*
|
|
40
|
+
* Tolerates a missing / empty / corrupt settings file: a missing file is
|
|
41
|
+
* created; an unparseable one is replaced with a fresh object carrying just the
|
|
42
|
+
* extensions (we can only safely merge a valid object). Other recognised keys in
|
|
43
|
+
* a valid settings object are preserved.
|
|
44
|
+
*/
|
|
45
|
+
export declare function installPiExtensions(opts?: InstallPiOptions): InstallPiResult;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.piExtensionPaths = piExtensionPaths;
|
|
4
|
+
exports.piSettingsPath = piSettingsPath;
|
|
5
|
+
exports.installPiExtensions = installPiExtensions;
|
|
6
|
+
/**
|
|
7
|
+
* Install the agent-tempo Pi extensions the normal `.pi` way (#700 P1, design §8).
|
|
8
|
+
*
|
|
9
|
+
* One agent-tempo install ships TWO Pi extension entry points — the player
|
|
10
|
+
* extension (`dist/pi/extension.js`, claims attachment / registers as a player)
|
|
11
|
+
* and the command-center extension (`dist/pi/mission-control/extension.js`,
|
|
12
|
+
* observer-only board + operator controller). `installPiExtensions()`
|
|
13
|
+
* idempotently merges their ABSOLUTE dist paths into Pi's `settings.json`
|
|
14
|
+
* `"extensions"` array.
|
|
15
|
+
*
|
|
16
|
+
* ── INSTALL-BY-REFERENCE, never a loose copy (design §8, load-bearing) ──
|
|
17
|
+
* We point Pi's settings at the extension files *where they already live inside
|
|
18
|
+
* the installed agent-tempo package* — we never copy a loose `.js` into
|
|
19
|
+
* `~/.pi/agent/extensions/`. The reason is dependency resolution: our extension
|
|
20
|
+
* imports `@temporalio/*`, `croner`, etc., which resolve via Node's upward
|
|
21
|
+
* `node_modules` walk. That walk only finds our deps when the extension file
|
|
22
|
+
* sits inside the agent-tempo package tree. A loose copy has no `node_modules`
|
|
23
|
+
* beside it, so those bare imports would fail. Reference-install keeps Node's
|
|
24
|
+
* resolution intact with zero copying.
|
|
25
|
+
*
|
|
26
|
+
* ── MUST-FIX 1: resolve the two paths DIRECTLY from `__dirname` ──
|
|
27
|
+
* This file compiles to `<pkg>/dist/pi/install.js`, co-located with
|
|
28
|
+
* `dist/pi/extension.js` and `dist/pi/mission-control/extension.js`. So the
|
|
29
|
+
* paths are `join(__dirname, 'extension.js')` and
|
|
30
|
+
* `join(__dirname, 'mission-control', 'extension.js')` — NOT
|
|
31
|
+
* `resolve(__dirname, '..', …)` (that would yield `dist/dist/…`, the bug).
|
|
32
|
+
*/
|
|
33
|
+
const fs_1 = require("fs");
|
|
34
|
+
const path_1 = require("path");
|
|
35
|
+
const os_1 = require("os");
|
|
36
|
+
/**
|
|
37
|
+
* Absolute paths to the two shipped Pi extension entry points.
|
|
38
|
+
*
|
|
39
|
+
* `__dirname` is `<pkg>/dist/pi` (this module compiles to `dist/pi/install.js`),
|
|
40
|
+
* so both entry points are resolved relative to it directly (MUST-FIX 1).
|
|
41
|
+
*/
|
|
42
|
+
function piExtensionPaths() {
|
|
43
|
+
return {
|
|
44
|
+
player: (0, path_1.resolve)(__dirname, 'extension.js'),
|
|
45
|
+
missionControl: (0, path_1.resolve)(__dirname, 'mission-control', 'extension.js'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Resolve the Pi settings.json path for the chosen scope. */
|
|
49
|
+
function piSettingsPath(opts = {}) {
|
|
50
|
+
if (opts.project)
|
|
51
|
+
return (0, path_1.join)(opts.cwd ?? process.cwd(), '.pi', 'settings.json');
|
|
52
|
+
return (0, path_1.join)(opts.home ?? (0, os_1.homedir)(), '.pi', 'agent', 'settings.json');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Idempotently merge the two agent-tempo Pi extension absolute paths into Pi's
|
|
56
|
+
* `settings.json` `"extensions"` array. Re-running is a no-op (no duplicates, no
|
|
57
|
+
* write when nothing changed). Never copies any extension file — install by
|
|
58
|
+
* reference only (see file header).
|
|
59
|
+
*
|
|
60
|
+
* Tolerates a missing / empty / corrupt settings file: a missing file is
|
|
61
|
+
* created; an unparseable one is replaced with a fresh object carrying just the
|
|
62
|
+
* extensions (we can only safely merge a valid object). Other recognised keys in
|
|
63
|
+
* a valid settings object are preserved.
|
|
64
|
+
*/
|
|
65
|
+
function installPiExtensions(opts = {}) {
|
|
66
|
+
const settingsPath = piSettingsPath(opts);
|
|
67
|
+
const { player, missionControl } = piExtensionPaths();
|
|
68
|
+
const want = [player, missionControl];
|
|
69
|
+
let settings = {};
|
|
70
|
+
const fileExists = (0, fs_1.existsSync)(settingsPath);
|
|
71
|
+
if (fileExists) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = JSON.parse((0, fs_1.readFileSync)(settingsPath, 'utf8'));
|
|
74
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
75
|
+
settings = parsed;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Corrupt JSON — fall back to a clean object (can't safely merge into
|
|
80
|
+
// something we can't parse). Other keys are unrecoverable in that case.
|
|
81
|
+
settings = {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const current = Array.isArray(settings.extensions)
|
|
85
|
+
? settings.extensions.filter((x) => typeof x === 'string')
|
|
86
|
+
: [];
|
|
87
|
+
const added = [];
|
|
88
|
+
const alreadyPresent = [];
|
|
89
|
+
const merged = [...current];
|
|
90
|
+
for (const p of want) {
|
|
91
|
+
if (merged.includes(p)) {
|
|
92
|
+
alreadyPresent.push(p);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
merged.push(p);
|
|
96
|
+
added.push(p);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
settings.extensions = merged;
|
|
100
|
+
// Idempotent: only write when something actually changed (or the file is
|
|
101
|
+
// absent and must be created). A clean repeat run touches nothing.
|
|
102
|
+
if (added.length > 0 || !fileExists) {
|
|
103
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(settingsPath), { recursive: true });
|
|
104
|
+
(0, fs_1.writeFileSync)(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
return { settingsPath, added, alreadyPresent, extensions: merged };
|
|
107
|
+
}
|
|
@@ -43,6 +43,34 @@ export declare class MissionControlActions {
|
|
|
43
43
|
restart(playerId: string, reason?: string): Promise<ActionResult>;
|
|
44
44
|
destroy(playerId: string, reason?: string): Promise<ActionResult>;
|
|
45
45
|
reset(playerId: string, reason?: string): Promise<ActionResult>;
|
|
46
|
+
/**
|
|
47
|
+
* Create a fresh ensemble via `POST /v1/ensembles` (the catalog route, NOT
|
|
48
|
+
* ensemble-scoped). Defaults `name` to this client's bound ensemble — the one
|
|
49
|
+
* the command-center board observes. `conductorAgent` lets `/ensemble-up`
|
|
50
|
+
* default the conductor to a headless Pi (design §3).
|
|
51
|
+
*/
|
|
52
|
+
createEnsemble(opts?: {
|
|
53
|
+
name?: string;
|
|
54
|
+
lineup?: string;
|
|
55
|
+
host?: string;
|
|
56
|
+
startMode?: 'hold' | 'release';
|
|
57
|
+
conductorInstructions?: string;
|
|
58
|
+
conductorAgent?: string;
|
|
59
|
+
}): Promise<ActionResult>;
|
|
60
|
+
/** Recruit a player into the bound ensemble (`POST /v1/ensembles/:e/recruit`). */
|
|
61
|
+
recruit(opts: {
|
|
62
|
+
name: string;
|
|
63
|
+
workDir: string;
|
|
64
|
+
playerType?: string;
|
|
65
|
+
host?: string;
|
|
66
|
+
agent?: string;
|
|
67
|
+
}): Promise<ActionResult>;
|
|
68
|
+
/**
|
|
69
|
+
* Tear down the bound ensemble (`POST /v1/ensembles/:e/shutdown`). Graceful by
|
|
70
|
+
* default (detach + pause, survives in `detached`); `destroy: true` escalates
|
|
71
|
+
* to ensemble-scope destroy (terminate).
|
|
72
|
+
*/
|
|
73
|
+
shutdownEnsemble(destroy?: boolean): Promise<ActionResult>;
|
|
46
74
|
gateArm(playerId: string): Promise<ActionResult>;
|
|
47
75
|
gateDisarm(playerId: string): Promise<ActionResult>;
|
|
48
76
|
gateDecide(playerId: string, requestId: string, decision: 'allow' | 'deny'): Promise<ActionResult>;
|
|
@@ -87,6 +87,29 @@ class MissionControlActions {
|
|
|
87
87
|
reset(playerId, reason) {
|
|
88
88
|
return this.post(`/v1/ensembles/${this.ens()}/reset`, { playerId, ...(reason ? { reason } : {}) });
|
|
89
89
|
}
|
|
90
|
+
// ── Bootstrap surface (#700 P1) ──
|
|
91
|
+
/**
|
|
92
|
+
* Create a fresh ensemble via `POST /v1/ensembles` (the catalog route, NOT
|
|
93
|
+
* ensemble-scoped). Defaults `name` to this client's bound ensemble — the one
|
|
94
|
+
* the command-center board observes. `conductorAgent` lets `/ensemble-up`
|
|
95
|
+
* default the conductor to a headless Pi (design §3).
|
|
96
|
+
*/
|
|
97
|
+
createEnsemble(opts = {}) {
|
|
98
|
+
const { name, ...rest } = opts;
|
|
99
|
+
return this.post('/v1/ensembles', { name: name ?? this.ensemble, ...rest });
|
|
100
|
+
}
|
|
101
|
+
/** Recruit a player into the bound ensemble (`POST /v1/ensembles/:e/recruit`). */
|
|
102
|
+
recruit(opts) {
|
|
103
|
+
return this.post(`/v1/ensembles/${this.ens()}/recruit`, opts);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Tear down the bound ensemble (`POST /v1/ensembles/:e/shutdown`). Graceful by
|
|
107
|
+
* default (detach + pause, survives in `detached`); `destroy: true` escalates
|
|
108
|
+
* to ensemble-scope destroy (terminate).
|
|
109
|
+
*/
|
|
110
|
+
shutdownEnsemble(destroy) {
|
|
111
|
+
return this.post(`/v1/ensembles/${this.ens()}/shutdown`, destroy ? { destroy: true } : {});
|
|
112
|
+
}
|
|
90
113
|
// ── Operator gate plane (T3) ──
|
|
91
114
|
gateArm(playerId) {
|
|
92
115
|
return this.post(`/v1/players/${this.player(playerId)}/gate-arm`, {});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BoardModel } from './board';
|
|
2
2
|
import { MissionControlActions } from './actions';
|
|
3
|
+
import { type InfraProgress } from '../../cli/ensure-infra';
|
|
3
4
|
import type { McExtensionAPI, McExtensionContext } from './pi-ui';
|
|
4
5
|
/** Injectable seams (production defaults; tests override). */
|
|
5
6
|
export interface MissionControlDeps {
|
|
@@ -10,6 +11,27 @@ export interface MissionControlDeps {
|
|
|
10
11
|
/** Local daemon host for tailability (test override; defaults to `os.hostname()`). */
|
|
11
12
|
localHost?: string;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Infra-bootstrap seam (#700 P1). Defaults to the real {@link ensureInfra}; the
|
|
16
|
+
* extension command tests inject a fake so `/ensemble-up` etc. don't spawn
|
|
17
|
+
* Temporal / the daemon. Accepts only the `onStep` opt the controller passes.
|
|
18
|
+
*/
|
|
19
|
+
export type EnsureInfraFn = (opts?: {
|
|
20
|
+
onStep?: (p: InfraProgress) => void;
|
|
21
|
+
}) => Promise<unknown>;
|
|
22
|
+
/** Parsed `/ensemble-up [name] [--lineup X] [--hold]` args. */
|
|
23
|
+
export declare function parseEnsembleUpArgs(args: string): {
|
|
24
|
+
name?: string;
|
|
25
|
+
lineup?: string;
|
|
26
|
+
hold: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** Parsed `/recruit <name> [--type T] [--host H] [--agent A]` args. */
|
|
29
|
+
export declare function parseRecruitArgs(args: string): {
|
|
30
|
+
name?: string;
|
|
31
|
+
type?: string;
|
|
32
|
+
host?: string;
|
|
33
|
+
agent?: string;
|
|
34
|
+
};
|
|
13
35
|
/**
|
|
14
36
|
* The operator-command + board controller. Holds the model + the action client;
|
|
15
37
|
* command methods are independently unit-testable with a fake actions + ctx.
|
|
@@ -25,7 +47,9 @@ export declare class Controller {
|
|
|
25
47
|
readonly localHost: string;
|
|
26
48
|
/** Set by the extension so /tail can (re)open the fine SSE; null in unit tests. */
|
|
27
49
|
onTailRequest: ((playerId: string | null) => void) | null;
|
|
28
|
-
|
|
50
|
+
/** Infra bootstrap fn (#700 P1); injectable for tests. */
|
|
51
|
+
private readonly ensureInfraFn;
|
|
52
|
+
constructor(ensemble: string, actions: MissionControlActions, localHost?: string, ensureInfraFn?: EnsureInfraFn);
|
|
29
53
|
private notify;
|
|
30
54
|
private report;
|
|
31
55
|
/** First whitespace-delimited token + the remainder. */
|
|
@@ -40,6 +64,16 @@ export declare class Controller {
|
|
|
40
64
|
cmdReset(args: string, ctx: McExtensionContext): Promise<void>;
|
|
41
65
|
cmdArm(args: string, ctx: McExtensionContext): Promise<void>;
|
|
42
66
|
cmdGate(args: string, ctx: McExtensionContext): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Ensure local infra is up (Temporal + SAs + agent types + daemon), streaming
|
|
69
|
+
* each step to the UI. Returns false (and notifies) if bootstrap fails, so the
|
|
70
|
+
* caller can bail before the HTTP action. Idempotent — a no-op when infra is
|
|
71
|
+
* already live.
|
|
72
|
+
*/
|
|
73
|
+
private ensureInfraReady;
|
|
74
|
+
cmdEnsembleUp(args: string, ctx: McExtensionContext): Promise<void>;
|
|
75
|
+
cmdRecruit(args: string, ctx: McExtensionContext): Promise<void>;
|
|
76
|
+
cmdEnsembleDown(args: string, ctx: McExtensionContext): Promise<void>;
|
|
43
77
|
}
|
|
44
78
|
/**
|
|
45
79
|
* Build the mission-control extension (default-export shape). The operator's Pi
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.Controller = void 0;
|
|
37
|
+
exports.parseEnsembleUpArgs = parseEnsembleUpArgs;
|
|
38
|
+
exports.parseRecruitArgs = parseRecruitArgs;
|
|
37
39
|
exports.createMissionControlExtension = createMissionControlExtension;
|
|
38
40
|
/**
|
|
39
41
|
* Mission-control Pi extension (3f) — turns ONE interactive Pi TUI into an
|
|
@@ -58,9 +60,52 @@ const board_1 = require("./board");
|
|
|
58
60
|
const render_1 = require("./render");
|
|
59
61
|
const actions_1 = require("./actions");
|
|
60
62
|
const inner_tail_1 = require("./inner-tail");
|
|
63
|
+
const ensure_infra_1 = require("../../cli/ensure-infra");
|
|
61
64
|
const WIDGET_KEY = 'mission-control';
|
|
62
65
|
const DEFAULT_RENDER_THROTTLE_MS = 200;
|
|
63
66
|
const DEFAULT_PORT = 8473;
|
|
67
|
+
/** Parsed `/ensemble-up [name] [--lineup X] [--hold]` args. */
|
|
68
|
+
function parseEnsembleUpArgs(args) {
|
|
69
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
70
|
+
let name;
|
|
71
|
+
let lineup;
|
|
72
|
+
let hold = false;
|
|
73
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
74
|
+
const t = tokens[i];
|
|
75
|
+
if (t === '--hold')
|
|
76
|
+
hold = true;
|
|
77
|
+
else if (t === '--lineup')
|
|
78
|
+
lineup = tokens[++i];
|
|
79
|
+
else if (t.startsWith('--lineup='))
|
|
80
|
+
lineup = t.slice('--lineup='.length);
|
|
81
|
+
else if (!t.startsWith('--') && name === undefined)
|
|
82
|
+
name = t;
|
|
83
|
+
}
|
|
84
|
+
return { ...(name !== undefined ? { name } : {}), ...(lineup !== undefined ? { lineup } : {}), hold };
|
|
85
|
+
}
|
|
86
|
+
/** Parsed `/recruit <name> [--type T] [--host H] [--agent A]` args. */
|
|
87
|
+
function parseRecruitArgs(args) {
|
|
88
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
89
|
+
const out = {};
|
|
90
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
91
|
+
const t = tokens[i];
|
|
92
|
+
if (t === '--type')
|
|
93
|
+
out.type = tokens[++i];
|
|
94
|
+
else if (t.startsWith('--type='))
|
|
95
|
+
out.type = t.slice('--type='.length);
|
|
96
|
+
else if (t === '--host')
|
|
97
|
+
out.host = tokens[++i];
|
|
98
|
+
else if (t.startsWith('--host='))
|
|
99
|
+
out.host = t.slice('--host='.length);
|
|
100
|
+
else if (t === '--agent')
|
|
101
|
+
out.agent = tokens[++i];
|
|
102
|
+
else if (t.startsWith('--agent='))
|
|
103
|
+
out.agent = t.slice('--agent='.length);
|
|
104
|
+
else if (!t.startsWith('--') && out.name === undefined)
|
|
105
|
+
out.name = t;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
64
109
|
/**
|
|
65
110
|
* The operator-command + board controller. Holds the model + the action client;
|
|
66
111
|
* command methods are independently unit-testable with a fake actions + ctx.
|
|
@@ -76,10 +121,13 @@ class Controller {
|
|
|
76
121
|
localHost;
|
|
77
122
|
/** Set by the extension so /tail can (re)open the fine SSE; null in unit tests. */
|
|
78
123
|
onTailRequest = null;
|
|
79
|
-
|
|
124
|
+
/** Infra bootstrap fn (#700 P1); injectable for tests. */
|
|
125
|
+
ensureInfraFn;
|
|
126
|
+
constructor(ensemble, actions, localHost = os.hostname(), ensureInfraFn = ensure_infra_1.ensureInfra) {
|
|
80
127
|
this.model = (0, board_1.initBoard)(ensemble);
|
|
81
128
|
this.actions = actions;
|
|
82
129
|
this.localHost = localHost;
|
|
130
|
+
this.ensureInfraFn = ensureInfraFn;
|
|
83
131
|
}
|
|
84
132
|
notify(ctx, msg) {
|
|
85
133
|
if (ctx.hasUI)
|
|
@@ -191,6 +239,65 @@ class Controller {
|
|
|
191
239
|
}
|
|
192
240
|
this.report(ctx, `gate ${reqId} ${decision}`, await this.actions.gateDecide(this.model.selected, reqId, decision));
|
|
193
241
|
}
|
|
242
|
+
// ── Bootstrap commands (#700 P1) ──
|
|
243
|
+
/**
|
|
244
|
+
* Ensure local infra is up (Temporal + SAs + agent types + daemon), streaming
|
|
245
|
+
* each step to the UI. Returns false (and notifies) if bootstrap fails, so the
|
|
246
|
+
* caller can bail before the HTTP action. Idempotent — a no-op when infra is
|
|
247
|
+
* already live.
|
|
248
|
+
*/
|
|
249
|
+
async ensureInfraReady(ctx) {
|
|
250
|
+
try {
|
|
251
|
+
await this.ensureInfraFn({
|
|
252
|
+
onStep: (p) => this.notify(ctx, `infra: ${p.step} ${p.status}${p.detail ? ` (${p.detail})` : ''}`),
|
|
253
|
+
});
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
this.notify(ctx, `infra failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async cmdEnsembleUp(args, ctx) {
|
|
262
|
+
const { name, lineup, hold } = parseEnsembleUpArgs(args);
|
|
263
|
+
if (!(await this.ensureInfraReady(ctx)))
|
|
264
|
+
return;
|
|
265
|
+
// Conductor defaults to a HEADLESS Pi (design §3) — the human's seat is this
|
|
266
|
+
// command-center planner, so a second interactive conductor window is
|
|
267
|
+
// redundant. `conductorAgent: 'pi'` overrides any lineup conductor agent.
|
|
268
|
+
const r = await this.actions.createEnsemble({
|
|
269
|
+
...(name !== undefined ? { name } : {}),
|
|
270
|
+
...(lineup !== undefined ? { lineup } : {}),
|
|
271
|
+
startMode: hold ? 'hold' : 'release',
|
|
272
|
+
conductorAgent: 'pi',
|
|
273
|
+
});
|
|
274
|
+
this.report(ctx, `ensemble-up${name ? ` ${name}` : ''}`, r);
|
|
275
|
+
}
|
|
276
|
+
async cmdRecruit(args, ctx) {
|
|
277
|
+
const { name, type, host, agent } = parseRecruitArgs(args);
|
|
278
|
+
if (!name) {
|
|
279
|
+
this.notify(ctx, 'Usage: /recruit <name> [--type T] [--host H] [--agent A]');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (!(await this.ensureInfraReady(ctx)))
|
|
283
|
+
return;
|
|
284
|
+
const r = await this.actions.recruit({
|
|
285
|
+
name,
|
|
286
|
+
// The extension runs in the Pi process; its cwd is the project dir.
|
|
287
|
+
workDir: process.cwd(),
|
|
288
|
+
...(type !== undefined ? { playerType: type } : {}),
|
|
289
|
+
...(host !== undefined ? { host } : {}),
|
|
290
|
+
...(agent !== undefined ? { agent } : {}),
|
|
291
|
+
});
|
|
292
|
+
this.report(ctx, `recruit ${name}`, r);
|
|
293
|
+
}
|
|
294
|
+
async cmdEnsembleDown(args, ctx) {
|
|
295
|
+
const destroy = args.trim().split(/\s+/).includes('--destroy');
|
|
296
|
+
if (!(await this.ensureInfraReady(ctx)))
|
|
297
|
+
return;
|
|
298
|
+
const r = await this.actions.shutdownEnsemble(destroy);
|
|
299
|
+
this.report(ctx, `ensemble-down${destroy ? ' --destroy' : ''}`, r);
|
|
300
|
+
}
|
|
194
301
|
}
|
|
195
302
|
exports.Controller = Controller;
|
|
196
303
|
function resolveBaseUrl(override) {
|
|
@@ -321,6 +428,10 @@ function createMissionControlExtension(deps = {}) {
|
|
|
321
428
|
pi.registerCommand('reset', { description: 'Clean-wipe a player (/reset <player> [reason])', handler: (a, ctx) => ctrl.cmdReset(a, ctx) });
|
|
322
429
|
pi.registerCommand('arm', { description: 'Arm/disarm the operator gate for a player (/arm <player> [off])', handler: (a, ctx) => ctrl.cmdArm(a, ctx) });
|
|
323
430
|
pi.registerCommand('gate', { description: 'Decide a gate request for the tailed player (/gate <reqId> allow|deny)', handler: (a, ctx) => ctrl.cmdGate(a, ctx) });
|
|
431
|
+
// #700 P1 — bootstrap commands (ensureInfra → daemon HTTP action).
|
|
432
|
+
pi.registerCommand('ensemble-up', { description: 'Bootstrap the ensemble (/ensemble-up [name] [--lineup X] [--hold])', handler: (a, ctx) => ctrl.cmdEnsembleUp(a, ctx) });
|
|
433
|
+
pi.registerCommand('recruit', { description: 'Recruit a player (/recruit <name> [--type T] [--host H] [--agent A])', handler: (a, ctx) => ctrl.cmdRecruit(a, ctx) });
|
|
434
|
+
pi.registerCommand('ensemble-down', { description: 'Tear down the ensemble (/ensemble-down [--destroy])', handler: (a, ctx) => ctrl.cmdEnsembleDown(a, ctx) });
|
|
324
435
|
};
|
|
325
436
|
}
|
|
326
437
|
/** Default export — the loadable Pi extension. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo",
|
|
3
|
-
"version": "1.7.0-beta.
|
|
3
|
+
"version": "1.7.0-beta.1",
|
|
4
4
|
"description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -54,6 +54,12 @@
|
|
|
54
54
|
"agent-tempo": "dist/cli.js",
|
|
55
55
|
"agent-tempo-server": "dist/server.js"
|
|
56
56
|
},
|
|
57
|
+
"pi": {
|
|
58
|
+
"extensions": [
|
|
59
|
+
"./dist/pi/extension.js",
|
|
60
|
+
"./dist/pi/mission-control/extension.js"
|
|
61
|
+
]
|
|
62
|
+
},
|
|
57
63
|
"scripts": {
|
|
58
64
|
"build": "tsc && npm run build:scripts && npm run build:dashboard && node -e \"const{bundleWorkflowCode}=require('@temporalio/worker');const path=require('path');const fs=require('fs');bundleWorkflowCode({workflowsPath:path.resolve('dist/workflows/index.js')}).then(b=>{fs.writeFileSync('workflow-bundle.js',b.code);console.log('Workflow bundle created')})\"",
|
|
59
65
|
"build:scripts": "tsc -p scripts/tsconfig.json",
|