@aryasaatvik/motel 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { config } from "./config.js"
|
|
2
|
+
|
|
3
|
+
export const otelServerInstructions = () => `Motel is a local OpenTelemetry server for traces and logs. Use it for OTLP/HTTP ingestion and as a runtime evidence loop for debugging.
|
|
4
|
+
|
|
5
|
+
Base URL: ${config.otel.queryUrl}
|
|
6
|
+
|
|
7
|
+
OTLP ingest:
|
|
8
|
+
- Traces: POST ${config.otel.exporterUrl}
|
|
9
|
+
- Logs: POST ${config.otel.logsExporterUrl}
|
|
10
|
+
- Content-Type: application/json
|
|
11
|
+
- No auth required
|
|
12
|
+
|
|
13
|
+
Query endpoints:
|
|
14
|
+
- Health: GET ${config.otel.queryUrl}/api/health
|
|
15
|
+
- Services: GET ${config.otel.queryUrl}/api/services
|
|
16
|
+
- Traces: GET ${config.otel.queryUrl}/api/traces
|
|
17
|
+
- Trace search: GET ${config.otel.queryUrl}/api/traces/search
|
|
18
|
+
- Trace stats: GET ${config.otel.queryUrl}/api/traces/stats
|
|
19
|
+
- Trace spans: GET ${config.otel.queryUrl}/api/traces/<trace-id>/spans
|
|
20
|
+
- Trace logs: GET ${config.otel.queryUrl}/api/traces/<trace-id>/logs
|
|
21
|
+
- Span search: GET ${config.otel.queryUrl}/api/spans/search
|
|
22
|
+
- Span detail: GET ${config.otel.queryUrl}/api/spans/<span-id>
|
|
23
|
+
- Span logs: GET ${config.otel.queryUrl}/api/spans/<span-id>/logs
|
|
24
|
+
- Logs: GET ${config.otel.queryUrl}/api/logs
|
|
25
|
+
- Log search: GET ${config.otel.queryUrl}/api/logs/search
|
|
26
|
+
- Log stats: GET ${config.otel.queryUrl}/api/logs/stats
|
|
27
|
+
- AI calls: GET ${config.otel.queryUrl}/api/ai/calls
|
|
28
|
+
- AI call detail: GET ${config.otel.queryUrl}/api/ai/calls/<span-id>
|
|
29
|
+
- AI stats: GET ${config.otel.queryUrl}/api/ai/stats
|
|
30
|
+
- Facets: GET ${config.otel.queryUrl}/api/facets?type=logs&field=severity
|
|
31
|
+
- OpenAPI: GET ${config.otel.queryUrl}/openapi.json
|
|
32
|
+
- Docs: GET ${config.otel.queryUrl}/api/docs
|
|
33
|
+
|
|
34
|
+
Documentation:
|
|
35
|
+
- Debug workflow: GET ${config.otel.queryUrl}/api/docs/debug
|
|
36
|
+
- Effect guide: GET ${config.otel.queryUrl}/api/docs/effect
|
|
37
|
+
|
|
38
|
+
For full API details, query the OpenAPI spec at ${config.otel.queryUrl}/openapi.json.
|
|
39
|
+
For setup guidance with Effect or other frameworks, query ${config.otel.queryUrl}/api/docs/effect.
|
|
40
|
+
|
|
41
|
+
Debug workflow (hypothesis-driven):
|
|
42
|
+
|
|
43
|
+
1. Verify motel is running: curl ${config.otel.queryUrl}/api/health
|
|
44
|
+
2. Generate 3-5 hypotheses about why the bug occurs before touching code.
|
|
45
|
+
3. Add temporary instrumentation to confirm or reject all hypotheses.
|
|
46
|
+
- Use whatever tracing/logging the codebase already has (spans, structured logs, annotations).
|
|
47
|
+
- Tag every debug point with structured attributes: debug.session, debug.hypothesis, debug.step, debug.label.
|
|
48
|
+
- Wrap every temporary block in markers for cleanup:
|
|
49
|
+
// #region motel debug
|
|
50
|
+
// ... instrumentation ...
|
|
51
|
+
// #endregion motel debug
|
|
52
|
+
4. Reproduce the issue.
|
|
53
|
+
5. Query motel for evidence:
|
|
54
|
+
- curl "${config.otel.queryUrl}/api/spans/search?service=<svc>&attr.debug.hypothesis=<id>"
|
|
55
|
+
- curl "${config.otel.queryUrl}/api/spans/search?service=<svc>&traceId=<trace-id>&attrContains.ai.prompt=<phrase>"
|
|
56
|
+
- curl "${config.otel.queryUrl}/api/logs/search?service=<svc>&severity=ERROR&attr.debug.session=<session>"
|
|
57
|
+
- curl "${config.otel.queryUrl}/api/logs/search?service=<svc>&attrContains.debug.label=<substring>"
|
|
58
|
+
6. Evaluate each hypothesis (CONFIRMED / REJECTED / INCONCLUSIVE) with cited evidence.
|
|
59
|
+
7. Fix only with runtime evidence. Keep instrumentation during verification.
|
|
60
|
+
8. Reproduce again to verify the fix with before/after evidence.
|
|
61
|
+
9. If the fix failed, revert speculative changes, generate new hypotheses, and iterate.
|
|
62
|
+
10. After verified success, remove all #region motel debug blocks and confirm with git diff.
|
|
63
|
+
|
|
64
|
+
Rules:
|
|
65
|
+
- Never fix without runtime evidence.
|
|
66
|
+
- Do not remove instrumentation before verification succeeds.
|
|
67
|
+
- Do not log secrets, tokens, passwords, or PII.
|
|
68
|
+
- Revert code from rejected hypotheses — do not let unproven changes accumulate.
|
|
69
|
+
- Use attr.<key>=<value> for exact attribute match, attrContains.<key>=<substring> for case-insensitive substring search inside attribute values.
|
|
70
|
+
- /api/logs supports severity=ERROR|WARN|INFO|DEBUG|TRACE and case-insensitive body search.
|
|
71
|
+
- /api/spans/search supports traceId to scope to one trace.
|
|
72
|
+
- List and search responses include meta.nextCursor when more data is available.`
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { createDaemonManager, type DaemonManager, type DaemonStatus } from "./daemon.js"
|
|
4
|
+
import {
|
|
5
|
+
buildLaunchAgentSpec,
|
|
6
|
+
compareLaunchAgent,
|
|
7
|
+
createLaunchAgentManager,
|
|
8
|
+
createMotelLifecycle,
|
|
9
|
+
inspectLaunchAgentJson,
|
|
10
|
+
renderLaunchAgentPlist,
|
|
11
|
+
usesLaunchAgentRuntime,
|
|
12
|
+
type LaunchAgentManager,
|
|
13
|
+
type LaunchAgentOperations,
|
|
14
|
+
} from "./launchAgent.js"
|
|
15
|
+
|
|
16
|
+
const spec = buildLaunchAgentSpec({ home: "/Users/motel-test" })
|
|
17
|
+
|
|
18
|
+
const plistValue = () => ({
|
|
19
|
+
Label: spec.label,
|
|
20
|
+
ProgramArguments: [...spec.programArguments],
|
|
21
|
+
WorkingDirectory: spec.workingDirectory,
|
|
22
|
+
EnvironmentVariables: { ...spec.environment },
|
|
23
|
+
StandardOutPath: spec.logPath,
|
|
24
|
+
StandardErrorPath: spec.logPath,
|
|
25
|
+
RunAtLoad: true,
|
|
26
|
+
KeepAlive: true,
|
|
27
|
+
ProcessType: "Background",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const daemonStatus = (overrides: Partial<DaemonStatus> = {}): DaemonStatus => ({
|
|
31
|
+
running: true,
|
|
32
|
+
managed: true,
|
|
33
|
+
service: "motel-local-server",
|
|
34
|
+
pid: 123,
|
|
35
|
+
url: "http://127.0.0.1:27686",
|
|
36
|
+
databasePath: `${spec.workingDirectory}/telemetry.sqlite`,
|
|
37
|
+
workdir: spec.workingDirectory,
|
|
38
|
+
startedAt: "2026-07-23T00:00:00.000Z",
|
|
39
|
+
version: "0.2.6",
|
|
40
|
+
sameWorkdir: true,
|
|
41
|
+
reason: null,
|
|
42
|
+
logPath: `${spec.workingDirectory}/daemon.log`,
|
|
43
|
+
lockPath: `${spec.workingDirectory}/daemon.lock`,
|
|
44
|
+
registryPid: 123,
|
|
45
|
+
...overrides,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const makeHarness = (options: { readonly installed?: boolean; readonly missingExecutables?: readonly string[]; readonly plist?: Record<string, unknown>; readonly plutilExitCode?: number; readonly launchctlPrintExitCode?: number; readonly launchctlPrintStderr?: string; readonly bootoutExitCode?: number; readonly health?: DaemonStatus } = {}) => {
|
|
49
|
+
let installed = options.installed ?? false
|
|
50
|
+
let plist = options.plist ?? plistValue()
|
|
51
|
+
let launchctlPrintExitCode = options.launchctlPrintExitCode ?? 113
|
|
52
|
+
const calls: Array<readonly string[]> = []
|
|
53
|
+
const fileEvents: string[] = []
|
|
54
|
+
const files = new Map<string, string>()
|
|
55
|
+
const operations: LaunchAgentOperations = {
|
|
56
|
+
platform: "darwin",
|
|
57
|
+
exists: async (file) => file === spec.plistPath ? installed : !options.missingExecutables?.includes(file),
|
|
58
|
+
readFile: async (file) => files.get(file) ?? "",
|
|
59
|
+
mkdir: async () => {},
|
|
60
|
+
writeFile: async (file, contents) => { fileEvents.push("write"); files.set(file, contents) },
|
|
61
|
+
rename: async (_from, to) => { fileEvents.push("rename"); if (to === spec.plistPath) { installed = true; plist = plistValue() } },
|
|
62
|
+
unlink: async (file) => { fileEvents.push("unlink"); if (file === spec.plistPath) installed = false; files.delete(file) },
|
|
63
|
+
run: async (command, args) => {
|
|
64
|
+
calls.push([command, ...args])
|
|
65
|
+
if (command === "plutil") return { exitCode: options.plutilExitCode ?? 0, stdout: JSON.stringify(plist), stderr: "invalid plist" }
|
|
66
|
+
if (command === "launchctl" && args[0] === "print") return { exitCode: launchctlPrintExitCode, stdout: "", stderr: options.launchctlPrintStderr ?? (launchctlPrintExitCode === 113 ? "Could not find service" : "") }
|
|
67
|
+
if (command === "launchctl" && args[0] === "bootout") {
|
|
68
|
+
const exitCode = options.bootoutExitCode ?? 0
|
|
69
|
+
if (exitCode === 0) launchctlPrintExitCode = 113
|
|
70
|
+
return { exitCode, stdout: "", stderr: "could not boot out" }
|
|
71
|
+
}
|
|
72
|
+
if (command === "launchctl" && args[0] === "bootstrap") launchctlPrintExitCode = 0
|
|
73
|
+
return { exitCode: 0, stdout: "", stderr: "" }
|
|
74
|
+
},
|
|
75
|
+
getDaemonStatus: async () => options.health ?? daemonStatus(),
|
|
76
|
+
version: "0.2.6",
|
|
77
|
+
}
|
|
78
|
+
return { calls, fileEvents, manager: createLaunchAgentManager(spec, operations) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("LaunchAgent specification", () => {
|
|
82
|
+
test("renders the locked per-user contract with XML-safe values", () => {
|
|
83
|
+
const rendered = renderLaunchAgentPlist({ ...spec, label: "dev.motel<&\"'" })
|
|
84
|
+
expect(rendered).toContain("dev.motel<&"'")
|
|
85
|
+
expect(rendered).toContain(`<key>RunAtLoad</key>\n <true/>`)
|
|
86
|
+
expect(rendered).toContain(`<key>KeepAlive</key>\n <true/>`)
|
|
87
|
+
expect(spec.programArguments).toEqual(["/Users/motel-test/.bun/bin/bun", "/Users/motel-test/.bun/bin/motel", "server"])
|
|
88
|
+
expect(spec.environment.MOTEL_DAEMON_INSTANCE_ID).toBe("launchd-user-agent")
|
|
89
|
+
expect(spec.environment.MOTEL_RUNTIME_DIR).toBe("/Users/motel-test/.local/state/motel")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("compares structural plist JSON and ignores formatting or unrelated keys", () => {
|
|
93
|
+
const parsed = inspectLaunchAgentJson(JSON.stringify({ ...plistValue(), Comment: "formatting is not configuration" }))
|
|
94
|
+
expect(compareLaunchAgent(parsed, spec)).toEqual({ kind: "equivalent" })
|
|
95
|
+
const divergent = inspectLaunchAgentJson(JSON.stringify({ ...plistValue(), KeepAlive: false }))
|
|
96
|
+
expect(compareLaunchAgent(divergent, spec)).toEqual({ kind: "divergent", fields: ["KeepAlive"] })
|
|
97
|
+
expect(compareLaunchAgent(inspectLaunchAgentJson("not json"), spec).kind).toBe("malformed")
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("LaunchAgent lifecycle", () => {
|
|
102
|
+
test("installs atomically and keeps an equivalent loaded service as a no-op", async () => {
|
|
103
|
+
const harness = makeHarness()
|
|
104
|
+
const installed = await Effect.runPromise(harness.manager.install(false))
|
|
105
|
+
expect(installed.installed).toBe(true)
|
|
106
|
+
expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([
|
|
107
|
+
["launchctl", "print", spec.target],
|
|
108
|
+
["launchctl", "bootstrap", spec.domain, spec.plistPath],
|
|
109
|
+
["launchctl", "enable", spec.target],
|
|
110
|
+
["launchctl", "kickstart", "-k", spec.target],
|
|
111
|
+
["launchctl", "print", spec.target],
|
|
112
|
+
])
|
|
113
|
+
harness.calls.length = 0
|
|
114
|
+
await Effect.runPromise(harness.manager.install(false))
|
|
115
|
+
expect(harness.calls.filter(([command, action]) => command === "launchctl" && action !== "print")).toEqual([])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("reconciles an equivalent unloaded definition without rewriting it", async () => {
|
|
119
|
+
const harness = makeHarness({ installed: true, launchctlPrintExitCode: 113 })
|
|
120
|
+
await Effect.runPromise(harness.manager.install(false))
|
|
121
|
+
expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([
|
|
122
|
+
["launchctl", "print", spec.target],
|
|
123
|
+
["launchctl", "bootstrap", spec.domain, spec.plistPath],
|
|
124
|
+
["launchctl", "enable", spec.target],
|
|
125
|
+
["launchctl", "kickstart", "-k", spec.target],
|
|
126
|
+
["launchctl", "print", spec.target],
|
|
127
|
+
])
|
|
128
|
+
expect(harness.fileEvents).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test("refuses to load an equivalent definition when the linked executable is missing", async () => {
|
|
132
|
+
const harness = makeHarness({
|
|
133
|
+
installed: true,
|
|
134
|
+
launchctlPrintExitCode: 113,
|
|
135
|
+
missingExecutables: [spec.programArguments[1]!],
|
|
136
|
+
})
|
|
137
|
+
await expect(Effect.runPromise(harness.manager.install(false))).rejects.toThrow("bun link")
|
|
138
|
+
expect(harness.calls).toEqual([
|
|
139
|
+
["plutil", "-convert", "json", "-o", "-", spec.plistPath],
|
|
140
|
+
["launchctl", "print", spec.target],
|
|
141
|
+
])
|
|
142
|
+
expect(harness.fileEvents).toEqual([])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("refuses a divergent definition unless replace is explicit", async () => {
|
|
146
|
+
const harness = makeHarness({ installed: true, plist: { ...plistValue(), ProcessType: "Interactive" }, launchctlPrintExitCode: 0 })
|
|
147
|
+
await expect(Effect.runPromise(harness.manager.install(false))).rejects.toThrow("ProcessType")
|
|
148
|
+
expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([])
|
|
149
|
+
harness.calls.length = 0
|
|
150
|
+
await Effect.runPromise(harness.manager.install(true))
|
|
151
|
+
expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([
|
|
152
|
+
["launchctl", "print", spec.target],
|
|
153
|
+
["launchctl", "bootout", spec.target],
|
|
154
|
+
["launchctl", "bootstrap", spec.domain, spec.plistPath],
|
|
155
|
+
["launchctl", "enable", spec.target],
|
|
156
|
+
["launchctl", "kickstart", "-k", spec.target],
|
|
157
|
+
["launchctl", "print", spec.target],
|
|
158
|
+
])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("validates locked Bun and Motel executable paths before mutating the service", async () => {
|
|
162
|
+
const harness = makeHarness({ missingExecutables: [spec.programArguments[1]!] })
|
|
163
|
+
await expect(Effect.runPromise(harness.manager.install(false))).rejects.toThrow("bun link")
|
|
164
|
+
expect(harness.fileEvents).toEqual([])
|
|
165
|
+
expect(harness.calls).toEqual([["launchctl", "print", spec.target]])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("reports a plutil parse failure as a malformed configuration diagnostic", async () => {
|
|
169
|
+
const harness = makeHarness({ installed: true, plutilExitCode: 1, launchctlPrintExitCode: 113 })
|
|
170
|
+
const status = await Effect.runPromise(harness.manager.status)
|
|
171
|
+
expect(status.configuration).toBe("malformed")
|
|
172
|
+
expect(status.configurationDetails).toEqual(["invalid plist"])
|
|
173
|
+
expect(status.manager).toBe("not-loaded")
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("reports config, manager, health, registry, and version state without contaminating JSON", async () => {
|
|
177
|
+
const harness = makeHarness({ installed: true, launchctlPrintExitCode: 113, health: daemonStatus({ managed: false, version: "0.2.5" }) })
|
|
178
|
+
const status = await Effect.runPromise(harness.manager.status)
|
|
179
|
+
expect(status).toMatchObject({
|
|
180
|
+
installed: true,
|
|
181
|
+
manager: "not-loaded",
|
|
182
|
+
running: true,
|
|
183
|
+
registryIdentity: "unverified",
|
|
184
|
+
version: { cli: "0.2.6", server: "0.2.5", drift: true },
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test("uninstall only unloads and removes the plist", async () => {
|
|
189
|
+
const harness = makeHarness({ installed: true, launchctlPrintExitCode: 0 })
|
|
190
|
+
expect(await Effect.runPromise(harness.manager.uninstall)).toEqual({ removed: true })
|
|
191
|
+
expect(harness.calls).toEqual([
|
|
192
|
+
["launchctl", "print", spec.target],
|
|
193
|
+
["launchctl", "bootout", spec.target],
|
|
194
|
+
])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("does not replace or remove a loaded job after bootout failure", async () => {
|
|
198
|
+
const replacement = makeHarness({ installed: true, plist: { ...plistValue(), ProcessType: "Interactive" }, launchctlPrintExitCode: 0, bootoutExitCode: 1 })
|
|
199
|
+
await expect(Effect.runPromise(replacement.manager.install(true))).rejects.toThrow("could not boot out")
|
|
200
|
+
expect(replacement.fileEvents).toEqual([])
|
|
201
|
+
|
|
202
|
+
const removal = makeHarness({ installed: true, launchctlPrintExitCode: 0, bootoutExitCode: 1 })
|
|
203
|
+
await expect(Effect.runPromise(removal.manager.uninstall)).rejects.toThrow("could not boot out")
|
|
204
|
+
expect(removal.fileEvents).toEqual([])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test("keeps uninstall a no-op when neither plist nor job is present", async () => {
|
|
208
|
+
const harness = makeHarness({ installed: false, launchctlPrintExitCode: 113 })
|
|
209
|
+
expect(await Effect.runPromise(harness.manager.uninstall)).toEqual({ removed: false })
|
|
210
|
+
expect(harness.fileEvents).toEqual([])
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("bootstraps an unloaded service before restarting it", async () => {
|
|
214
|
+
const harness = makeHarness({ installed: true, launchctlPrintExitCode: 113 })
|
|
215
|
+
await Effect.runPromise(harness.manager.restart)
|
|
216
|
+
expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([
|
|
217
|
+
["launchctl", "print", spec.target],
|
|
218
|
+
["launchctl", "bootstrap", spec.domain, spec.plistPath],
|
|
219
|
+
["launchctl", "kickstart", "-k", spec.target],
|
|
220
|
+
["launchctl", "print", spec.target],
|
|
221
|
+
])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test("uninstalls a loaded orphan job even when its plist is already absent", async () => {
|
|
225
|
+
const harness = makeHarness({ installed: false, launchctlPrintExitCode: 0 })
|
|
226
|
+
expect(await Effect.runPromise(harness.manager.uninstall)).toEqual({ removed: true })
|
|
227
|
+
expect(harness.calls).toEqual([
|
|
228
|
+
["launchctl", "print", spec.target],
|
|
229
|
+
["launchctl", "bootout", spec.target],
|
|
230
|
+
])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test("requires replace and bootout before overwriting a loaded orphan job", async () => {
|
|
234
|
+
const harness = makeHarness({ installed: false, launchctlPrintExitCode: 0 })
|
|
235
|
+
await expect(Effect.runPromise(harness.manager.install(false))).rejects.toThrow("--replace")
|
|
236
|
+
expect(harness.fileEvents).toEqual([])
|
|
237
|
+
harness.calls.length = 0
|
|
238
|
+
await Effect.runPromise(harness.manager.install(true))
|
|
239
|
+
expect(harness.calls.filter(([command]) => command === "launchctl")).toEqual([
|
|
240
|
+
["launchctl", "print", spec.target],
|
|
241
|
+
["launchctl", "bootout", spec.target],
|
|
242
|
+
["launchctl", "bootstrap", spec.domain, spec.plistPath],
|
|
243
|
+
["launchctl", "enable", spec.target],
|
|
244
|
+
["launchctl", "kickstart", "-k", spec.target],
|
|
245
|
+
["launchctl", "print", spec.target],
|
|
246
|
+
])
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test("reports unknown launchctl inspection and refuses mutation", async () => {
|
|
250
|
+
const harness = makeHarness({ installed: true, plist: { ...plistValue(), ProcessType: "Interactive" }, launchctlPrintExitCode: 1, launchctlPrintStderr: "permission denied" })
|
|
251
|
+
expect((await Effect.runPromise(harness.manager.status)).manager).toBe("unknown")
|
|
252
|
+
await expect(Effect.runPromise(harness.manager.install(true))).rejects.toThrow("Unable to determine")
|
|
253
|
+
expect(harness.fileEvents).toEqual([])
|
|
254
|
+
|
|
255
|
+
const equivalent = makeHarness({ installed: true, launchctlPrintExitCode: 1, launchctlPrintStderr: "permission denied" })
|
|
256
|
+
await expect(Effect.runPromise(equivalent.manager.start)).rejects.toThrow("Unable to determine")
|
|
257
|
+
await expect(Effect.runPromise(equivalent.manager.stop)).rejects.toThrow("Unable to determine")
|
|
258
|
+
await expect(Effect.runPromise(equivalent.manager.restart)).rejects.toThrow("Unable to determine")
|
|
259
|
+
await expect(Effect.runPromise(equivalent.manager.uninstall)).rejects.toThrow("Unable to determine")
|
|
260
|
+
expect(equivalent.fileEvents).toEqual([])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test("fails service commands on non-macOS without attempting launchctl", async () => {
|
|
264
|
+
const harness = makeHarness()
|
|
265
|
+
const linuxManager = createLaunchAgentManager(spec, {
|
|
266
|
+
...({
|
|
267
|
+
platform: "linux",
|
|
268
|
+
exists: async () => false,
|
|
269
|
+
readFile: async () => "",
|
|
270
|
+
mkdir: async () => {},
|
|
271
|
+
writeFile: async () => {},
|
|
272
|
+
rename: async () => {},
|
|
273
|
+
unlink: async () => {},
|
|
274
|
+
run: async (command: string, args: readonly string[]) => {
|
|
275
|
+
harness.calls.push([command, ...args])
|
|
276
|
+
return { exitCode: 0, stdout: "", stderr: "" }
|
|
277
|
+
},
|
|
278
|
+
getDaemonStatus: async () => daemonStatus(),
|
|
279
|
+
version: "0.2.6",
|
|
280
|
+
} satisfies LaunchAgentOperations),
|
|
281
|
+
})
|
|
282
|
+
await expect(Effect.runPromise(linuxManager.status)).rejects.toThrow("only on macOS")
|
|
283
|
+
expect(harness.calls).toEqual([])
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
const fakeService = (input: { readonly installed: boolean; readonly configuration?: "missing" | "equivalent" | "divergent" | "malformed"; readonly manager?: "loaded" | "not-loaded" | "unknown" }, events: string[]): LaunchAgentManager => {
|
|
288
|
+
const status = Effect.succeed({
|
|
289
|
+
installed: input.installed,
|
|
290
|
+
configuration: input.configuration ?? "equivalent",
|
|
291
|
+
configurationDetails: [],
|
|
292
|
+
manager: input.manager ?? "loaded",
|
|
293
|
+
running: true,
|
|
294
|
+
health: daemonStatus(),
|
|
295
|
+
registryIdentity: "verified" as const,
|
|
296
|
+
version: { cli: "0.2.6", server: "0.2.6", drift: false },
|
|
297
|
+
})
|
|
298
|
+
return {
|
|
299
|
+
available: true,
|
|
300
|
+
inspect: Effect.succeed({ kind: "missing" as const }),
|
|
301
|
+
status,
|
|
302
|
+
install: () => status,
|
|
303
|
+
uninstall: Effect.succeed({ removed: true }),
|
|
304
|
+
start: Effect.sync(() => { events.push("service:start"); return Effect.runSync(status) }),
|
|
305
|
+
stop: Effect.sync(() => { events.push("service:stop"); return Effect.runSync(status) }),
|
|
306
|
+
restart: Effect.sync(() => { events.push("service:restart"); return Effect.runSync(status) }),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
describe("supervisor-aware top-level lifecycle", () => {
|
|
311
|
+
test("routes installed services to launchd actions rather than the detached manager", async () => {
|
|
312
|
+
const events: string[] = []
|
|
313
|
+
const daemon = {
|
|
314
|
+
applyEnv: Effect.void,
|
|
315
|
+
getStatus: Effect.sync(() => { events.push("daemon:status"); return daemonStatus() }),
|
|
316
|
+
ensure: Effect.sync(() => { events.push("daemon:start"); return daemonStatus() }),
|
|
317
|
+
stop: Effect.sync(() => { events.push("daemon:stop"); return daemonStatus() }),
|
|
318
|
+
} satisfies DaemonManager
|
|
319
|
+
const lifecycle = createMotelLifecycle({ service: fakeService({ installed: true }, events), daemon })
|
|
320
|
+
await Effect.runPromise(lifecycle.start)
|
|
321
|
+
await Effect.runPromise(lifecycle.stop)
|
|
322
|
+
await Effect.runPromise(lifecycle.restart)
|
|
323
|
+
expect(events).toEqual(["service:start", "service:stop", "service:restart"])
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test("keeps detached-manager lifecycle when no service definition is installed", async () => {
|
|
327
|
+
const events: string[] = []
|
|
328
|
+
const daemon = {
|
|
329
|
+
applyEnv: Effect.void,
|
|
330
|
+
getStatus: Effect.sync(() => { events.push("daemon:status"); return daemonStatus() }),
|
|
331
|
+
ensure: Effect.sync(() => { events.push("daemon:start"); return daemonStatus() }),
|
|
332
|
+
stop: Effect.sync(() => { events.push("daemon:stop"); return daemonStatus() }),
|
|
333
|
+
} satisfies DaemonManager
|
|
334
|
+
const lifecycle = createMotelLifecycle({ service: fakeService({ installed: false, configuration: "missing", manager: "not-loaded" }, events), daemon })
|
|
335
|
+
await Effect.runPromise(lifecycle.restart)
|
|
336
|
+
expect(events).toEqual(["daemon:stop", "daemon:start"])
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test("never falls through to PID control for divergent definitions or loaded orphan jobs", async () => {
|
|
340
|
+
for (const serviceState of [
|
|
341
|
+
{ installed: false, configuration: "divergent" as const, manager: "not-loaded" as const },
|
|
342
|
+
{ installed: false, configuration: "missing" as const, manager: "loaded" as const },
|
|
343
|
+
{ installed: false, configuration: "missing" as const, manager: "unknown" as const },
|
|
344
|
+
]) {
|
|
345
|
+
const events: string[] = []
|
|
346
|
+
const daemon = {
|
|
347
|
+
applyEnv: Effect.void,
|
|
348
|
+
getStatus: Effect.succeed(daemonStatus()),
|
|
349
|
+
ensure: Effect.sync(() => { events.push("daemon:start"); return daemonStatus() }),
|
|
350
|
+
stop: Effect.sync(() => { events.push("daemon:stop"); return daemonStatus() }),
|
|
351
|
+
} satisfies DaemonManager
|
|
352
|
+
const lifecycle = createMotelLifecycle({ service: fakeService(serviceState, events), daemon })
|
|
353
|
+
await expect(Effect.runPromise(lifecycle.stop)).rejects.toThrow("LaunchAgent")
|
|
354
|
+
expect(events).toEqual([])
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
test("uses detached management for an explicitly isolated runtime without consulting the LaunchAgent", async () => {
|
|
359
|
+
const events: string[] = []
|
|
360
|
+
const daemon = {
|
|
361
|
+
applyEnv: Effect.void,
|
|
362
|
+
getStatus: Effect.succeed(daemonStatus()),
|
|
363
|
+
ensure: Effect.sync(() => { events.push("daemon:start"); return daemonStatus() }),
|
|
364
|
+
stop: Effect.sync(() => { events.push("daemon:stop"); return daemonStatus() }),
|
|
365
|
+
} satisfies DaemonManager
|
|
366
|
+
const lifecycle = createMotelLifecycle({
|
|
367
|
+
service: fakeService({ installed: true }, events),
|
|
368
|
+
daemon,
|
|
369
|
+
runtimeDirectory: "/tmp/motel-release-isolation",
|
|
370
|
+
})
|
|
371
|
+
await Effect.runPromise(lifecycle.stop)
|
|
372
|
+
expect(events).toEqual(["daemon:stop"])
|
|
373
|
+
expect(usesLaunchAgentRuntime({ runtimeDirectory: "/tmp/motel-release-isolation" })).toBe(false)
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
test("uses detached management for every non-global effective daemon identity", () => {
|
|
377
|
+
expect(usesLaunchAgentRuntime({ environment: {} })).toBe(true)
|
|
378
|
+
for (const options of [
|
|
379
|
+
{ stateHome: "/tmp/motel-xdg" },
|
|
380
|
+
{ runtimeDirectory: "/tmp/motel-runtime" },
|
|
381
|
+
{ databasePath: "/tmp/motel.sqlite" },
|
|
382
|
+
{ baseUrl: "http://127.0.0.1:27687" },
|
|
383
|
+
{ queryUrl: "http://127.0.0.1:27687" },
|
|
384
|
+
{ host: "0.0.0.0" },
|
|
385
|
+
{ port: 27687 },
|
|
386
|
+
]) expect(usesLaunchAgentRuntime({ environment: {}, ...options })).toBe(false)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test("never consults the global LaunchAgent for a non-global top-level lifecycle identity", async () => {
|
|
390
|
+
for (const options of [
|
|
391
|
+
{ stateHome: "/tmp/motel-xdg" },
|
|
392
|
+
{ databasePath: "/tmp/motel.sqlite" },
|
|
393
|
+
{ baseUrl: "http://127.0.0.1:27687" },
|
|
394
|
+
{ queryUrl: "http://127.0.0.1:27687" },
|
|
395
|
+
{ host: "0.0.0.0" },
|
|
396
|
+
{ port: 27687 },
|
|
397
|
+
]) {
|
|
398
|
+
const events: string[] = []
|
|
399
|
+
const daemon = {
|
|
400
|
+
applyEnv: Effect.void,
|
|
401
|
+
getStatus: Effect.succeed(daemonStatus()),
|
|
402
|
+
ensure: Effect.sync(() => { events.push("daemon:start"); return daemonStatus() }),
|
|
403
|
+
stop: Effect.sync(() => { events.push("daemon:stop"); return daemonStatus() }),
|
|
404
|
+
} satisfies DaemonManager
|
|
405
|
+
const original = fakeService({ installed: true }, events)
|
|
406
|
+
const service = {
|
|
407
|
+
...original,
|
|
408
|
+
status: Effect.sync(() => {
|
|
409
|
+
events.push("service:status")
|
|
410
|
+
return Effect.runSync(original.status)
|
|
411
|
+
}),
|
|
412
|
+
}
|
|
413
|
+
await Effect.runPromise(createMotelLifecycle({ service, daemon, environment: {}, ...options }).stop)
|
|
414
|
+
expect(events).toEqual(["daemon:stop"])
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test("uses the detached manager on a non-macOS host without consulting launchctl", async () => {
|
|
419
|
+
const events: string[] = []
|
|
420
|
+
const daemon = {
|
|
421
|
+
applyEnv: Effect.void,
|
|
422
|
+
getStatus: Effect.succeed(daemonStatus()),
|
|
423
|
+
ensure: Effect.sync(() => { events.push("daemon:start"); return daemonStatus() }),
|
|
424
|
+
stop: Effect.sync(() => { events.push("daemon:stop"); return daemonStatus() }),
|
|
425
|
+
} satisfies DaemonManager
|
|
426
|
+
const service = { ...fakeService({ installed: false, configuration: "missing", manager: "not-loaded" }, events), available: false }
|
|
427
|
+
await Effect.runPromise(createMotelLifecycle({ service, daemon }).restart)
|
|
428
|
+
expect(events).toEqual(["daemon:stop", "daemon:start"])
|
|
429
|
+
})
|
|
430
|
+
})
|