openryoko 2026.7.26 → 2026.8.5
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/dist/bin/jimmy.js +63 -0
- package/dist/bin/jimmy.js.map +1 -1
- package/dist/package.json +1 -1
- package/dist/src/connectors/slack/__tests__/send-thread.test.d.ts +2 -0
- package/dist/src/connectors/slack/__tests__/send-thread.test.d.ts.map +1 -0
- package/dist/src/connectors/slack/__tests__/send-thread.test.js +61 -0
- package/dist/src/connectors/slack/__tests__/send-thread.test.js.map +1 -0
- package/dist/src/connectors/slack/index.d.ts.map +1 -1
- package/dist/src/connectors/slack/index.js +9 -0
- package/dist/src/connectors/slack/index.js.map +1 -1
- package/dist/src/engines/__tests__/claude-interactive-race.test.js +10 -0
- package/dist/src/engines/__tests__/claude-interactive-race.test.js.map +1 -1
- package/dist/src/gateway/__tests__/connector-proxy-send.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/connector-proxy-send.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/connector-proxy-send.test.js +91 -0
- package/dist/src/gateway/__tests__/connector-proxy-send.test.js.map +1 -0
- package/dist/src/gateway/__tests__/notification-wake-delivery.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/notification-wake-delivery.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/notification-wake-delivery.test.js +145 -0
- package/dist/src/gateway/__tests__/notification-wake-delivery.test.js.map +1 -0
- package/dist/src/gateway/__tests__/notification-wake-restart.test.d.ts +2 -0
- package/dist/src/gateway/__tests__/notification-wake-restart.test.d.ts.map +1 -0
- package/dist/src/gateway/__tests__/notification-wake-restart.test.js +97 -0
- package/dist/src/gateway/__tests__/notification-wake-restart.test.js.map +1 -0
- package/dist/src/gateway/api.d.ts.map +1 -1
- package/dist/src/gateway/api.js +114 -15
- package/dist/src/gateway/api.js.map +1 -1
- package/dist/src/jobs/__tests__/monitor.test.d.ts +2 -0
- package/dist/src/jobs/__tests__/monitor.test.d.ts.map +1 -0
- package/dist/src/jobs/__tests__/monitor.test.js +142 -0
- package/dist/src/jobs/__tests__/monitor.test.js.map +1 -0
- package/dist/src/jobs/__tests__/notify.test.d.ts +2 -0
- package/dist/src/jobs/__tests__/notify.test.d.ts.map +1 -0
- package/dist/src/jobs/__tests__/notify.test.js +122 -0
- package/dist/src/jobs/__tests__/notify.test.js.map +1 -0
- package/dist/src/jobs/__tests__/state.test.d.ts +2 -0
- package/dist/src/jobs/__tests__/state.test.d.ts.map +1 -0
- package/dist/src/jobs/__tests__/state.test.js +95 -0
- package/dist/src/jobs/__tests__/state.test.js.map +1 -0
- package/dist/src/jobs/monitor.d.ts +7 -0
- package/dist/src/jobs/monitor.d.ts.map +1 -0
- package/dist/src/jobs/monitor.js +153 -0
- package/dist/src/jobs/monitor.js.map +1 -0
- package/dist/src/jobs/notify.d.ts +26 -0
- package/dist/src/jobs/notify.d.ts.map +1 -0
- package/dist/src/jobs/notify.js +132 -0
- package/dist/src/jobs/notify.js.map +1 -0
- package/dist/src/jobs/run.d.ts +21 -0
- package/dist/src/jobs/run.d.ts.map +1 -0
- package/dist/src/jobs/run.js +65 -0
- package/dist/src/jobs/run.js.map +1 -0
- package/dist/src/jobs/state.d.ts +63 -0
- package/dist/src/jobs/state.d.ts.map +1 -0
- package/dist/src/jobs/state.js +126 -0
- package/dist/src/jobs/state.js.map +1 -0
- package/dist/src/sessions/__tests__/context-process-lifetime.test.d.ts +2 -0
- package/dist/src/sessions/__tests__/context-process-lifetime.test.d.ts.map +1 -0
- package/dist/src/sessions/__tests__/context-process-lifetime.test.js +70 -0
- package/dist/src/sessions/__tests__/context-process-lifetime.test.js.map +1 -0
- package/dist/src/sessions/__tests__/detached-jobs-context.test.d.ts +2 -0
- package/dist/src/sessions/__tests__/detached-jobs-context.test.d.ts.map +1 -0
- package/dist/src/sessions/__tests__/detached-jobs-context.test.js +57 -0
- package/dist/src/sessions/__tests__/detached-jobs-context.test.js.map +1 -0
- package/dist/src/sessions/__tests__/manager-orphan-delivery.test.d.ts +2 -0
- package/dist/src/sessions/__tests__/manager-orphan-delivery.test.d.ts.map +1 -0
- package/dist/src/sessions/__tests__/manager-orphan-delivery.test.js +74 -0
- package/dist/src/sessions/__tests__/manager-orphan-delivery.test.js.map +1 -0
- package/dist/src/sessions/__tests__/origin-delivery.test.d.ts +2 -0
- package/dist/src/sessions/__tests__/origin-delivery.test.d.ts.map +1 -0
- package/dist/src/sessions/__tests__/origin-delivery.test.js +104 -0
- package/dist/src/sessions/__tests__/origin-delivery.test.js.map +1 -0
- package/dist/src/sessions/context.d.ts +14 -0
- package/dist/src/sessions/context.d.ts.map +1 -1
- package/dist/src/sessions/context.js +84 -0
- package/dist/src/sessions/context.js.map +1 -1
- package/dist/src/sessions/manager.d.ts.map +1 -1
- package/dist/src/sessions/manager.js +12 -16
- package/dist/src/sessions/manager.js.map +1 -1
- package/dist/src/sessions/origin-delivery.d.ts +36 -0
- package/dist/src/sessions/origin-delivery.d.ts.map +1 -0
- package/dist/src/sessions/origin-delivery.js +66 -0
- package/dist/src/sessions/origin-delivery.js.map +1 -0
- package/dist/src/sessions/registry.d.ts +7 -0
- package/dist/src/sessions/registry.d.ts.map +1 -1
- package/dist/src/sessions/registry.js +14 -0
- package/dist/src/sessions/registry.js.map +1 -1
- package/dist/src/shared/__tests__/threading.test.d.ts +2 -0
- package/dist/src/shared/__tests__/threading.test.d.ts.map +1 -0
- package/dist/src/shared/__tests__/threading.test.js +23 -0
- package/dist/src/shared/__tests__/threading.test.js.map +1 -0
- package/dist/src/shared/paths.d.ts +1 -0
- package/dist/src/shared/paths.d.ts.map +1 -1
- package/dist/src/shared/paths.js +1 -0
- package/dist/src/shared/paths.js.map +1 -1
- package/dist/src/shared/threading.d.ts +11 -0
- package/dist/src/shared/threading.d.ts.map +1 -0
- package/dist/src/shared/threading.js +16 -0
- package/dist/src/shared/threading.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
// Regression pin for issue #38 follow-up: a session woken by a notification
|
|
7
|
+
// (detached job finished) runs on the gateway's connector-less web path. Its
|
|
8
|
+
// final answer MUST come back to the original Slack channel/thread stored in
|
|
9
|
+
// reply_context — the 2026-08-04 accident left an externally-shared customer
|
|
10
|
+
// thread waiting on "生成中です" forever.
|
|
11
|
+
//
|
|
12
|
+
// RYOKO_HOME must point at a temp dir BEFORE any module import pulls in
|
|
13
|
+
// shared/paths.js, so every module here is imported dynamically.
|
|
14
|
+
process.env.RYOKO_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "wake-test-home-"));
|
|
15
|
+
const STUB_CONFIG = {
|
|
16
|
+
jinn: { version: "0.0.0" },
|
|
17
|
+
gateway: { port: 0, host: "127.0.0.1" },
|
|
18
|
+
engines: {
|
|
19
|
+
default: "claude",
|
|
20
|
+
claude: { bin: "claude", model: "" },
|
|
21
|
+
codex: { bin: "codex", model: "" },
|
|
22
|
+
},
|
|
23
|
+
connectors: {},
|
|
24
|
+
sessions: {},
|
|
25
|
+
logging: { level: "error", stdout: false, file: "" },
|
|
26
|
+
};
|
|
27
|
+
describe("notification wake-up delivers the reply to the origin connector", () => {
|
|
28
|
+
let server;
|
|
29
|
+
let baseUrl;
|
|
30
|
+
let replyMessage;
|
|
31
|
+
let sessionId;
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
const { handleApiRequest } = await import("../api.js");
|
|
34
|
+
const { createSession } = await import("../../sessions/registry.js");
|
|
35
|
+
const { SessionManager } = await import("../../sessions/manager.js");
|
|
36
|
+
const engineRun = vi.fn(async () => ({
|
|
37
|
+
result: "資料が完成しました。アップロード済みです。",
|
|
38
|
+
sessionId: "engine-sess-1",
|
|
39
|
+
numTurns: 1,
|
|
40
|
+
}));
|
|
41
|
+
const mockEngine = { run: engineRun };
|
|
42
|
+
const engines = new Map([["claude", mockEngine]]);
|
|
43
|
+
const sessionManager = new SessionManager(STUB_CONFIG, engines, ["slack"]);
|
|
44
|
+
replyMessage = vi.fn(async () => "posted-ts");
|
|
45
|
+
const fakeSlack = {
|
|
46
|
+
name: "slack",
|
|
47
|
+
replyMessage,
|
|
48
|
+
addReaction: vi.fn(async () => { }),
|
|
49
|
+
getCapabilities: () => ({ threading: true, messageEdits: true, reactions: true, attachments: true }),
|
|
50
|
+
reconstructTarget: (rc) => ({
|
|
51
|
+
channel: typeof rc.channel === "string" ? rc.channel : "",
|
|
52
|
+
thread: typeof rc.thread === "string" ? rc.thread : undefined,
|
|
53
|
+
messageTs: typeof rc.messageTs === "string" ? rc.messageTs : undefined,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
const session = createSession({
|
|
57
|
+
engine: "claude",
|
|
58
|
+
source: "slack",
|
|
59
|
+
sourceRef: "C_EXTERNAL",
|
|
60
|
+
connector: "slack",
|
|
61
|
+
sessionKey: "slack:C_EXTERNAL:100.200",
|
|
62
|
+
replyContext: { channel: "C_EXTERNAL", thread: "100.200", messageTs: "100.300" },
|
|
63
|
+
transportMeta: { channelExternal: true },
|
|
64
|
+
prompt: "資料を作って",
|
|
65
|
+
});
|
|
66
|
+
sessionId = session.id;
|
|
67
|
+
const context = {
|
|
68
|
+
config: STUB_CONFIG,
|
|
69
|
+
getConfig: () => STUB_CONFIG,
|
|
70
|
+
sessionManager,
|
|
71
|
+
startTime: 0,
|
|
72
|
+
emit: () => { },
|
|
73
|
+
connectors: new Map([["slack", fakeSlack]]),
|
|
74
|
+
};
|
|
75
|
+
server = http.createServer((req, res) => {
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
+
handleApiRequest(req, res, context);
|
|
78
|
+
});
|
|
79
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
80
|
+
const addr = server.address();
|
|
81
|
+
baseUrl = typeof addr === "object" && addr ? `http://127.0.0.1:${addr.port}` : "";
|
|
82
|
+
});
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
|
|
85
|
+
fs.rmSync(process.env.RYOKO_HOME, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
it("wakes the session and replies into the original channel/thread", async () => {
|
|
88
|
+
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
message: '✅ Detached job "pdf-build" completed successfully (exit 0).',
|
|
93
|
+
role: "notification",
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
// The engine run + delivery happen asynchronously after the 200.
|
|
98
|
+
await vi.waitFor(() => {
|
|
99
|
+
expect(replyMessage).toHaveBeenCalledTimes(1);
|
|
100
|
+
}, { timeout: 10_000 });
|
|
101
|
+
const [target, text] = replyMessage.mock.calls[0];
|
|
102
|
+
expect(target).toMatchObject({ channel: "C_EXTERNAL", thread: "100.200" });
|
|
103
|
+
expect(text).toBe("資料が完成しました。アップロード済みです。");
|
|
104
|
+
});
|
|
105
|
+
it("a duplicate dedupeKey does not enqueue a second turn (monitor retry after lost response)", async () => {
|
|
106
|
+
replyMessage.mockClear();
|
|
107
|
+
const body = JSON.stringify({
|
|
108
|
+
message: '✅ Detached job "dedupe-job" completed successfully (exit 0).',
|
|
109
|
+
role: "notification",
|
|
110
|
+
dedupeKey: "job:dedupe-job-1",
|
|
111
|
+
});
|
|
112
|
+
const first = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "content-type": "application/json" },
|
|
115
|
+
body,
|
|
116
|
+
});
|
|
117
|
+
expect(first.status).toBe(200);
|
|
118
|
+
expect((await first.json()).status).toBe("queued");
|
|
119
|
+
const second = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { "content-type": "application/json" },
|
|
122
|
+
body,
|
|
123
|
+
});
|
|
124
|
+
expect(second.status).toBe(200);
|
|
125
|
+
expect((await second.json()).status).toBe("duplicate");
|
|
126
|
+
await vi.waitFor(() => {
|
|
127
|
+
expect(replyMessage).toHaveBeenCalledTimes(1); // one turn, one delivery
|
|
128
|
+
}, { timeout: 10_000 });
|
|
129
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
130
|
+
expect(replyMessage).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
it("a plain user message on the web path does NOT auto-post to the connector", async () => {
|
|
133
|
+
replyMessage.mockClear();
|
|
134
|
+
const res = await fetch(`${baseUrl}/api/sessions/${sessionId}/message`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/json" },
|
|
137
|
+
body: JSON.stringify({ message: "operator typing in web UI", role: "user" }),
|
|
138
|
+
});
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
// Give the async run time to complete, then confirm no connector post.
|
|
141
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
142
|
+
expect(replyMessage).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
//# sourceMappingURL=notification-wake-delivery.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification-wake-delivery.test.js","sourceRoot":"","sources":["../../../../src/gateway/__tests__/notification-wake-delivery.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification-wake-restart.test.d.ts","sourceRoot":"","sources":["../../../../src/gateway/__tests__/notification-wake-restart.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// Gateway-restart recovery for notification wake-ups (issue #38 follow-up):
|
|
6
|
+
// the job monitor got its 200 (message persisted + queue item enqueued) and
|
|
7
|
+
// will never re-send. If the gateway dies before running the turn, the resume
|
|
8
|
+
// pass must replay the queue item FOR CONNECTOR-ORIGIN SESSIONS TOO — and
|
|
9
|
+
// with origin-connector delivery, or the customer thread stays silent.
|
|
10
|
+
process.env.RYOKO_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "wake-restart-home-"));
|
|
11
|
+
const STUB_CONFIG = {
|
|
12
|
+
jinn: { version: "0.0.0" },
|
|
13
|
+
gateway: { port: 0, host: "127.0.0.1" },
|
|
14
|
+
engines: {
|
|
15
|
+
default: "claude",
|
|
16
|
+
claude: { bin: "claude", model: "" },
|
|
17
|
+
codex: { bin: "codex", model: "" },
|
|
18
|
+
},
|
|
19
|
+
connectors: {},
|
|
20
|
+
sessions: {},
|
|
21
|
+
logging: { level: "error", stdout: false, file: "" },
|
|
22
|
+
};
|
|
23
|
+
describe("resumePendingWebQueueItems — connector-origin notification wake-ups", () => {
|
|
24
|
+
let replyMessage;
|
|
25
|
+
let engineRun;
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
const { resumePendingWebQueueItems } = await import("../api.js");
|
|
28
|
+
const { createSession, insertMessage, enqueueQueueItem } = await import("../../sessions/registry.js");
|
|
29
|
+
const { SessionManager } = await import("../../sessions/manager.js");
|
|
30
|
+
engineRun = vi.fn(async () => ({ result: "遅れましたが完成しました", sessionId: "es-1", numTurns: 1 }));
|
|
31
|
+
const engines = new Map([["claude", { run: engineRun }]]);
|
|
32
|
+
const sessionManager = new SessionManager(STUB_CONFIG, engines, ["slack"]);
|
|
33
|
+
replyMessage = vi.fn(async () => "ts");
|
|
34
|
+
const fakeSlack = {
|
|
35
|
+
name: "slack",
|
|
36
|
+
replyMessage,
|
|
37
|
+
addReaction: vi.fn(async () => { }),
|
|
38
|
+
getCapabilities: () => ({ threading: true, messageEdits: true, reactions: true, attachments: true }),
|
|
39
|
+
reconstructTarget: (rc) => ({
|
|
40
|
+
channel: typeof rc.channel === "string" ? rc.channel : "",
|
|
41
|
+
thread: typeof rc.thread === "string" ? rc.thread : undefined,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
// Simulate the pre-restart state the monitor left behind: notification
|
|
45
|
+
// message persisted + queue item pending, but no turn ever ran.
|
|
46
|
+
const wakeText = '✅ Detached job "pdf" completed successfully (exit 0).';
|
|
47
|
+
const slackSession = createSession({
|
|
48
|
+
engine: "claude",
|
|
49
|
+
source: "slack",
|
|
50
|
+
sourceRef: "C_CUST",
|
|
51
|
+
connector: "slack",
|
|
52
|
+
sessionKey: "slack:C_CUST:1.2",
|
|
53
|
+
replyContext: { channel: "C_CUST", thread: "1.2" },
|
|
54
|
+
transportMeta: { channelExternal: true },
|
|
55
|
+
prompt: "資料を作って",
|
|
56
|
+
});
|
|
57
|
+
insertMessage(slackSession.id, "notification", wakeText);
|
|
58
|
+
enqueueQueueItem(slackSession.id, slackSession.sessionKey, wakeText);
|
|
59
|
+
// Control: a connector session with a pending USER prompt must stay
|
|
60
|
+
// untouched (pre-existing behavior — the connector route owns it).
|
|
61
|
+
const userSession = createSession({
|
|
62
|
+
engine: "claude",
|
|
63
|
+
source: "slack",
|
|
64
|
+
sourceRef: "C_OTHER",
|
|
65
|
+
connector: "slack",
|
|
66
|
+
sessionKey: "slack:C_OTHER:9.9",
|
|
67
|
+
replyContext: { channel: "C_OTHER", thread: "9.9" },
|
|
68
|
+
prompt: "こんにちは",
|
|
69
|
+
});
|
|
70
|
+
insertMessage(userSession.id, "user", "続きをやって");
|
|
71
|
+
enqueueQueueItem(userSession.id, userSession.sessionKey, "続きをやって");
|
|
72
|
+
const context = {
|
|
73
|
+
config: STUB_CONFIG,
|
|
74
|
+
getConfig: () => STUB_CONFIG,
|
|
75
|
+
sessionManager,
|
|
76
|
+
startTime: 0,
|
|
77
|
+
emit: () => { },
|
|
78
|
+
connectors: new Map([["slack", fakeSlack]]),
|
|
79
|
+
};
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
81
|
+
resumePendingWebQueueItems(context);
|
|
82
|
+
});
|
|
83
|
+
afterAll(() => {
|
|
84
|
+
fs.rmSync(process.env.RYOKO_HOME, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
it("replays the notification wake-up and delivers to the original thread", async () => {
|
|
87
|
+
await vi.waitFor(() => {
|
|
88
|
+
expect(replyMessage).toHaveBeenCalledTimes(1);
|
|
89
|
+
}, { timeout: 10_000 });
|
|
90
|
+
const [target, text] = replyMessage.mock.calls[0];
|
|
91
|
+
expect(target).toMatchObject({ channel: "C_CUST", thread: "1.2" });
|
|
92
|
+
expect(text).toBe("遅れましたが完成しました");
|
|
93
|
+
// Only the notification item ran — the user-prompt item was left alone.
|
|
94
|
+
expect(engineRun).toHaveBeenCalledTimes(1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
//# sourceMappingURL=notification-wake-restart.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification-wake-restart.test.js","sourceRoot":"","sources":["../../../../src/gateway/__tests__/notification-wake-restart.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/gateway/api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,IAAI,WAAW,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAMhF,OAAO,KAAK,EAAoC,UAAU,EAAmB,MAAM,oBAAoB,CAAC;AAExG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../../src/gateway/api.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,IAAI,WAAW,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAMhF,OAAO,KAAK,EAAoC,UAAU,EAAmB,MAAM,oBAAoB,CAAC;AAExG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAuD7D,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,UAAU,CAAC;IACnB,cAAc,EAAE,cAAc,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,UAAU,CAAC;IAC5B,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,oBAAoB,EAAE,SAAS,CAAC,CAAC;IAChE,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IACrG;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;IAChG;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,MAAM,IAAI,CAAC;IACzC;;;;OAIG;IACH,gCAAgC,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9C;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,oBAAoB,EAAE,YAAY,CAAC;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,UAAU,GAAG,IAAI,CA4CpE;AAoMD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA+BnH;AA4CD,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,WAAW,EAChB,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,IAAI,CAAC,CAoqDf"}
|
package/dist/src/gateway/api.js
CHANGED
|
@@ -5,14 +5,16 @@ import path from "node:path";
|
|
|
5
5
|
import yaml from "js-yaml";
|
|
6
6
|
import { isInterruptibleEngine } from "../shared/types.js";
|
|
7
7
|
import { buildContext } from "../sessions/context.js";
|
|
8
|
-
import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
|
|
8
|
+
import { initDb, listSessions, getSession, createSession, updateSession, deleteSession, deleteSessions, duplicateSession, insertMessage, getMessages, enqueueQueueItem, insertNotificationWithQueueItem, cancelQueueItem, getQueueItems, cancelAllPendingQueueItems, listAllPendingQueueItems, getFile, } from "../sessions/registry.js";
|
|
9
9
|
import { forkEngineSession } from "../sessions/fork.js";
|
|
10
|
+
import { deliverToOriginConnector, isUndeliveredToOrigin, recordFailedOriginDelivery } from "../sessions/origin-delivery.js";
|
|
10
11
|
import { CONFIG_PATH, CRON_RUNS, ORG_DIR, SKILLS_DIR, LOGS_DIR, TMP_DIR, FILES_DIR, } from "../shared/paths.js";
|
|
11
12
|
import { logger } from "../shared/logger.js";
|
|
12
13
|
import { getSttStatus, downloadModel, transcribe as sttTranscribe, resolveLanguages, WHISPER_LANGUAGES } from "../stt/stt.js";
|
|
13
14
|
import { JINN_HOME } from "../shared/paths.js";
|
|
14
15
|
import { handleHookPost, LOOPBACK as HOOK_LOOPBACK } from "./hook-endpoint.js";
|
|
15
16
|
import { resolveEffort } from "../shared/effort.js";
|
|
17
|
+
import { explicitThread } from "../shared/threading.js";
|
|
16
18
|
import { effortLevelsForModel, invalidateModelRegistry } from "../shared/models.js";
|
|
17
19
|
import { computeNextRetryDelayMs, computeRateLimitDeadlineMs, detectRateLimit } from "../shared/rateLimit.js";
|
|
18
20
|
import { getClaudeExpectedResetAt, recordClaudeRateLimit } from "../shared/usageAwareness.js";
|
|
@@ -36,8 +38,20 @@ export function resumePendingWebQueueItems(context) {
|
|
|
36
38
|
cancelQueueItem(item.id);
|
|
37
39
|
continue;
|
|
38
40
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
// Connector-origin sessions normally get their runs from the connector
|
|
42
|
+
// route, EXCEPT notification wake-ups (detached jobs, child callbacks):
|
|
43
|
+
// the job monitor already got its 200 and will never re-send, so a
|
|
44
|
+
// restart here would strand the wake-up forever. Detect those by the
|
|
45
|
+
// persisted notification message matching the queued prompt and resume
|
|
46
|
+
// them with origin-connector delivery.
|
|
47
|
+
let deliverToConnector = false;
|
|
48
|
+
if (session.source !== "web") {
|
|
49
|
+
const messages = getMessages(session.id);
|
|
50
|
+
const match = [...messages].reverse().find((m) => m.content === item.prompt);
|
|
51
|
+
if (match?.role !== "notification")
|
|
52
|
+
continue;
|
|
53
|
+
deliverToConnector = true;
|
|
54
|
+
}
|
|
41
55
|
session = maybeRevertEngineOverride(session);
|
|
42
56
|
const config = context.getConfig();
|
|
43
57
|
const engine = context.sessionManager.getEngine(session.engine);
|
|
@@ -48,7 +62,7 @@ export function resumePendingWebQueueItems(context) {
|
|
|
48
62
|
}
|
|
49
63
|
// Ensure the session is in a runnable state
|
|
50
64
|
updateSession(session.id, { status: "running", lastActivity: new Date().toISOString(), lastError: null });
|
|
51
|
-
dispatchWebSessionRun(session, item.prompt, engine, config, context, { queueItemId: item.id });
|
|
65
|
+
dispatchWebSessionRun(session, item.prompt, engine, config, context, { queueItemId: item.id, deliverToConnector });
|
|
52
66
|
resumed++;
|
|
53
67
|
}
|
|
54
68
|
if (resumed > 0) {
|
|
@@ -95,11 +109,24 @@ function maybeRevertEngineOverride(session) {
|
|
|
95
109
|
lastError: null,
|
|
96
110
|
}) ?? session;
|
|
97
111
|
}
|
|
112
|
+
// In-memory idempotency keys for notification wake-ups. The persisted-message
|
|
113
|
+
// comparison in the /message handler covers gateway restarts; this map covers
|
|
114
|
+
// the common case cheaply and caps unbounded growth by TTL pruning on write.
|
|
115
|
+
const seenDedupeKeys = new Map();
|
|
116
|
+
const DEDUPE_TTL_MS = 60 * 60 * 1000;
|
|
117
|
+
function rememberDedupeKey(key) {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
for (const [k, t] of seenDedupeKeys) {
|
|
120
|
+
if (now - t > DEDUPE_TTL_MS)
|
|
121
|
+
seenDedupeKeys.delete(k);
|
|
122
|
+
}
|
|
123
|
+
seenDedupeKeys.set(key, now);
|
|
124
|
+
}
|
|
98
125
|
function dispatchWebSessionRun(session, prompt, engine, config, context, opts) {
|
|
99
126
|
const run = async () => {
|
|
100
127
|
await context.sessionManager.getQueue().enqueue(session.sessionKey || session.sourceRef, async () => {
|
|
101
128
|
context.emit("session:started", { sessionId: session.id });
|
|
102
|
-
await runWebSession(session, prompt, engine, config, context, opts?.attachments);
|
|
129
|
+
await runWebSession(session, prompt, engine, config, context, opts?.attachments, opts?.deliverToConnector);
|
|
103
130
|
}, opts?.queueItemId);
|
|
104
131
|
};
|
|
105
132
|
const launch = () => {
|
|
@@ -739,12 +766,39 @@ export async function handleApiRequest(req, res, context) {
|
|
|
739
766
|
// Allow internal callers (e.g. child session callbacks) to specify a non-user role
|
|
740
767
|
const messageRole = body.role === "notification" ? "notification" : "user";
|
|
741
768
|
const isNotification = messageRole === "notification";
|
|
769
|
+
// Notification wake-ups are a loopback-only mechanism (job monitors,
|
|
770
|
+
// child-session callbacks). A remote caller must not be able to wake an
|
|
771
|
+
// arbitrary session — the woken turn can post into its origin Slack
|
|
772
|
+
// conversation, so this would be a remote-to-customer-channel bridge.
|
|
773
|
+
if (isNotification && (!req.socket.remoteAddress || !HOOK_LOOPBACK.has(req.socket.remoteAddress))) {
|
|
774
|
+
return json(res, { error: "notification role is loopback-only" }, 403);
|
|
775
|
+
}
|
|
776
|
+
// Idempotency: a job monitor retries its wake-up when a response is
|
|
777
|
+
// lost after the gateway already accepted it. Same dedupeKey (+ an
|
|
778
|
+
// identical persisted notification, which survives restarts) → don't
|
|
779
|
+
// enqueue a second engine turn. The key is only REMEMBERED after the
|
|
780
|
+
// message + queue item are durably persisted below, so a failure
|
|
781
|
+
// before that point never turns the retry into a false duplicate.
|
|
782
|
+
const dedupeKey = typeof body.dedupeKey === "string" && body.dedupeKey.trim() ? body.dedupeKey.trim() : null;
|
|
783
|
+
const dedupedNotification = isNotification && dedupeKey !== null;
|
|
784
|
+
if (dedupedNotification) {
|
|
785
|
+
const duplicate = seenDedupeKeys.has(dedupeKey)
|
|
786
|
+
|| getMessages(session.id).some((m) => m.role === "notification" && m.content === prompt);
|
|
787
|
+
if (duplicate) {
|
|
788
|
+
return json(res, { status: "duplicate", sessionId: session.id });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
742
791
|
const config = context.getConfig();
|
|
743
792
|
const engine = context.sessionManager.getEngine(session.engine);
|
|
744
793
|
if (!engine)
|
|
745
794
|
return serverError(res, `Engine "${session.engine}" not available`);
|
|
746
|
-
// Persist the message immediately
|
|
747
|
-
|
|
795
|
+
// Persist the message immediately. Deduped notifications defer this to
|
|
796
|
+
// the atomic message+queue-item write below — the duplicate check above
|
|
797
|
+
// treats "message exists" as "turn queued", so the two writes must
|
|
798
|
+
// never be separable by a crash.
|
|
799
|
+
if (!dedupedNotification) {
|
|
800
|
+
insertMessage(session.id, messageRole, prompt);
|
|
801
|
+
}
|
|
748
802
|
// Emit notification event for UI display (renders as system banner, not user bubble)
|
|
749
803
|
if (isNotification) {
|
|
750
804
|
context.emit("session:notification", { sessionId: session.id, message: prompt });
|
|
@@ -788,9 +842,20 @@ export async function handleApiRequest(req, res, context) {
|
|
|
788
842
|
context.sessionManager.getQueue().clearCancelled(session.sessionKey || session.sourceRef || session.id);
|
|
789
843
|
const attachmentPaths = resolveAttachmentPaths(body.attachments);
|
|
790
844
|
const sessionKey = session.sessionKey || session.sourceRef || session.id;
|
|
791
|
-
const queueItemId =
|
|
845
|
+
const queueItemId = dedupedNotification
|
|
846
|
+
? insertNotificationWithQueueItem(session.id, sessionKey, prompt)
|
|
847
|
+
: enqueueQueueItem(session.id, sessionKey, prompt);
|
|
848
|
+
if (dedupedNotification)
|
|
849
|
+
rememberDedupeKey(dedupeKey);
|
|
792
850
|
context.emit("queue:updated", { sessionId: session.id, sessionKey });
|
|
793
|
-
dispatchWebSessionRun(session, prompt, engine, config, context, {
|
|
851
|
+
dispatchWebSessionRun(session, prompt, engine, config, context, {
|
|
852
|
+
queueItemId,
|
|
853
|
+
attachments: attachmentPaths.length > 0 ? attachmentPaths : undefined,
|
|
854
|
+
// A notification wake-up (detached job finished, child callback) runs
|
|
855
|
+
// on this connector-less path; deliver the answer back to the origin
|
|
856
|
+
// conversation so e.g. a Slack thread is not left waiting silently.
|
|
857
|
+
deliverToConnector: isNotification,
|
|
858
|
+
});
|
|
794
859
|
return json(res, { status: "queued", sessionId: session.id });
|
|
795
860
|
}
|
|
796
861
|
// GET /api/cron
|
|
@@ -1464,11 +1529,18 @@ Handle this as a priority request from a colleague.`;
|
|
|
1464
1529
|
const target = body.target;
|
|
1465
1530
|
let messageId;
|
|
1466
1531
|
switch (action) {
|
|
1467
|
-
case "sendMessage":
|
|
1532
|
+
case "sendMessage": {
|
|
1468
1533
|
if (!target || !body.text)
|
|
1469
1534
|
return badRequest(res, "target and text are required");
|
|
1470
|
-
|
|
1535
|
+
// Same contract as /send: an explicit thread on the target means a
|
|
1536
|
+
// thread reply. Connectors' sendMessage historically dropped
|
|
1537
|
+
// target.thread, landing "thread replies" bare in the channel (#6).
|
|
1538
|
+
const proxyThread = explicitThread(target.thread);
|
|
1539
|
+
messageId = proxyThread
|
|
1540
|
+
? (await connector.replyMessage({ ...target, thread: proxyThread }, body.text))
|
|
1541
|
+
: (await connector.sendMessage(target, body.text));
|
|
1471
1542
|
break;
|
|
1543
|
+
}
|
|
1472
1544
|
case "replyMessage":
|
|
1473
1545
|
if (!target || !body.text)
|
|
1474
1546
|
return badRequest(res, "target and text are required");
|
|
@@ -1512,9 +1584,9 @@ Handle this as a priority request from a colleague.`;
|
|
|
1512
1584
|
const body = _parsed.body;
|
|
1513
1585
|
if (!body.channel || !body.text)
|
|
1514
1586
|
return badRequest(res, "channel and text are required");
|
|
1515
|
-
const
|
|
1516
|
-
const target = { channel: body.channel, thread
|
|
1517
|
-
if (
|
|
1587
|
+
const thread = explicitThread(body.thread);
|
|
1588
|
+
const target = { channel: body.channel, thread };
|
|
1589
|
+
if (thread) {
|
|
1518
1590
|
await connector.replyMessage(target, body.text);
|
|
1519
1591
|
}
|
|
1520
1592
|
else {
|
|
@@ -2048,7 +2120,12 @@ function loadTranscriptMessages(engineSessionId) {
|
|
|
2048
2120
|
}
|
|
2049
2121
|
return [];
|
|
2050
2122
|
}
|
|
2051
|
-
async function runWebSession(session, prompt, engine, config, context, attachments
|
|
2123
|
+
async function runWebSession(session, prompt, engine, config, context, attachments,
|
|
2124
|
+
/** Deliver the final answer to the session's origin connector (Slack thread
|
|
2125
|
+
* etc.). Set for notification-triggered wake-ups: this run path has no
|
|
2126
|
+
* connector of its own, so without explicit delivery a woken Slack session
|
|
2127
|
+
* would compute its reply and post it nowhere (issue #38 follow-up). */
|
|
2128
|
+
deliverToConnector) {
|
|
2052
2129
|
const currentSession = getSession(session.id);
|
|
2053
2130
|
if (!currentSession) {
|
|
2054
2131
|
logger.info(`Skipping deleted web session ${session.id} before run start`);
|
|
@@ -2083,6 +2160,13 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
|
|
|
2083
2160
|
connectors: Array.from(context.connectors.keys()),
|
|
2084
2161
|
config,
|
|
2085
2162
|
sessionId: currentSession.id,
|
|
2163
|
+
// Interactive PTY survives across turns; everything else is a one-shot
|
|
2164
|
+
// process whose background tasks die at turn end (#38).
|
|
2165
|
+
processLifetime: currentSession.engine === "claude" &&
|
|
2166
|
+
config.engines.claude?.interactive === true &&
|
|
2167
|
+
!employee?.sshHost
|
|
2168
|
+
? "persistent"
|
|
2169
|
+
: "one-shot",
|
|
2086
2170
|
hierarchy: orgHierarchy,
|
|
2087
2171
|
});
|
|
2088
2172
|
const engineConfig = currentSession.engine === "codex"
|
|
@@ -2245,6 +2329,11 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
|
|
|
2245
2329
|
});
|
|
2246
2330
|
if (completedFallback) {
|
|
2247
2331
|
notifyParentSession(completedFallback, { result: fallbackResult.result, error: fallbackResult.error ?? null, cost: fallbackResult.cost, durationMs: fallbackResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
|
|
2332
|
+
if (deliverToConnector && fallbackResult.result && !fallbackResult.error) {
|
|
2333
|
+
const delivery = await deliverToOriginConnector(completedFallback, fallbackResult.result, context.connectors);
|
|
2334
|
+
if (isUndeliveredToOrigin(delivery, completedFallback))
|
|
2335
|
+
recordFailedOriginDelivery(completedFallback, context.emit);
|
|
2336
|
+
}
|
|
2248
2337
|
}
|
|
2249
2338
|
context.emit("session:completed", {
|
|
2250
2339
|
sessionId: currentSession.id,
|
|
@@ -2360,6 +2449,11 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
|
|
|
2360
2449
|
notifyRateLimitResumed(completedAfterRetry);
|
|
2361
2450
|
notifyDiscordChannel(`✅ Claude usage limit cleared. Session ${currentSession.id}${currentSession.employee ? ` (${currentSession.employee})` : ""} resumed.`);
|
|
2362
2451
|
notifyParentSession(completedAfterRetry, { result: retryResult.result, error: retryResult.error ?? null, cost: retryResult.cost, durationMs: retryResult.durationMs }, { alwaysNotify: employee?.alwaysNotify });
|
|
2452
|
+
if (deliverToConnector && retryResult.result && !retryResult.error) {
|
|
2453
|
+
const delivery = await deliverToOriginConnector(completedAfterRetry, retryResult.result, context.connectors);
|
|
2454
|
+
if (isUndeliveredToOrigin(delivery, completedAfterRetry))
|
|
2455
|
+
recordFailedOriginDelivery(completedAfterRetry, context.emit);
|
|
2456
|
+
}
|
|
2363
2457
|
}
|
|
2364
2458
|
context.emit("session:completed", {
|
|
2365
2459
|
sessionId: currentSession.id,
|
|
@@ -2416,6 +2510,11 @@ async function runWebSession(session, prompt, engine, config, context, attachmen
|
|
|
2416
2510
|
}
|
|
2417
2511
|
if (completedSession) {
|
|
2418
2512
|
notifyParentSession(completedSession, { result: result.result, error: result.error ?? null, cost: result.cost, durationMs: result.durationMs }, { alwaysNotify: employee?.alwaysNotify });
|
|
2513
|
+
if (deliverToConnector && result.result && !result.error) {
|
|
2514
|
+
const delivery = await deliverToOriginConnector(completedSession, result.result, context.connectors);
|
|
2515
|
+
if (isUndeliveredToOrigin(delivery, completedSession))
|
|
2516
|
+
recordFailedOriginDelivery(completedSession, context.emit);
|
|
2517
|
+
}
|
|
2419
2518
|
}
|
|
2420
2519
|
context.emit("session:completed", {
|
|
2421
2520
|
sessionId: currentSession.id,
|