@agentsfleet/orly 0.6.9 → 0.7.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/audits/agents-md.md +2 -2
- package/audits/logging.sh +242 -15
- package/dispatch/write_any.md +8 -5
- package/docs/LOGGING_STANDARD.md +71 -6
- package/docs/ORLY_ARCHITECTURE.md +19 -0
- package/docs/RULE_ENFORCEMENT.md +2 -2
- package/package.json +1 -1
- package/skills/orly-babysit-prs/SKILL.md +6 -0
- package/skills/orly-spec-new/SKILL.md +6 -0
- package/skills/orly-write-integration-test/SKILL.md +6 -0
- package/skills/orly-write-unit-test/SKILL.md +6 -0
- package/src/cli.test.ts +83 -2
- package/src/cli.ts +62 -18
- package/src/install.test.ts +3 -2
- package/src/install.ts +2 -2
- package/src/telemetry.test.ts +308 -0
- package/src/telemetry.ts +350 -0
- package/src/telemetry_sync.ts +134 -0
|
@@ -11,6 +11,12 @@ description: >
|
|
|
11
11
|
|
|
12
12
|
# Write Integration Test
|
|
13
13
|
|
|
14
|
+
At invocation start, record the consent-gated usage signal without blocking the skill:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
command -v orly >/dev/null 2>&1 && ORLY_INVOCATION=skill orly skill-event orly-write-integration-test >/dev/null 2>&1 || true
|
|
18
|
+
```
|
|
19
|
+
|
|
14
20
|
Service-layer tests that prove real wiring works under real failure modes. Sits between unit tests (`make test`) and browser E2E (gstack `/qa`, `/e2e-qa-playwright`).
|
|
15
21
|
|
|
16
22
|
> **What this guarantees / what it doesn't.** Integration tests don't prove "zero bugs
|
|
@@ -11,6 +11,12 @@ description: >
|
|
|
11
11
|
|
|
12
12
|
# Write Unit Test
|
|
13
13
|
|
|
14
|
+
At invocation start, record the consent-gated usage signal without blocking the skill:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
command -v orly >/dev/null 2>&1 && ORLY_INVOCATION=skill orly skill-event orly-write-unit-test >/dev/null 2>&1 || true
|
|
18
|
+
```
|
|
19
|
+
|
|
14
20
|
Generate tests that catch bugs before they ship. Tests run *during* implementation, not after.
|
|
15
21
|
|
|
16
22
|
> **What this guarantees / what it doesn't.** No test suite proves "zero bugs in
|
package/src/cli.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
|
|
6
6
|
const ROOT = resolve(import.meta.dir, "..");
|
|
7
7
|
const COMMAND = resolve(ROOT, "bin/orly");
|
|
@@ -18,6 +18,19 @@ describe("orly command", () => {
|
|
|
18
18
|
expect(output).not.toContain("oracle-rules");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
+
test("telemetry_help_names_tiers_storage_and_privacy", () => {
|
|
22
|
+
const result = Bun.spawnSync([COMMAND, "--help"], { cwd: ROOT, stdout: "pipe", stderr: "pipe" });
|
|
23
|
+
const output = result.stdout.toString();
|
|
24
|
+
|
|
25
|
+
expect(output).toContain("off by default");
|
|
26
|
+
expect(output).toContain("anonymous");
|
|
27
|
+
expect(output).toContain("~/.config/agentsfleet/orly/.orly.json");
|
|
28
|
+
expect(output).toContain("AGENTSFLEET_STATE_DIR");
|
|
29
|
+
expect(output).toContain("ORLY_TELEMETRY_OFF=1");
|
|
30
|
+
expect(output).toContain("packaged Orly skill");
|
|
31
|
+
expect(output).toContain("never collects source, paths, repositories, branches, arguments, prompts");
|
|
32
|
+
});
|
|
33
|
+
|
|
21
34
|
// Adversarial review found recordOptIn running before the --dry-run branch,
|
|
22
35
|
// so a preview created .oracle/orly.json — and on an installed repository
|
|
23
36
|
// permanently added the pack. A dry run changes nothing, opt-in included.
|
|
@@ -67,4 +80,72 @@ describe("orly command", () => {
|
|
|
67
80
|
|
|
68
81
|
expect(result.exitCode).toBe(0);
|
|
69
82
|
});
|
|
83
|
+
|
|
84
|
+
test("telemetry_failure_never_changes_command_result", () => {
|
|
85
|
+
const blockedRoot = join(mkdtempSync(join(tmpdir(), "orly-telemetry-blocked-")), "state-file");
|
|
86
|
+
writeFileSync(blockedRoot, "not a directory\n");
|
|
87
|
+
const enabled = { ...process.env, AGENTSFLEET_STATE_DIR: blockedRoot, ORLY_TELEMETRY: "anonymous" };
|
|
88
|
+
const off = { ...enabled, ORLY_TELEMETRY_OFF: "1" };
|
|
89
|
+
try {
|
|
90
|
+
const successWithFailure = Bun.spawnSync([COMMAND, "doctor"], { cwd: ROOT, env: enabled, stdout: "pipe", stderr: "pipe" });
|
|
91
|
+
const successWithOff = Bun.spawnSync([COMMAND, "doctor"], { cwd: ROOT, env: off, stdout: "pipe", stderr: "pipe" });
|
|
92
|
+
const errorWithFailure = Bun.spawnSync([COMMAND, "gate", "nope"], { cwd: ROOT, env: enabled, stdout: "pipe", stderr: "pipe" });
|
|
93
|
+
const errorWithOff = Bun.spawnSync([COMMAND, "gate", "nope"], { cwd: ROOT, env: off, stdout: "pipe", stderr: "pipe" });
|
|
94
|
+
|
|
95
|
+
expect(successWithFailure.exitCode).toBe(successWithOff.exitCode);
|
|
96
|
+
expect(successWithFailure.stdout.toString()).toBe(successWithOff.stdout.toString());
|
|
97
|
+
expect(errorWithFailure.exitCode).toBe(errorWithOff.exitCode);
|
|
98
|
+
expect(errorWithFailure.stderr.toString()).toBe(errorWithOff.stderr.toString());
|
|
99
|
+
} finally {
|
|
100
|
+
rmSync(dirname(blockedRoot), { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("gate_command_records_its_selected_gate_and_failed_criterion", async () => {
|
|
105
|
+
const root = mkdtempSync(join(tmpdir(), "orly-telemetry-command-"));
|
|
106
|
+
const repo = join(root, "repo");
|
|
107
|
+
const stateRoot = join(root, "state");
|
|
108
|
+
Bun.spawnSync(["git", "init", "-q", repo]);
|
|
109
|
+
writeFileSync(join(repo, "README.md"), "fixture\n");
|
|
110
|
+
Bun.spawnSync(["git", "-C", repo, "add", "README.md"]);
|
|
111
|
+
Bun.spawnSync(["git", "-C", repo, "-c", "user.name=Orly Test", "-c", "user.email=orly@example.invalid", "commit", "-q", "-m", "test: seed"]);
|
|
112
|
+
Bun.spawnSync(["git", "-C", repo, "checkout", "-q", "-b", "feat/telemetry"]);
|
|
113
|
+
const env = {
|
|
114
|
+
...process.env,
|
|
115
|
+
AGENTSFLEET_STATE_DIR: stateRoot,
|
|
116
|
+
ORLY_TELEMETRY: "anonymous",
|
|
117
|
+
ORLY_TELEMETRY_OFF: "0",
|
|
118
|
+
ORLY_POSTHOG_HOST: "http://127.0.0.1:1",
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
const result = Bun.spawnSync([COMMAND, "gate", "pr"], { cwd: repo, env, stdout: "pipe", stderr: "pipe" });
|
|
122
|
+
expect(result.exitCode).toBe(1);
|
|
123
|
+
const failedCriterion = result.stdout.toString().match(/🔴 ([^:]+):/)?.[1];
|
|
124
|
+
expect(failedCriterion).toBeDefined();
|
|
125
|
+
|
|
126
|
+
const spool = join(stateRoot, "orly/analytics/orly-usage.jsonl");
|
|
127
|
+
for (let attempt = 0; attempt < 20 && !existsSync(spool); attempt += 1) await Bun.sleep(10);
|
|
128
|
+
const event = JSON.parse(readFileSync(spool, "utf8"));
|
|
129
|
+
expect(event).toMatchObject({ command: "gate", gate: "pr", outcome: "error", failed_criterion: failedCriterion });
|
|
130
|
+
expect(event.duration_ms).toBeGreaterThanOrEqual(0);
|
|
131
|
+
} finally {
|
|
132
|
+
rmSync(root, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("skill_event_accepts_only_packaged_skills", () => {
|
|
137
|
+
const env = { ...process.env, ORLY_TELEMETRY_OFF: "1", ORLY_INVOCATION: "skill" };
|
|
138
|
+
const packagedSkills = ["orly-babysit-prs", "orly-spec-new", "orly-write-integration-test", "orly-write-unit-test"];
|
|
139
|
+
for (const skill of packagedSkills) {
|
|
140
|
+
const accepted = Bun.spawnSync([COMMAND, "skill-event", skill], { cwd: ROOT, env, stdout: "pipe", stderr: "pipe" });
|
|
141
|
+
expect(accepted.exitCode).toBe(0);
|
|
142
|
+
expect(accepted.stdout.toString()).toBe("");
|
|
143
|
+
expect(readFileSync(join(ROOT, "skills", skill, "SKILL.md"), "utf8")).toContain(`orly skill-event ${skill}`);
|
|
144
|
+
}
|
|
145
|
+
const rejected = Bun.spawnSync([COMMAND, "skill-event", "other-skill"], { cwd: ROOT, env, stdout: "pipe", stderr: "pipe" });
|
|
146
|
+
|
|
147
|
+
expect(rejected.exitCode).toBe(1);
|
|
148
|
+
expect(rejected.stderr.toString()).toContain("skill-event requires a packaged Orly skill name");
|
|
149
|
+
expect(Bun.spawnSync([COMMAND, "--help"], { cwd: ROOT, env, stdout: "pipe" }).stdout.toString()).not.toContain("skill-event");
|
|
150
|
+
});
|
|
70
151
|
});
|
package/src/cli.ts
CHANGED
|
@@ -7,8 +7,11 @@ import { GateReport, isGateName, recordOverride, runGate, runGates } from "./gat
|
|
|
7
7
|
import { install, InstallResult } from "./install";
|
|
8
8
|
import { isString, OrlyError, readJsonObject, RulesModel } from "./model";
|
|
9
9
|
import { Renderer } from "./render";
|
|
10
|
+
import { beginTelemetry, installedOrlyVersion, isTelemetrySkill, recordTelemetry } from "./telemetry";
|
|
10
11
|
import { verifyRenders, writeEvidence } from "./verify";
|
|
11
12
|
|
|
13
|
+
type CliResult = { exitCode: number; gate?: string; failedCriterion?: string };
|
|
14
|
+
|
|
12
15
|
const PASS_RESULT = "pass";
|
|
13
16
|
const NOT_REQUIRED_RESULT = "not-required";
|
|
14
17
|
const ACCEPT_DIRTY_FLAG = "--accept-dirty";
|
|
@@ -26,42 +29,61 @@ const FAIL_GLYPH = "🔴";
|
|
|
26
29
|
const PR_GATE = "pr";
|
|
27
30
|
const ENGINE_MARKER = "evals/install/run.sh";
|
|
28
31
|
const VERIFY_COMMAND = "verify";
|
|
32
|
+
const SKILL_EVENT_COMMAND = "skill-event";
|
|
33
|
+
const TELEMETRY_CONFIG_KEY = "telemetry";
|
|
34
|
+
const ANONYMOUS_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "anonymous" });
|
|
35
|
+
const OFF_TELEMETRY_CONFIG = JSON.stringify({ [TELEMETRY_CONFIG_KEY]: "off" });
|
|
29
36
|
|
|
30
37
|
const { root, arguments: commandArguments } = parseRoot(Bun.argv.slice(2));
|
|
38
|
+
const startedAt = performance.now();
|
|
39
|
+
const telemetry = await beginTelemetry(commandArguments);
|
|
40
|
+
const result = await execute(root, commandArguments);
|
|
41
|
+
await recordTelemetry(telemetry, {
|
|
42
|
+
...(result.gate ? { gate: result.gate } : {}),
|
|
43
|
+
outcome: result.exitCode === 0 ? "success" : "error",
|
|
44
|
+
...(result.failedCriterion ? { failedCriterion: result.failedCriterion } : {}),
|
|
45
|
+
durationMs: performance.now() - startedAt,
|
|
46
|
+
version: await installedOrlyVersion(root),
|
|
47
|
+
});
|
|
48
|
+
process.exit(result.exitCode);
|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
async function execute(engineRoot: string, args: string[]): Promise<CliResult> {
|
|
51
|
+
try {
|
|
52
|
+
return await run(await RulesModel.load(engineRoot), args);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
console.error(`orly: ${message}`);
|
|
56
|
+
return cliResult(1);
|
|
57
|
+
}
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
async function run(model: RulesModel, args: string[]): Promise<
|
|
60
|
+
async function run(model: RulesModel, args: string[]): Promise<CliResult> {
|
|
43
61
|
const [command, ...rest] = args;
|
|
44
62
|
if (!command || command === "--help" || command === "-h") {
|
|
45
63
|
printHelp();
|
|
46
|
-
return command ? 0 : 1;
|
|
64
|
+
return cliResult(command ? 0 : 1);
|
|
47
65
|
}
|
|
48
|
-
if (command === "--version" || command === "-v") return printVersion(model);
|
|
66
|
+
if (command === "--version" || command === "-v") return cliResult(await printVersion(model));
|
|
49
67
|
if (command === "doctor") {
|
|
50
68
|
model.validate();
|
|
51
69
|
requireNoArguments(rest, "doctor checks the installed ruleset: orly doctor");
|
|
52
|
-
return doctorGlobal(model);
|
|
70
|
+
return cliResult(await doctorGlobal(model));
|
|
53
71
|
}
|
|
54
|
-
if (command === "init") return materialise(model, rest, true);
|
|
55
|
-
if (command === "update") return materialise(model, rest, false);
|
|
56
|
-
if (command === VERIFY_COMMAND) return verify(model, rest);
|
|
72
|
+
if (command === "init") return cliResult(await materialise(model, rest, true));
|
|
73
|
+
if (command === "update") return cliResult(await materialise(model, rest, false));
|
|
74
|
+
if (command === VERIFY_COMMAND) return cliResult(await verify(model, rest));
|
|
57
75
|
if (command === "gate") return gate(model, rest);
|
|
58
|
-
if (command === "override") return override(rest);
|
|
76
|
+
if (command === "override") return cliResult(override(rest));
|
|
77
|
+
if (command === SKILL_EVENT_COMMAND) {
|
|
78
|
+
if (rest.length !== 1 || !isTelemetrySkill(rest[0])) throw new OrlyError("skill-event requires a packaged Orly skill name");
|
|
79
|
+
return cliResult(0);
|
|
80
|
+
}
|
|
59
81
|
throw new OrlyError(`unknown command: ${command}`);
|
|
60
82
|
}
|
|
61
83
|
|
|
62
84
|
// Read-only: run every gate in order (stop at the first red group), or one
|
|
63
85
|
// named gate. Nothing is ever written.
|
|
64
|
-
function gate(model: RulesModel, args: string[]):
|
|
86
|
+
function gate(model: RulesModel, args: string[]): CliResult {
|
|
65
87
|
const acceptDirty = args.includes(ACCEPT_DIRTY_FLAG);
|
|
66
88
|
const named = args.find((argument) => !argument.startsWith("-"));
|
|
67
89
|
if (named !== undefined && !isGateName(named)) throw new OrlyError(`unknown gate: ${named} (work, verify, pr)`);
|
|
@@ -69,7 +91,14 @@ function gate(model: RulesModel, args: string[]): number {
|
|
|
69
91
|
for (const report of reports) printGate(report);
|
|
70
92
|
const allGreen = reports.every((report) => report.ok);
|
|
71
93
|
if (allGreen && (named === undefined || named === PR_GATE)) console.log(`${PASS_GLYPH} PR boundary open — CHORE(close) is the next motion`);
|
|
72
|
-
|
|
94
|
+
const failedReport = reports.find((report) => !report.ok);
|
|
95
|
+
const failedCriterion = failedReport?.results.find((result) => !result.ok)?.name;
|
|
96
|
+
const selectedGate = named ?? failedReport?.gate;
|
|
97
|
+
return {
|
|
98
|
+
exitCode: allGreen ? 0 : 1,
|
|
99
|
+
...(selectedGate ? { gate: selectedGate } : {}),
|
|
100
|
+
...(failedCriterion ? { failedCriterion } : {}),
|
|
101
|
+
};
|
|
73
102
|
}
|
|
74
103
|
|
|
75
104
|
function override(args: string[]): number {
|
|
@@ -254,6 +283,10 @@ function optionalValues(args: string[], name: string): string[] {
|
|
|
254
283
|
return values;
|
|
255
284
|
}
|
|
256
285
|
|
|
286
|
+
function cliResult(exitCode: number): CliResult {
|
|
287
|
+
return { exitCode };
|
|
288
|
+
}
|
|
289
|
+
|
|
257
290
|
function printHelp(): void {
|
|
258
291
|
console.log(`orly — prove the boundary; carry the rules
|
|
259
292
|
|
|
@@ -281,6 +314,17 @@ Install (the repository is the unit — no checkout of this package required):
|
|
|
281
314
|
against what orly would write today
|
|
282
315
|
orly --version the installed package version
|
|
283
316
|
|
|
317
|
+
Usage telemetry (off by default):
|
|
318
|
+
Consent file: ~/.config/agentsfleet/orly/.orly.json
|
|
319
|
+
State-root override: AGENTSFLEET_STATE_DIR
|
|
320
|
+
Set ${ANONYMOUS_TELEMETRY_CONFIG} to send command, gate, and packaged Orly skill
|
|
321
|
+
names, outcome, failed criterion, duration, Orly version, operating system, architecture,
|
|
322
|
+
invocation type, timestamps, and random event, session, and installation IDs.
|
|
323
|
+
Set ${OFF_TELEMETRY_CONFIG} to write and send nothing.
|
|
324
|
+
ORLY_TELEMETRY=off|anonymous changes one process; ORLY_TELEMETRY_OFF=1 wins.
|
|
325
|
+
Orly never collects source, paths, repositories, branches, arguments, prompts,
|
|
326
|
+
output, environment values, raw errors, or personal and account details.
|
|
327
|
+
|
|
284
328
|
Ruleset authoring (an orly checkout only — refused from an installed package):
|
|
285
329
|
orly verify the rules render the same twice, and the
|
|
286
330
|
committed copy matches that render`);
|
package/src/install.test.ts
CHANGED
|
@@ -237,7 +237,7 @@ describe("install", () => {
|
|
|
237
237
|
expect(existsSync(join(repo, "AGENTS.md"))).toBe(false);
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
-
test("
|
|
240
|
+
test("generated_hooks_mark_telemetry_invocation", async () => {
|
|
241
241
|
const model = await RulesModel.load(ROOT);
|
|
242
242
|
const repo = newRepository();
|
|
243
243
|
await Bun.write(join(repo, ".githooks/pre-commit"), "#!/usr/bin/env bash\necho 'ours'\n");
|
|
@@ -245,7 +245,8 @@ describe("install", () => {
|
|
|
245
245
|
const result = await install(model, { targetRoot: repo, force: true, installHooks: true, orlyVersion: "0.4.0" });
|
|
246
246
|
|
|
247
247
|
expect(result.ok).toBe(true);
|
|
248
|
-
expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toContain("
|
|
248
|
+
expect(await Bun.file(join(repo, ".githooks/pre-commit")).text()).toContain("export ORLY_INVOCATION=hook");
|
|
249
|
+
expect(await Bun.file(join(repo, ".githooks/pre-push")).text()).toContain("export ORLY_INVOCATION=hook");
|
|
249
250
|
});
|
|
250
251
|
|
|
251
252
|
test("--no-hooks installs the rules over an existing hook without touching it", async () => {
|
package/src/install.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { CONFIG_PATH, readConfig, seedConfig, selectPacks, writeConfig } from ".
|
|
|
6
6
|
import { applyMode, assertWritableInside, hashContent, isBelow, isString, JsonObject, modeLabel, objectArray, objectValue, OrlyError, RulesModel, stringArray } from "./model";
|
|
7
7
|
import { referenceClosureErrors, renderProfileText } from "./references";
|
|
8
8
|
import { Renderer } from "./render";
|
|
9
|
+
import { HOOK_INVOCATION_ENV, HOOK_INVOCATION_VALUE } from "./telemetry";
|
|
9
10
|
|
|
10
11
|
const AGENTS_FILENAME = "AGENTS.md";
|
|
11
12
|
const ORLY_AGENTS_FILENAME = "AGENTS.orly.md";
|
|
@@ -277,12 +278,11 @@ function hookScript(gate: string): string {
|
|
|
277
278
|
" exit 1",
|
|
278
279
|
"fi",
|
|
279
280
|
"",
|
|
281
|
+
`export ${HOOK_INVOCATION_ENV}=${HOOK_INVOCATION_VALUE}`,
|
|
280
282
|
`exec orly gate ${gate}`,
|
|
281
283
|
"",
|
|
282
284
|
].join("\n");
|
|
283
285
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
286
|
// One entry per target, so two packs naming the same file collapse instead of
|
|
287
287
|
// racing. Two packs naming DIFFERENT sources for one target is a registry bug
|
|
288
288
|
// and stops the install rather than letting pack order decide the winner.
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { TelemetryEvent } from "./telemetry";
|
|
7
|
+
import {
|
|
8
|
+
beginTelemetry,
|
|
9
|
+
parseTelemetryEvent,
|
|
10
|
+
readTelemetrySpool,
|
|
11
|
+
recordTelemetry,
|
|
12
|
+
replaceTelemetrySpool,
|
|
13
|
+
resolveTelemetryConsent,
|
|
14
|
+
TELEMETRY_PROMPT,
|
|
15
|
+
withSpoolLock,
|
|
16
|
+
} from "./telemetry";
|
|
17
|
+
import { syncTelemetry } from "./telemetry_sync";
|
|
18
|
+
|
|
19
|
+
const FIXED_TIME = new Date("2026-08-25T12:00:00.000Z");
|
|
20
|
+
const FIVE_MINUTES_MILLISECONDS = 5 * 60 * 1_000;
|
|
21
|
+
const MAX_SPOOL_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
const STATE_ENV = "AGENTSFLEET_STATE_DIR";
|
|
23
|
+
const ANONYMOUS_ENV = "ORLY_TELEMETRY";
|
|
24
|
+
const PRIVATE_SENTINEL = "private-context-sentinel";
|
|
25
|
+
const temporaryRoots: string[] = [];
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("telemetry consent", () => {
|
|
32
|
+
test("resolves_telemetry_consent_in_precedence_order", () => {
|
|
33
|
+
expect(resolveTelemetryConsent({ ORLY_TELEMETRY_OFF: "1", ORLY_TELEMETRY: "anonymous" }, "anonymous")).toBe("off");
|
|
34
|
+
expect(resolveTelemetryConsent({ ORLY_TELEMETRY: "off" }, "anonymous")).toBe("off");
|
|
35
|
+
expect(resolveTelemetryConsent({ ORLY_TELEMETRY: "anonymous" }, "off")).toBe("anonymous");
|
|
36
|
+
expect(resolveTelemetryConsent({ ORLY_TELEMETRY: "invalid" }, "anonymous")).toBe("off");
|
|
37
|
+
expect(resolveTelemetryConsent({}, "anonymous")).toBe("anonymous");
|
|
38
|
+
expect(resolveTelemetryConsent({})).toBe("off");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("prompts_once_and_marks_aborted_prompt", async () => {
|
|
42
|
+
const root = temporaryRoot();
|
|
43
|
+
let prompts = 0;
|
|
44
|
+
const options = {
|
|
45
|
+
env: { [STATE_ENV]: root }, stdinIsTty: true, stdoutIsTty: true,
|
|
46
|
+
ask: () => { prompts += 1; return null; },
|
|
47
|
+
};
|
|
48
|
+
expect(await beginTelemetry(["doctor"], options)).toBeUndefined();
|
|
49
|
+
expect(await beginTelemetry(["doctor"], options)).toBeUndefined();
|
|
50
|
+
expect(prompts).toBe(1);
|
|
51
|
+
expect(await Bun.file(join(root, "orly/.telemetry-prompted")).exists()).toBe(true);
|
|
52
|
+
expect(await Bun.file(join(root, "orly/.orly.json")).json()).toEqual({ telemetry: "off" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("anonymous_opt_in_persists_identity_and_does_not_reprompt", async () => {
|
|
56
|
+
const root = temporaryRoot();
|
|
57
|
+
let prompts = 0;
|
|
58
|
+
const options = {
|
|
59
|
+
env: { [STATE_ENV]: root }, stdinIsTty: true, stdoutIsTty: true,
|
|
60
|
+
ask: () => { prompts += 1; return "2"; }, randomId: idSequence(40, 41, 42),
|
|
61
|
+
};
|
|
62
|
+
const first = await beginTelemetry(["doctor"], options);
|
|
63
|
+
const second = await beginTelemetry(["doctor"], options);
|
|
64
|
+
|
|
65
|
+
expect(prompts).toBe(1);
|
|
66
|
+
expect(first).toMatchObject({ command: "doctor", invocation: "direct", installationId: uuid(41) });
|
|
67
|
+
expect(second?.installationId).toBe(first?.installationId);
|
|
68
|
+
expect(await Bun.file(join(root, "orly/.orly.json")).json()).toEqual({ telemetry: "anonymous" });
|
|
69
|
+
expect(await Bun.file(join(root, "orly/installation-id")).text()).toBe(`${uuid(41)}\n`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("automation_never_prompts_for_telemetry", async () => {
|
|
73
|
+
const root = temporaryRoot();
|
|
74
|
+
let prompts = 0;
|
|
75
|
+
const ask = () => { prompts += 1; return "2"; };
|
|
76
|
+
await beginTelemetry(["gate"], { env: { [STATE_ENV]: root, ORLY_INVOCATION: "hook" }, stdinIsTty: true, stdoutIsTty: true, ask });
|
|
77
|
+
await beginTelemetry(["gate"], { env: { [STATE_ENV]: root, CI: "true" }, stdinIsTty: true, stdoutIsTty: true, ask });
|
|
78
|
+
await beginTelemetry(["gate"], { env: { [STATE_ENV]: root }, stdinIsTty: false, stdoutIsTty: false, ask });
|
|
79
|
+
await beginTelemetry(["skill-event", "orly-spec-new"], { env: { [STATE_ENV]: root, ORLY_INVOCATION: "skill" }, stdinIsTty: true, stdoutIsTty: true, ask });
|
|
80
|
+
expect(prompts).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("consent_prompt_discloses_complete_collection_boundary", () => {
|
|
84
|
+
for (const phrase of [
|
|
85
|
+
"command, gate, and packaged Orly skill names", "failed gate criterion", "duration, Orly version", "installation IDs", "persists",
|
|
86
|
+
"source code, file contents, prompts", "file paths, working directory", "environment variables", "raw error messages",
|
|
87
|
+
"name, email, username, hostname", "Off", "default", "Anonymous",
|
|
88
|
+
]) expect(TELEMETRY_PROMPT).toContain(phrase);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("telemetry events", () => {
|
|
93
|
+
test("appends_one_valid_event_per_command", async () => {
|
|
94
|
+
const root = temporaryRoot();
|
|
95
|
+
const session = await anonymousSession(root, idSequence(1, 2));
|
|
96
|
+
let launches = 0;
|
|
97
|
+
await recordTelemetry(session, observation(), { now: () => FIXED_TIME, randomId: () => uuid(3), launchSync: () => { launches += 1; } });
|
|
98
|
+
const events = await readTelemetrySpool(join(root, "orly"), FIXED_TIME);
|
|
99
|
+
expect(events).toHaveLength(1);
|
|
100
|
+
expect(events[0]).toMatchObject({ command: "gate", gate: "verify", outcome: "error", failed_criterion: "verify.unit" });
|
|
101
|
+
expect(launches).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("anonymous_installation_identity_is_stable_and_random", async () => {
|
|
105
|
+
const root = temporaryRoot();
|
|
106
|
+
const first = await anonymousSession(root, idSequence(10, 11));
|
|
107
|
+
const second = await anonymousSession(root, idSequence(12, 13));
|
|
108
|
+
expect(first?.installationId).toBe(uuid(11));
|
|
109
|
+
expect(second?.installationId).toBe(first?.installationId);
|
|
110
|
+
expect(first?.installationId).not.toContain(process.env.USER ?? PRIVATE_SENTINEL);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("event_schema_rejects_private_context", async () => {
|
|
114
|
+
const root = temporaryRoot();
|
|
115
|
+
const session = await beginTelemetry(["gate", PRIVATE_SENTINEL], {
|
|
116
|
+
env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous", PRIVATE_VALUE: PRIVATE_SENTINEL },
|
|
117
|
+
stdinIsTty: false, stdoutIsTty: false, randomId: idSequence(20, 21),
|
|
118
|
+
});
|
|
119
|
+
await recordTelemetry(session, observation(), { now: () => FIXED_TIME, randomId: () => uuid(22), launchSync: () => undefined });
|
|
120
|
+
const text = await Bun.file(join(root, "orly/analytics/orly-usage.jsonl")).text();
|
|
121
|
+
expect(text).not.toContain(PRIVATE_SENTINEL);
|
|
122
|
+
const value = JSON.parse(text);
|
|
123
|
+
expect(parseTelemetryEvent(JSON.stringify({ ...value, repository: PRIVATE_SENTINEL }))).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("packaged_skill_uses_the_command_spool", async () => {
|
|
127
|
+
const root = temporaryRoot();
|
|
128
|
+
const session = await beginTelemetry(["skill-event", "orly-write-unit-test"], {
|
|
129
|
+
env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous", ORLY_INVOCATION: "skill" },
|
|
130
|
+
stdinIsTty: true, stdoutIsTty: true, randomId: idSequence(30, 31),
|
|
131
|
+
});
|
|
132
|
+
await recordTelemetry(session, { outcome: "success", durationMs: 1, version: "0.6.9" }, {
|
|
133
|
+
now: () => FIXED_TIME, randomId: () => uuid(32), launchSync: () => undefined,
|
|
134
|
+
});
|
|
135
|
+
const events = await readTelemetrySpool(join(root, "orly"), FIXED_TIME);
|
|
136
|
+
expect(events).toHaveLength(1);
|
|
137
|
+
expect(events[0]).toMatchObject({ command: "skill", skill: "orly-write-unit-test", invocation: "skill" });
|
|
138
|
+
expect(parseTelemetryEvent(JSON.stringify({ ...events[0], skill: "unknown-skill" }))).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("spool_lock_drops_contention_without_losing_the_owner_write", async () => {
|
|
142
|
+
const root = join(temporaryRoot(), "orly");
|
|
143
|
+
let contenderResult: boolean | undefined;
|
|
144
|
+
const ownerResult = await withSpoolLock(root, async () => {
|
|
145
|
+
contenderResult = await withSpoolLock(root, async () => {
|
|
146
|
+
throw new Error("contending writer must not run");
|
|
147
|
+
});
|
|
148
|
+
await replaceTelemetrySpool(root, [event(450)], FIXED_TIME);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(ownerResult).toBe(true);
|
|
152
|
+
expect(contenderResult).toBe(false);
|
|
153
|
+
expect((await readTelemetrySpool(root, FIXED_TIME)).map((entry) => entry.event_id)).toEqual([uuid(450)]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("telemetry synchronization", () => {
|
|
158
|
+
test("sync_is_consent_gated_and_schema_safe", async () => {
|
|
159
|
+
const root = temporaryRoot();
|
|
160
|
+
const stateRoot = join(root, "orly");
|
|
161
|
+
await replaceTelemetrySpool(stateRoot, [event(1)], FIXED_TIME);
|
|
162
|
+
const bodies: unknown[] = [];
|
|
163
|
+
const fetchImpl = captureFetch(bodies);
|
|
164
|
+
await syncTelemetry({ env: { [STATE_ENV]: root, ORLY_TELEMETRY_OFF: "1" }, now: () => FIXED_TIME, fetchImpl });
|
|
165
|
+
expect(bodies).toHaveLength(0);
|
|
166
|
+
await syncTelemetry({ env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, now: () => FIXED_TIME, fetchImpl });
|
|
167
|
+
const sent = postHogBatch(bodies[0]);
|
|
168
|
+
expect(sent).toHaveLength(1);
|
|
169
|
+
const properties = postHogProperties(sent[0]);
|
|
170
|
+
expect(Object.keys(properties).sort()).toEqual([
|
|
171
|
+
"$insert_id", "arch", "command", "distinct_id", "duration_ms", "event_id", "installation_id", "invocation",
|
|
172
|
+
"orly_version", "os", "outcome", "schema_version", "session_id",
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("sync_spaces_attempts_and_bounds_batches", async () => {
|
|
177
|
+
const root = temporaryRoot();
|
|
178
|
+
const stateRoot = join(root, "orly");
|
|
179
|
+
await replaceTelemetrySpool(stateRoot, Array.from({ length: 101 }, (_, index) => event(index + 1)), FIXED_TIME);
|
|
180
|
+
const bodies: unknown[] = [];
|
|
181
|
+
const options = { env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, now: () => FIXED_TIME, fetchImpl: captureFetch(bodies) };
|
|
182
|
+
await syncTelemetry(options);
|
|
183
|
+
await syncTelemetry(options);
|
|
184
|
+
expect(bodies).toHaveLength(1);
|
|
185
|
+
expect(postHogBatch(bodies[0])).toHaveLength(100);
|
|
186
|
+
expect((await readTelemetrySpool(stateRoot, FIXED_TIME)).map((entry) => entry.event_id)).toEqual([uuid(101)]);
|
|
187
|
+
expect(await Bun.file(join(stateRoot, "analytics/.last-sync-time")).text()).toBe(`${FIXED_TIME.toISOString()}\n`);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("sync_retries_without_losing_or_renaming_events", async () => {
|
|
191
|
+
const root = temporaryRoot();
|
|
192
|
+
const stateRoot = join(root, "orly");
|
|
193
|
+
const original = [event(201), event(202)];
|
|
194
|
+
await replaceTelemetrySpool(stateRoot, original, FIXED_TIME);
|
|
195
|
+
const failedBodies: unknown[] = [];
|
|
196
|
+
const env = { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" };
|
|
197
|
+
await syncTelemetry({ env, now: () => FIXED_TIME, fetchImpl: failingFetch(failedBodies) });
|
|
198
|
+
expect((await readTelemetrySpool(stateRoot, FIXED_TIME)).map((entry) => entry.event_id)).toEqual(original.map((entry) => entry.event_id));
|
|
199
|
+
const retryTime = new Date(FIXED_TIME.getTime() + FIVE_MINUTES_MILLISECONDS + 1);
|
|
200
|
+
const successBodies: unknown[] = [];
|
|
201
|
+
await syncTelemetry({ env, now: () => retryTime, fetchImpl: captureFetch(successBodies) });
|
|
202
|
+
expect(postHogIds(successBodies[0])).toEqual(postHogIds(failedBodies[0]));
|
|
203
|
+
expect(await readTelemetrySpool(stateRoot, retryTime)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("sync_retains_events_when_fetch_rejects", async () => {
|
|
207
|
+
const root = temporaryRoot();
|
|
208
|
+
const stateRoot = join(root, "orly");
|
|
209
|
+
const original = [event(250)];
|
|
210
|
+
await replaceTelemetrySpool(stateRoot, original, FIXED_TIME);
|
|
211
|
+
|
|
212
|
+
await syncTelemetry({
|
|
213
|
+
env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" },
|
|
214
|
+
now: () => FIXED_TIME,
|
|
215
|
+
fetchImpl: async () => { throw new Error("network unavailable"); },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect((await readTelemetrySpool(stateRoot, FIXED_TIME)).map((entry) => entry.event_id)).toEqual(original.map((entry) => entry.event_id));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("sync_compacts_and_bounds_local_spool", async () => {
|
|
222
|
+
const root = temporaryRoot();
|
|
223
|
+
const stateRoot = join(root, "orly");
|
|
224
|
+
const old = new Date(FIXED_TIME.getTime() - 8 * 24 * 60 * 60 * 1_000).toISOString();
|
|
225
|
+
const large = Array.from({ length: 20 }, (_, index) => ({ ...event(index + 300), orly_version: "x".repeat(600_000) }));
|
|
226
|
+
await replaceTelemetrySpool(stateRoot, [event(299, old), ...large], FIXED_TIME);
|
|
227
|
+
const path = join(stateRoot, "analytics/orly-usage.jsonl");
|
|
228
|
+
expect(statSync(path).size).toBeLessThanOrEqual(MAX_SPOOL_BYTES);
|
|
229
|
+
const events = await readTelemetrySpool(stateRoot, FIXED_TIME);
|
|
230
|
+
expect(events.some((entry) => entry.event_id === uuid(299))).toBe(false);
|
|
231
|
+
expect(events.at(-1)?.event_id).toBe(uuid(319));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("sync_names_skill_events_separately", async () => {
|
|
235
|
+
const root = temporaryRoot();
|
|
236
|
+
const stateRoot = join(root, "orly");
|
|
237
|
+
await replaceTelemetrySpool(stateRoot, [{ ...event(400), command: "skill", skill: "orly-spec-new", invocation: "skill" }], FIXED_TIME);
|
|
238
|
+
const bodies: unknown[] = [];
|
|
239
|
+
await syncTelemetry({
|
|
240
|
+
env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, now: () => FIXED_TIME, fetchImpl: captureFetch(bodies),
|
|
241
|
+
});
|
|
242
|
+
const sent = postHogBatch(bodies[0]);
|
|
243
|
+
expect(isRecord(sent[0]) ? sent[0].event : undefined).toBe("orly_skill_run");
|
|
244
|
+
expect(postHogProperties(sent[0]).skill).toBe("orly-spec-new");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
function observation() {
|
|
249
|
+
return { gate: "verify", outcome: "error" as const, failedCriterion: "verify.unit", durationMs: 42, version: "0.6.9" };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function anonymousSession(root: string, randomId: () => string) {
|
|
253
|
+
return beginTelemetry(["gate", "verify"], {
|
|
254
|
+
env: { [STATE_ENV]: root, [ANONYMOUS_ENV]: "anonymous" }, stdinIsTty: false, stdoutIsTty: false, randomId,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function event(index: number, timestamp = FIXED_TIME.toISOString()): TelemetryEvent {
|
|
259
|
+
return {
|
|
260
|
+
schema_version: 1, event_id: uuid(index), timestamp, command: "gate", outcome: "success", duration_ms: index,
|
|
261
|
+
orly_version: "0.6.9", os: "darwin", arch: "arm64", invocation: "direct", session_id: uuid(9_000 + index), installation_id: uuid(8_000),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function temporaryRoot(): string {
|
|
266
|
+
const root = mkdtempSync(join(tmpdir(), "orly-telemetry-test-"));
|
|
267
|
+
temporaryRoots.push(root);
|
|
268
|
+
return root;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function idSequence(...values: number[]): () => string {
|
|
272
|
+
let index = 0;
|
|
273
|
+
return () => uuid(values[index++] ?? 999_999);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function uuid(value: number): string {
|
|
277
|
+
return `00000000-0000-4000-8000-${value.toString(16).padStart(12, "0")}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function captureFetch(bodies: unknown[]) {
|
|
281
|
+
return async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
282
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
283
|
+
return new Response("ok", { status: 200 });
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function failingFetch(bodies: unknown[]) {
|
|
288
|
+
return async (_input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
289
|
+
bodies.push(JSON.parse(String(init?.body)));
|
|
290
|
+
return new Response("unavailable", { status: 503 });
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function postHogBatch(body: unknown): unknown[] {
|
|
295
|
+
return isRecord(body) && Array.isArray(body.batch) ? body.batch : [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function postHogProperties(value: unknown): Record<string, unknown> {
|
|
299
|
+
return isRecord(value) && isRecord(value.properties) ? value.properties : {};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function postHogIds(body: unknown): unknown[] {
|
|
303
|
+
return postHogBatch(body).map((entry) => postHogProperties(entry).event_id);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
307
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
308
|
+
}
|