@os-eco/overstory-cli 0.7.9 → 0.8.2
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 +16 -7
- package/agents/coordinator.md +41 -0
- package/agents/orchestrator.md +239 -0
- package/package.json +1 -1
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/commands/coordinator.test.ts +334 -0
- package/src/commands/coordinator.ts +366 -0
- package/src/commands/dashboard.test.ts +86 -0
- package/src/commands/dashboard.ts +8 -4
- package/src/commands/feed.test.ts +8 -0
- package/src/commands/init.test.ts +2 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/inspect.test.ts +156 -1
- package/src/commands/inspect.ts +19 -4
- package/src/commands/replay.test.ts +8 -0
- package/src/commands/sling.ts +218 -121
- package/src/commands/status.test.ts +77 -0
- package/src/commands/status.ts +6 -3
- package/src/commands/stop.test.ts +134 -0
- package/src/commands/stop.ts +41 -11
- package/src/commands/trace.test.ts +8 -0
- package/src/commands/update.test.ts +465 -0
- package/src/commands/update.ts +263 -0
- package/src/config.test.ts +65 -1
- package/src/config.ts +23 -0
- package/src/e2e/init-sling-lifecycle.test.ts +3 -2
- package/src/index.ts +21 -2
- package/src/logging/theme.ts +4 -0
- package/src/runtimes/connections.test.ts +74 -0
- package/src/runtimes/connections.ts +34 -0
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +2 -0
- package/src/runtimes/sapling.test.ts +1237 -0
- package/src/runtimes/sapling.ts +698 -0
- package/src/runtimes/types.ts +45 -0
- package/src/types.ts +5 -1
- package/src/watchdog/daemon.ts +34 -0
- package/src/watchdog/health.test.ts +102 -0
- package/src/watchdog/health.ts +140 -69
- package/src/worktree/process.test.ts +101 -0
- package/src/worktree/process.ts +111 -0
- package/src/worktree/tmux.ts +5 -0
|
@@ -15,11 +15,13 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
15
15
|
import { mkdir, realpath } from "node:fs/promises";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { AgentError, ValidationError } from "../errors.ts";
|
|
18
|
+
import { createMailStore } from "../mail/store.ts";
|
|
18
19
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
19
20
|
import { createRunStore } from "../sessions/store.ts";
|
|
20
21
|
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
21
22
|
import type { AgentSession } from "../types.ts";
|
|
22
23
|
import {
|
|
24
|
+
askCoordinator,
|
|
23
25
|
buildCoordinatorBeacon,
|
|
24
26
|
type CoordinatorDeps,
|
|
25
27
|
coordinatorCommand,
|
|
@@ -1770,3 +1772,335 @@ describe("SessionStore round-trip", () => {
|
|
|
1770
1772
|
expect(exists).toBe(true);
|
|
1771
1773
|
});
|
|
1772
1774
|
});
|
|
1775
|
+
|
|
1776
|
+
// --- Helpers for send/output tests ---
|
|
1777
|
+
|
|
1778
|
+
/** Read all messages from the mail store at mail.db for assertions. */
|
|
1779
|
+
function loadMailMessages() {
|
|
1780
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
1781
|
+
const mailStore = createMailStore(mailDbPath);
|
|
1782
|
+
try {
|
|
1783
|
+
return mailStore.getAll();
|
|
1784
|
+
} finally {
|
|
1785
|
+
mailStore.close();
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
describe("sendCoordinator", () => {
|
|
1790
|
+
test("send succeeds with running coordinator — mail is in DB", async () => {
|
|
1791
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1792
|
+
saveSessionsToDb([session]);
|
|
1793
|
+
|
|
1794
|
+
let nudgeCalled = false;
|
|
1795
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1796
|
+
deps._nudge = async () => {
|
|
1797
|
+
nudgeCalled = true;
|
|
1798
|
+
return { delivered: true };
|
|
1799
|
+
};
|
|
1800
|
+
|
|
1801
|
+
await captureStdout(() => coordinatorCommand(["send", "--body", "hello world"], deps));
|
|
1802
|
+
|
|
1803
|
+
const messages = loadMailMessages();
|
|
1804
|
+
expect(messages).toHaveLength(1);
|
|
1805
|
+
expect(messages[0]?.from).toBe("operator");
|
|
1806
|
+
expect(messages[0]?.to).toBe("coordinator");
|
|
1807
|
+
expect(messages[0]?.body).toBe("hello world");
|
|
1808
|
+
expect(messages[0]?.type).toBe("dispatch");
|
|
1809
|
+
expect(nudgeCalled).toBe(true);
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
test("send fails when no coordinator running", async () => {
|
|
1813
|
+
const { deps } = makeDeps();
|
|
1814
|
+
|
|
1815
|
+
await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
test("send fails when coordinator tmux is dead — state updated to zombie", async () => {
|
|
1819
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1820
|
+
saveSessionsToDb([session]);
|
|
1821
|
+
|
|
1822
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
1823
|
+
|
|
1824
|
+
await expect(coordinatorCommand(["send", "--body", "hello"], deps)).rejects.toThrow(AgentError);
|
|
1825
|
+
|
|
1826
|
+
const sessions = loadSessionsFromDb();
|
|
1827
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
test("send --json outputs JSON with id and nudged fields", async () => {
|
|
1831
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1832
|
+
saveSessionsToDb([session]);
|
|
1833
|
+
|
|
1834
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1835
|
+
deps._nudge = async () => ({ delivered: true });
|
|
1836
|
+
|
|
1837
|
+
const output = await captureStdout(() =>
|
|
1838
|
+
coordinatorCommand(["send", "--body", "hello", "--json"], deps),
|
|
1839
|
+
);
|
|
1840
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1841
|
+
expect(typeof parsed.id).toBe("string");
|
|
1842
|
+
expect(parsed.nudged).toBe(true);
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
test("send with custom --subject uses subject in mail", async () => {
|
|
1846
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1847
|
+
saveSessionsToDb([session]);
|
|
1848
|
+
|
|
1849
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1850
|
+
deps._nudge = async () => ({ delivered: false });
|
|
1851
|
+
|
|
1852
|
+
await captureStdout(() =>
|
|
1853
|
+
coordinatorCommand(
|
|
1854
|
+
["send", "--body", "build feature X", "--subject", "Deploy feature X"],
|
|
1855
|
+
deps,
|
|
1856
|
+
),
|
|
1857
|
+
);
|
|
1858
|
+
|
|
1859
|
+
const messages = loadMailMessages();
|
|
1860
|
+
expect(messages[0]?.subject).toBe("Deploy feature X");
|
|
1861
|
+
});
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
describe("outputCoordinator", () => {
|
|
1865
|
+
test("output shows pane content", async () => {
|
|
1866
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1867
|
+
saveSessionsToDb([session]);
|
|
1868
|
+
|
|
1869
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1870
|
+
deps._capturePaneContent = async () => "Hello from coordinator pane\n";
|
|
1871
|
+
|
|
1872
|
+
const output = await captureStdout(() => coordinatorCommand(["output"], deps));
|
|
1873
|
+
expect(output).toContain("Hello from coordinator pane");
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
test("output fails when no coordinator running", async () => {
|
|
1877
|
+
const { deps } = makeDeps();
|
|
1878
|
+
|
|
1879
|
+
await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
test("output fails when coordinator tmux is dead — state updated to zombie", async () => {
|
|
1883
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1884
|
+
saveSessionsToDb([session]);
|
|
1885
|
+
|
|
1886
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
1887
|
+
|
|
1888
|
+
await expect(coordinatorCommand(["output"], deps)).rejects.toThrow(AgentError);
|
|
1889
|
+
|
|
1890
|
+
const sessions = loadSessionsFromDb();
|
|
1891
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
test("output --json wraps content in JSON", async () => {
|
|
1895
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1896
|
+
saveSessionsToDb([session]);
|
|
1897
|
+
|
|
1898
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1899
|
+
deps._capturePaneContent = async () => "some output";
|
|
1900
|
+
|
|
1901
|
+
const output = await captureStdout(() => coordinatorCommand(["output", "--json"], deps));
|
|
1902
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
1903
|
+
expect(parsed.content).toBe("some output");
|
|
1904
|
+
expect(typeof parsed.lines).toBe("number");
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
test("output --lines passes lines parameter to capturePaneContent", async () => {
|
|
1908
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1909
|
+
saveSessionsToDb([session]);
|
|
1910
|
+
|
|
1911
|
+
let capturedLines: number | undefined;
|
|
1912
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1913
|
+
deps._capturePaneContent = async (_name: string, lines?: number) => {
|
|
1914
|
+
capturedLines = lines;
|
|
1915
|
+
return "output";
|
|
1916
|
+
};
|
|
1917
|
+
|
|
1918
|
+
await captureStdout(() => coordinatorCommand(["output", "--lines", "100"], deps));
|
|
1919
|
+
expect(capturedLines).toBe(100);
|
|
1920
|
+
});
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
describe("askCoordinator", () => {
|
|
1924
|
+
test("sends mail and returns reply body on stdout", async () => {
|
|
1925
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1926
|
+
saveSessionsToDb([session]);
|
|
1927
|
+
|
|
1928
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1929
|
+
deps._nudge = async () => ({ delivered: true });
|
|
1930
|
+
deps._pollIntervalMs = 50; // Fast polling for test
|
|
1931
|
+
|
|
1932
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
1933
|
+
const outputChunks: string[] = [];
|
|
1934
|
+
const originalWrite = process.stdout.write;
|
|
1935
|
+
process.stdout.write = ((chunk: string) => {
|
|
1936
|
+
outputChunks.push(chunk);
|
|
1937
|
+
return true;
|
|
1938
|
+
}) as typeof process.stdout.write;
|
|
1939
|
+
|
|
1940
|
+
try {
|
|
1941
|
+
// Start ask without awaiting — lets us insert the reply concurrently
|
|
1942
|
+
const askPromise = askCoordinator(
|
|
1943
|
+
"what is the status",
|
|
1944
|
+
{ subject: "status check", timeout: 10, json: false },
|
|
1945
|
+
deps,
|
|
1946
|
+
);
|
|
1947
|
+
|
|
1948
|
+
// Wait for the ask to complete setup and send mail, then insert a reply
|
|
1949
|
+
await Bun.sleep(300);
|
|
1950
|
+
const replyStore = createMailStore(mailDbPath);
|
|
1951
|
+
try {
|
|
1952
|
+
const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
|
|
1953
|
+
const sent = messages[0];
|
|
1954
|
+
if (sent) {
|
|
1955
|
+
replyStore.insert({
|
|
1956
|
+
id: "",
|
|
1957
|
+
from: "coordinator",
|
|
1958
|
+
to: "operator",
|
|
1959
|
+
subject: `Re: ${sent.subject}`,
|
|
1960
|
+
body: "Here is your answer",
|
|
1961
|
+
type: "status",
|
|
1962
|
+
priority: "normal",
|
|
1963
|
+
threadId: sent.id,
|
|
1964
|
+
payload: JSON.stringify({
|
|
1965
|
+
correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
|
|
1966
|
+
}),
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
} finally {
|
|
1970
|
+
replyStore.close();
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
await askPromise;
|
|
1974
|
+
} finally {
|
|
1975
|
+
process.stdout.write = originalWrite;
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
expect(outputChunks.join("")).toBe("Here is your answer\n");
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
test("times out when no reply arrives", async () => {
|
|
1982
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
1983
|
+
saveSessionsToDb([session]);
|
|
1984
|
+
|
|
1985
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
1986
|
+
deps._nudge = async () => ({ delivered: false });
|
|
1987
|
+
deps._pollIntervalMs = 50; // Fast polling so the 1s timeout exhausts quickly
|
|
1988
|
+
|
|
1989
|
+
let caughtError: unknown;
|
|
1990
|
+
try {
|
|
1991
|
+
await askCoordinator(
|
|
1992
|
+
"will you answer?",
|
|
1993
|
+
{ subject: "timeout test", timeout: 1, json: false },
|
|
1994
|
+
deps,
|
|
1995
|
+
);
|
|
1996
|
+
} catch (err) {
|
|
1997
|
+
caughtError = err;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
expect(caughtError).toBeInstanceOf(AgentError);
|
|
2001
|
+
const ae = caughtError as AgentError;
|
|
2002
|
+
expect(ae.message).toContain("Timed out");
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
test("throws when coordinator is not running", async () => {
|
|
2006
|
+
// No session in DB
|
|
2007
|
+
const { deps } = makeDeps();
|
|
2008
|
+
|
|
2009
|
+
let caughtError: unknown;
|
|
2010
|
+
try {
|
|
2011
|
+
await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
|
|
2012
|
+
} catch (err) {
|
|
2013
|
+
caughtError = err;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
expect(caughtError).toBeInstanceOf(AgentError);
|
|
2017
|
+
const ae = caughtError as AgentError;
|
|
2018
|
+
expect(ae.message).toContain("No active coordinator");
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
test("throws when coordinator tmux session is dead", async () => {
|
|
2022
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2023
|
+
saveSessionsToDb([session]);
|
|
2024
|
+
|
|
2025
|
+
// Tmux reports session as dead
|
|
2026
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": false });
|
|
2027
|
+
|
|
2028
|
+
let caughtError: unknown;
|
|
2029
|
+
try {
|
|
2030
|
+
await askCoordinator("hello", { subject: "test", timeout: 5, json: false }, deps);
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
caughtError = err;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
expect(caughtError).toBeInstanceOf(AgentError);
|
|
2036
|
+
const ae = caughtError as AgentError;
|
|
2037
|
+
expect(ae.message).toContain("not alive");
|
|
2038
|
+
|
|
2039
|
+
// Session state should be updated to zombie
|
|
2040
|
+
const sessions = loadSessionsFromDb();
|
|
2041
|
+
expect(sessions[0]?.state).toBe("zombie");
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
test("JSON output includes correlationId and reply details", async () => {
|
|
2045
|
+
const session = makeCoordinatorSession({ state: "working" });
|
|
2046
|
+
saveSessionsToDb([session]);
|
|
2047
|
+
|
|
2048
|
+
const { deps } = makeDeps({ "overstory-test-project-coordinator": true });
|
|
2049
|
+
deps._nudge = async () => ({ delivered: true });
|
|
2050
|
+
deps._pollIntervalMs = 50;
|
|
2051
|
+
|
|
2052
|
+
const mailDbPath = join(overstoryDir, "mail.db");
|
|
2053
|
+
let output = "";
|
|
2054
|
+
|
|
2055
|
+
const askPromise = captureStdout(async () => {
|
|
2056
|
+
const innerAskPromise = askCoordinator(
|
|
2057
|
+
"report status",
|
|
2058
|
+
{ subject: "status", timeout: 10, json: true },
|
|
2059
|
+
deps,
|
|
2060
|
+
);
|
|
2061
|
+
|
|
2062
|
+
// Insert reply while ask is polling
|
|
2063
|
+
await Bun.sleep(300);
|
|
2064
|
+
const replyStore = createMailStore(mailDbPath);
|
|
2065
|
+
try {
|
|
2066
|
+
const messages = replyStore.getAll({ from: "operator", to: "coordinator" });
|
|
2067
|
+
const sent = messages[0];
|
|
2068
|
+
if (sent) {
|
|
2069
|
+
replyStore.insert({
|
|
2070
|
+
id: "",
|
|
2071
|
+
from: "coordinator",
|
|
2072
|
+
to: "operator",
|
|
2073
|
+
subject: `Re: ${sent.subject}`,
|
|
2074
|
+
body: "Status: all good",
|
|
2075
|
+
type: "status",
|
|
2076
|
+
priority: "normal",
|
|
2077
|
+
threadId: sent.id,
|
|
2078
|
+
payload: JSON.stringify({
|
|
2079
|
+
correlationId: JSON.parse(sent.payload ?? "{}").correlationId,
|
|
2080
|
+
}),
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
} finally {
|
|
2084
|
+
replyStore.close();
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
await innerAskPromise;
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
output = await askPromise;
|
|
2091
|
+
|
|
2092
|
+
const parsed = JSON.parse(output) as Record<string, unknown>;
|
|
2093
|
+
expect(parsed.success).toBe(true);
|
|
2094
|
+
expect(parsed.command).toBe("coordinator ask");
|
|
2095
|
+
expect(typeof parsed.correlationId).toBe("string");
|
|
2096
|
+
expect(typeof parsed.sentId).toBe("string");
|
|
2097
|
+
expect(typeof parsed.replyId).toBe("string");
|
|
2098
|
+
expect(parsed.body).toBe("Status: all good");
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
test("command registration — createCoordinatorCommand has ask subcommand", () => {
|
|
2102
|
+
const cmd = createCoordinatorCommand({});
|
|
2103
|
+
const subcommandNames = cmd.commands.map((c) => c.name());
|
|
2104
|
+
expect(subcommandNames).toContain("ask");
|
|
2105
|
+
});
|
|
2106
|
+
});
|