@app-vitals/agent-lib 0.1.0 → 0.1.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/chat-notify.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * lib/chat-notify.ts
3
+ * Shared type for the chat service's outbound reply-notification webhook
4
+ * (CFB-4.3) — imported by both the chat service (builds/sends the event) and,
5
+ * potentially, the admin service (receives it at POST /admin/push/notify).
6
+ *
7
+ * Deliberately text-free: no message body/preview field. The payload is just
8
+ * enough for the admin side to look up subscriptions and render a generic
9
+ * "your agent replied" notification — the opt-in preview text (if any) is
10
+ * fetched separately by admin from the message itself, not carried here.
11
+ */
12
+
13
+ /** Event fired once per newly-persisted agent reply. */
14
+ export interface ReplyNotificationEvent {
15
+ threadId: string;
16
+ agentId: string;
17
+ /** Thread title, or null when the thread has none. Never coerced to "". */
18
+ title: string | null;
19
+ }
20
+
21
+ /**
22
+ * Parses `v` as a ReplyNotificationEvent, returning null on any shape
23
+ * mismatch (missing/wrong-typed fields). Used by callers that receive this
24
+ * payload over the wire and need to validate it before trusting the shape.
25
+ */
26
+ export function parseReplyNotificationEvent(
27
+ v: unknown,
28
+ ): ReplyNotificationEvent | null {
29
+ if (typeof v !== "object" || v === null) return null;
30
+ const obj = v as Record<string, unknown>;
31
+
32
+ if (typeof obj.threadId !== "string") return null;
33
+ if (typeof obj.agentId !== "string") return null;
34
+ if (typeof obj.title !== "string" && obj.title !== null) return null;
35
+
36
+ return {
37
+ threadId: obj.threadId,
38
+ agentId: obj.agentId,
39
+ title: obj.title,
40
+ };
41
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type ReplyNotificationEvent,
4
+ parseReplyNotificationEvent,
5
+ } from "./chat-notify.ts";
6
+
7
+ describe("parseReplyNotificationEvent — valid shapes", () => {
8
+ test("parses a full event with a string title", () => {
9
+ const input = { threadId: "thread-1", agentId: "agent-1", title: "Hi" };
10
+ const result = parseReplyNotificationEvent(input);
11
+ expect(result).toEqual({
12
+ threadId: "thread-1",
13
+ agentId: "agent-1",
14
+ title: "Hi",
15
+ } satisfies ReplyNotificationEvent);
16
+ });
17
+
18
+ test("parses an event with a null title", () => {
19
+ const input = { threadId: "thread-1", agentId: "agent-1", title: null };
20
+ const result = parseReplyNotificationEvent(input);
21
+ expect(result).toEqual({
22
+ threadId: "thread-1",
23
+ agentId: "agent-1",
24
+ title: null,
25
+ });
26
+ });
27
+ });
28
+
29
+ describe("parseReplyNotificationEvent — invalid shapes", () => {
30
+ test("rejects null", () => {
31
+ expect(parseReplyNotificationEvent(null)).toBeNull();
32
+ });
33
+
34
+ test("rejects a non-object", () => {
35
+ expect(parseReplyNotificationEvent("not an object")).toBeNull();
36
+ });
37
+
38
+ test("rejects a missing threadId", () => {
39
+ expect(
40
+ parseReplyNotificationEvent({ agentId: "agent-1", title: null }),
41
+ ).toBeNull();
42
+ });
43
+
44
+ test("rejects a missing agentId", () => {
45
+ expect(
46
+ parseReplyNotificationEvent({ threadId: "thread-1", title: null }),
47
+ ).toBeNull();
48
+ });
49
+
50
+ test("rejects a non-string threadId", () => {
51
+ expect(
52
+ parseReplyNotificationEvent({
53
+ threadId: 123,
54
+ agentId: "agent-1",
55
+ title: null,
56
+ }),
57
+ ).toBeNull();
58
+ });
59
+
60
+ test("rejects a non-string agentId", () => {
61
+ expect(
62
+ parseReplyNotificationEvent({
63
+ threadId: "thread-1",
64
+ agentId: 123,
65
+ title: null,
66
+ }),
67
+ ).toBeNull();
68
+ });
69
+
70
+ test("rejects a non-string, non-null title", () => {
71
+ expect(
72
+ parseReplyNotificationEvent({
73
+ threadId: "thread-1",
74
+ agentId: "agent-1",
75
+ title: 42,
76
+ }),
77
+ ).toBeNull();
78
+ });
79
+
80
+ test("rejects a missing title field", () => {
81
+ expect(
82
+ parseReplyNotificationEvent({
83
+ threadId: "thread-1",
84
+ agentId: "agent-1",
85
+ }),
86
+ ).toBeNull();
87
+ });
88
+ });
package/claim-ttl.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { DEFAULT_CLAUDE_TIMEOUT_MS } from "./claude-timeout.ts";
2
+
3
+ /** Extra headroom added on top of the session timeout (5min). */
4
+ export const CLAIM_TTL_BUFFER_MS = 300_000;
5
+
6
+ /**
7
+ * Default claim TTL: how long a claim (Task or PR review/patch/deploy) remains
8
+ * valid without a heartbeat before it's eligible for reaping. Must exceed
9
+ * DEFAULT_CLAUDE_TIMEOUT_MS so a claim isn't reaped mid-session.
10
+ */
11
+ export const DEFAULT_CLAIM_TTL_MS =
12
+ DEFAULT_CLAUDE_TIMEOUT_MS + CLAIM_TTL_BUFFER_MS;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CLAIM_TTL_BUFFER_MS, DEFAULT_CLAIM_TTL_MS } from "./claim-ttl.ts";
3
+ import { DEFAULT_CLAUDE_TIMEOUT_MS } from "./claude-timeout.ts";
4
+
5
+ describe("claim-ttl constants", () => {
6
+ test("CLAIM_TTL_BUFFER_MS is 300_000 (5min)", () => {
7
+ expect(CLAIM_TTL_BUFFER_MS).toBe(300_000);
8
+ });
9
+
10
+ test("DEFAULT_CLAIM_TTL_MS is 3_900_000 (65min)", () => {
11
+ expect(DEFAULT_CLAIM_TTL_MS).toBe(3_900_000);
12
+ });
13
+
14
+ test("DEFAULT_CLAIM_TTL_MS is computed as the sum of the other two constants", () => {
15
+ expect(DEFAULT_CLAIM_TTL_MS).toBe(
16
+ DEFAULT_CLAUDE_TIMEOUT_MS + CLAIM_TTL_BUFFER_MS,
17
+ );
18
+ });
19
+
20
+ // Relocated from claude-timeout.unit.test.ts (AEP-4.1): claim-ttl is the
21
+ // task-store side, so the cross-module invariant belongs here and the
22
+ // platform-set lib test no longer imports it.
23
+ test("DEFAULT_CLAIM_TTL_MS exceeds DEFAULT_CLAUDE_TIMEOUT_MS", () => {
24
+ expect(DEFAULT_CLAIM_TTL_MS).toBeGreaterThan(DEFAULT_CLAUDE_TIMEOUT_MS);
25
+ });
26
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The account every pre-multi-tenancy task-store row belongs to. Never use
3
+ * NULL for task-store accountId — the column is NOT NULL DEFAULT 'default'.
4
+ */
5
+ export const DEFAULT_ACCOUNT_ID = "default";
@@ -0,0 +1,8 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { DEFAULT_ACCOUNT_ID } from "./default-account.ts";
3
+
4
+ describe("DEFAULT_ACCOUNT_ID", () => {
5
+ it("is the literal 'default' matching the migration's column default", () => {
6
+ expect(DEFAULT_ACCOUNT_ID).toBe("default");
7
+ });
8
+ });
package/http.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Parses a request body as a JSON object, returning `{}` on any error or non-object shape. */
2
+ export async function readJson(c: {
3
+ req: { json: () => Promise<unknown> };
4
+ }): Promise<Record<string, unknown>> {
5
+ try {
6
+ const body = await c.req.json();
7
+ if (body && typeof body === "object" && !Array.isArray(body)) {
8
+ return body as Record<string, unknown>;
9
+ }
10
+ return {};
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readJson } from "./http.ts";
3
+
4
+ describe("readJson — valid bodies", () => {
5
+ test("returns the parsed object when the body is a plain object", async () => {
6
+ const c = { req: { json: async () => ({ foo: "bar" }) } };
7
+ expect(await readJson(c)).toEqual({ foo: "bar" });
8
+ });
9
+
10
+ test("returns an empty object when the body is an empty object", async () => {
11
+ const c = { req: { json: async () => ({}) } };
12
+ expect(await readJson(c)).toEqual({});
13
+ });
14
+ });
15
+
16
+ describe("readJson — invalid bodies", () => {
17
+ test("returns {} when the body is an array", async () => {
18
+ const c = { req: { json: async () => [1, 2, 3] } };
19
+ expect(await readJson(c)).toEqual({});
20
+ });
21
+
22
+ test("returns {} when the body is a string primitive", async () => {
23
+ const c = { req: { json: async () => "not an object" } };
24
+ expect(await readJson(c)).toEqual({});
25
+ });
26
+
27
+ test("returns {} when the body is null", async () => {
28
+ const c = { req: { json: async () => null } };
29
+ expect(await readJson(c)).toEqual({});
30
+ });
31
+
32
+ test("returns {} when req.json() throws", async () => {
33
+ const c = {
34
+ req: {
35
+ json: async () => {
36
+ throw new Error("invalid JSON");
37
+ },
38
+ },
39
+ };
40
+ expect(await readJson(c)).toEqual({});
41
+ });
42
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import pkg from "./package.json" with { type: "json" };
5
+
6
+ describe("lib/package.json exports", () => {
7
+ for (const [subpath, target] of Object.entries(pkg.exports)) {
8
+ if (target.includes("*")) continue;
9
+ test(`${subpath} target exists on disk`, () => {
10
+ expect(existsSync(join(import.meta.dir, target))).toBe(true);
11
+ });
12
+ }
13
+
14
+ test("every non-wildcard export imports cleanly", async () => {
15
+ for (const [, target] of Object.entries(pkg.exports)) {
16
+ if (target.includes("*")) continue;
17
+ const mod = await import(join(import.meta.dir, target));
18
+ expect(typeof mod).toBe("object");
19
+ }
20
+ });
21
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-lib",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -13,7 +13,6 @@
13
13
  "type": "module",
14
14
  "exports": {
15
15
  "./admin-types": "./admin-types.ts",
16
- "./agent-default-tools": "./agent-default-tools.ts",
17
16
  "./chat-notify": "./chat-notify.ts",
18
17
  "./claim-ttl": "./claim-ttl.ts",
19
18
  "./claude-timeout": "./claude-timeout.ts",