@fastagent-sh/fastagent 0.19.0 → 0.20.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/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-service.d.ts +12 -11
- package/dist/channels/agentcore-service.js +14 -18
- package/dist/channels/agentcore.d.ts +11 -19
- package/dist/channels/agentcore.js +14 -7
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +239 -96
- package/dist/channels/discover.d.ts +26 -4
- package/dist/channels/discover.js +12 -23
- package/dist/channels/feishu/feishu-api.js +5 -8
- package/dist/channels/feishu/feishu.js +35 -18
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/onboard.js +5 -5
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.js +1 -1
- package/dist/channels/slack/scaffold/slack-send.ts +2 -17
- package/dist/channels/slack/slack-api.js +22 -54
- package/dist/channels/slack/slack.js +13 -13
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +57 -69
- package/dist/cli/add-slack.js +6 -5
- package/dist/cli/commands/add.js +1 -5
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/deploy.d.ts +50 -5
- package/dist/cli/commands/deploy.js +183 -134
- package/dist/cli/commands/dev.js +4 -31
- package/dist/cli/commands/info.js +1 -2
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/start.js +31 -41
- package/dist/cli/serve.d.ts +12 -21
- package/dist/cli/serve.js +20 -25
- package/dist/cli/shared.d.ts +40 -10
- package/dist/cli/shared.js +51 -4
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +12 -10
- package/dist/deploy/agentcore/plan.js +39 -31
- package/dist/deploy/agentcore/run.d.ts +5 -4
- package/dist/deploy/agentcore/run.js +13 -36
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +18 -1
- package/dist/deploy/docker/run.js +10 -0
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +7 -22
- package/dist/deploy/fly/run.d.ts +9 -23
- package/dist/deploy/fly/run.js +32 -68
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +26 -22
- package/dist/deploy/railway/plan.d.ts +4 -5
- package/dist/deploy/railway/plan.js +11 -26
- package/dist/deploy/railway/run.d.ts +9 -9
- package/dist/deploy/railway/run.js +40 -40
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +64 -12
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.js +20 -39
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +14 -2
- package/dist/engines/pi/create.js +2 -2
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +3 -4
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +1 -2
- package/dist/engines/pi/open.js +1 -1
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +6 -33
- package/dist/engines/pi/session-control.d.ts +19 -6
- package/dist/engines/pi/session-control.js +565 -470
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +4 -4
- package/dist/engines/pi/session-settings.js +7 -4
- package/dist/engines/pi/session-store.d.ts +83 -13
- package/dist/engines/pi/session-store.js +408 -55
- package/dist/engines/pi/tool-context.d.ts +21 -8
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.js +2 -2
- package/dist/env.js +1 -2
- package/dist/loader.d.ts +49 -7
- package/dist/loader.js +79 -18
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +27 -2
- package/dist/scaffold/add-channel.js +2 -2
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +13 -7
- package/dist/schedule/wake-alarm.js +88 -46
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +1 -2
- package/dist/service.js +27 -5
- package/dist/session-remote.d.ts +7 -2
- package/dist/session-remote.js +162 -92
- package/dist/session.d.ts +226 -92
- package/dist/session.js +61 -23
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +18 -3
- package/dist/tunnel.js +32 -40
- package/package.json +1 -1
|
@@ -15,9 +15,11 @@ import { text } from "../respond.js";
|
|
|
15
15
|
import { createSeenRing } from "../kit/seen.js";
|
|
16
16
|
import { createTaskTracker } from "../kit/tasks.js";
|
|
17
17
|
import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
|
|
18
|
+
import { signatureIsFresh } from "../kit/signature.js";
|
|
18
19
|
import { dispatchStop, isStopText } from "../kit/stop-command.js";
|
|
19
20
|
import { createTurnQueue } from "../kit/turn-queue.js";
|
|
20
|
-
import { createTurnStore } from "../kit/turn-store.js";
|
|
21
|
+
import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
|
|
22
|
+
import { discussionBlock } from "../kit/context-buffer.js";
|
|
21
23
|
import { FEISHU_CLOUD } from "./cloud.js";
|
|
22
24
|
import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
|
|
23
25
|
import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
|
|
@@ -31,12 +33,17 @@ import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFei
|
|
|
31
33
|
import { connectFeishuWs } from "./ws-ingress.js";
|
|
32
34
|
// Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
|
|
33
35
|
export { defaultFeishuRoute, feishuEnvelope };
|
|
34
|
-
/** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
|
|
35
|
-
* rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
|
|
36
|
-
* at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
|
|
37
|
-
const MAX_TURN_ATTEMPTS = 3;
|
|
38
36
|
/** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
|
|
39
37
|
const MAX_EVENT_BYTES = 1 << 20;
|
|
38
|
+
/**
|
|
39
|
+
* Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
|
|
40
|
+
* 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
|
|
41
|
+
* is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
|
|
42
|
+
* to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
|
|
43
|
+
* turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
|
|
44
|
+
* enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
|
|
40
47
|
/** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
|
|
41
48
|
* accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
|
|
42
49
|
* is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
|
|
@@ -220,7 +227,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
220
227
|
});
|
|
221
228
|
const seen = createSeenRing(join(stateHome, "seen.json"), label);
|
|
222
229
|
// Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
|
|
223
|
-
const sideTasks = createTaskTracker();
|
|
230
|
+
const sideTasks = createTaskTracker(label);
|
|
224
231
|
const toStored = (r) => {
|
|
225
232
|
const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
|
|
226
233
|
return { ...intent, attempts: 0 };
|
|
@@ -285,7 +292,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
285
292
|
await notice?.done;
|
|
286
293
|
notices.delete(rec.id);
|
|
287
294
|
// Count this execution against the durable record (poison-turn ceiling) before running it again.
|
|
288
|
-
const decision = store.startAttempt(rec.id
|
|
295
|
+
const decision = store.startAttempt(rec.id);
|
|
289
296
|
if (decision === "exceeded") {
|
|
290
297
|
notifyDropped(rec);
|
|
291
298
|
return;
|
|
@@ -312,8 +319,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
312
319
|
const roomBlock = room?.text
|
|
313
320
|
? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
|
|
314
321
|
: "";
|
|
315
|
-
const
|
|
316
|
-
const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
|
|
322
|
+
const prompt = `${roomBlock}${discussionBlock(recent)}${rec.baseText}`;
|
|
317
323
|
// Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
|
|
318
324
|
// attachments win the slots.
|
|
319
325
|
const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
|
|
@@ -331,12 +337,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
|
|
|
331
337
|
label,
|
|
332
338
|
appId,
|
|
333
339
|
...(parentSession !== undefined ? { parentSession } : {}),
|
|
334
|
-
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
|
|
335
|
-
// Drop intent first: a crash between these writes may re-fold answered context later,
|
|
336
|
-
// but can never replay this turn after its context was removed.
|
|
337
|
-
store.remove(rec.id);
|
|
338
|
-
buffer.commit(rec.bufferKey, consumed);
|
|
339
|
-
}), api, targetOf(rec), formatError, rec.preview, label);
|
|
340
|
+
}, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.bufferKey, consumed })), api, targetOf(rec), formatError, rec.preview, label);
|
|
340
341
|
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
341
342
|
}
|
|
342
343
|
catch (error) {
|
|
@@ -583,6 +584,12 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
583
584
|
const { verificationToken, encryptKey } = opts;
|
|
584
585
|
const { kind, envPrefix } = profile;
|
|
585
586
|
const label = `[${kind}]`;
|
|
587
|
+
if (!encryptKey) {
|
|
588
|
+
// Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
|
|
589
|
+
// carry no signature, so the freshness window below never runs and a captured body replays for
|
|
590
|
+
// as long as the verification token lives.
|
|
591
|
+
log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
|
|
592
|
+
}
|
|
586
593
|
const handler = async (req) => {
|
|
587
594
|
if (req.method !== "POST")
|
|
588
595
|
return text("POST only\n", 405);
|
|
@@ -609,9 +616,19 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
|
|
|
609
616
|
nonce: req.headers.get("x-lark-request-nonce") ?? "",
|
|
610
617
|
signature: req.headers.get("x-lark-signature") ?? "",
|
|
611
618
|
};
|
|
612
|
-
if (sig.signature
|
|
613
|
-
|
|
614
|
-
|
|
619
|
+
if (sig.signature) {
|
|
620
|
+
// Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
|
|
621
|
+
// it, so without a window a captured body + its three x-lark-* headers replays forever. The
|
|
622
|
+
// `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
|
|
623
|
+
// turn (a re-sent message, a re-fired tool).
|
|
624
|
+
if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
|
|
625
|
+
log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
|
|
626
|
+
return text("stale signature\n", 401);
|
|
627
|
+
}
|
|
628
|
+
if (!verifySignature(encryptKey, sig, body.text)) {
|
|
629
|
+
log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
|
|
630
|
+
return text("invalid signature\n", 401);
|
|
631
|
+
}
|
|
615
632
|
}
|
|
616
633
|
try {
|
|
617
634
|
envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment destination for one conversation. Never rejects an id — but the path it returns is not
|
|
3
|
+
* promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
|
|
4
|
+
* an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
|
|
5
|
+
* `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
|
|
6
|
+
* no platform id approaches, at the cost of the readability this encoding exists to keep.
|
|
7
|
+
*/
|
|
8
|
+
export declare function attachmentPath(filesDir: string, conversationId: string | number, fileName: string): {
|
|
9
|
+
dir: string;
|
|
10
|
+
name: string;
|
|
11
|
+
path: string;
|
|
12
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an inbound attachment lands: `<filesDir>/<conversation>/<file>`.
|
|
3
|
+
*
|
|
4
|
+
* Both halves come from outside the channel, and they are NOT the same question:
|
|
5
|
+
*
|
|
6
|
+
* - The conversation is an id, and turning an arbitrary caller id into a storage name is a job this
|
|
7
|
+
* repo already answers by ENCODING it, never by rejecting it (`piSessionId` in
|
|
8
|
+
* engines/pi/session-store.ts, same shape: a character whitelist under a fixed prefix). An id is
|
|
9
|
+
* the caller's — a route returning `../..` gets an odd directory, not a failed turn, because the
|
|
10
|
+
* place to notice that id is broken is the reply it cannot deliver, not the disk.
|
|
11
|
+
* - The file name is not an id: it is read by a human and by the model, in a path this puts into the
|
|
12
|
+
* prompt. Encoding it would cost that, so it is reduced instead — losing the odd character rather
|
|
13
|
+
* than the readability. A leading dot survives: `.gitignore` is a name, not an escape, and nothing
|
|
14
|
+
* lists this directory, so hiding is not a property anyone here depends on.
|
|
15
|
+
*
|
|
16
|
+
* The encoding makes containment structural rather than checked: `encodeURIComponent` emits no path
|
|
17
|
+
* separator (`/` → `%2F`, `\` → `%5C`, `:` → `%3A`), and the prefix leaves no way to spell `.`, `..`
|
|
18
|
+
* or the empty string.
|
|
19
|
+
*
|
|
20
|
+
* It is lossless for a well-formed id, so `a/b` and `a_b` are different places. Two exceptions, both
|
|
21
|
+
* outside what a platform hands over: ids differing only in malformed UTF-16 (see the substitution
|
|
22
|
+
* below), and ids differing only in case on a case-insensitive filesystem.
|
|
23
|
+
*/
|
|
24
|
+
import { resolve, sep } from "node:path";
|
|
25
|
+
/**
|
|
26
|
+
* Attachment destination for one conversation. Never rejects an id — but the path it returns is not
|
|
27
|
+
* promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
|
|
28
|
+
* an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
|
|
29
|
+
* `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
|
|
30
|
+
* no platform id approaches, at the cost of the readability this encoding exists to keep.
|
|
31
|
+
*/
|
|
32
|
+
export function attachmentPath(filesDir, conversationId, fileName) {
|
|
33
|
+
// A lone surrogate makes `encodeURIComponent` throw `URIError`, and this function rejecting an id
|
|
34
|
+
// is the one thing it must not do — a permanent bad id would surface as a retryable turn failure.
|
|
35
|
+
// The `u` flag matches by code point, so a valid pair (an emoji in a route's id) is left alone.
|
|
36
|
+
const id = String(conversationId).replace(/\p{Surrogate}/gu, "\uFFFD");
|
|
37
|
+
const dir = resolve(filesDir, `c-${encodeURIComponent(id)}`);
|
|
38
|
+
// The name keeps its own check because it is NOT encoded: separators go, and `resolve` settles
|
|
39
|
+
// whether what is left (`..`, a Windows `D:foo`) still lands in `dir`.
|
|
40
|
+
const cleaned = fileName.replace(/[/\\]/g, "_");
|
|
41
|
+
const name = resolve(dir, cleaned).startsWith(dir + sep) ? cleaned : "file";
|
|
42
|
+
return { dir, name, path: resolve(dir, name) };
|
|
43
|
+
}
|
|
@@ -9,6 +9,15 @@ export declare const BUFFER_LINE_MAX_CHARS = 280;
|
|
|
9
9
|
* note, so the model never sees an attachment reference it silently cannot open. Shared policy:
|
|
10
10
|
* each channel's attachment collector caps against this. */
|
|
11
11
|
export declare const BUFFER_ATTACH_MAX = 3;
|
|
12
|
+
/**
|
|
13
|
+
* The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
|
|
14
|
+
* empty. One renderer for every channel: what the agent is told about un-summoned discussion should
|
|
15
|
+
* not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
|
|
16
|
+
*
|
|
17
|
+
* A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
|
|
18
|
+
* different claim about a different place, not this block with another name.
|
|
19
|
+
*/
|
|
20
|
+
export declare function discussionBlock(text: string): string;
|
|
12
21
|
export interface ContextBuffer<E> {
|
|
13
22
|
/** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
|
|
14
23
|
push(placeKey: string, entry: E): void;
|
|
@@ -39,6 +39,17 @@ export const BUFFER_LINE_MAX_CHARS = 280;
|
|
|
39
39
|
* note, so the model never sees an attachment reference it silently cannot open. Shared policy:
|
|
40
40
|
* each channel's attachment collector caps against this. */
|
|
41
41
|
export const BUFFER_ATTACH_MAX = 3;
|
|
42
|
+
/**
|
|
43
|
+
* The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
|
|
44
|
+
* empty. One renderer for every channel: what the agent is told about un-summoned discussion should
|
|
45
|
+
* not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
|
|
46
|
+
*
|
|
47
|
+
* A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
|
|
48
|
+
* different claim about a different place, not this block with another name.
|
|
49
|
+
*/
|
|
50
|
+
export function discussionBlock(text) {
|
|
51
|
+
return text ? `[recent group discussion:\n${text}\n]\n\n` : "";
|
|
52
|
+
}
|
|
42
53
|
export function createContextBuffer(options) {
|
|
43
54
|
const { path, label, isEntry, line } = options;
|
|
44
55
|
const load = () => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
|
|
3
|
+
* nothing about it, so without a window a captured body plus its signed headers replays forever.
|
|
4
|
+
* The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
|
|
8
|
+
* ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
|
|
9
|
+
* signed material, so a value the signature commits to but this cannot read is a reason to refuse,
|
|
10
|
+
* not to wave through.
|
|
11
|
+
*/
|
|
12
|
+
export declare function signatureIsFresh(timestamp: string, maxAgeS: number, nowMs?: number): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
|
|
3
|
+
* nothing about it, so without a window a captured body plus its signed headers replays forever.
|
|
4
|
+
* The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
|
|
8
|
+
* ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
|
|
9
|
+
* signed material, so a value the signature commits to but this cannot read is a reason to refuse,
|
|
10
|
+
* not to wave through.
|
|
11
|
+
*/
|
|
12
|
+
export function signatureIsFresh(timestamp, maxAgeS, nowMs = Date.now()) {
|
|
13
|
+
if (!/^\d+$/.test(timestamp))
|
|
14
|
+
return false;
|
|
15
|
+
const seconds = Number(timestamp);
|
|
16
|
+
return Number.isSafeInteger(seconds) && Math.abs(Math.floor(nowMs / 1000) - seconds) <= maxAgeS;
|
|
17
|
+
}
|
|
@@ -21,16 +21,16 @@ export async function dispatchStop(control, session, label) {
|
|
|
21
21
|
if (!control)
|
|
22
22
|
return STOP_UNAVAILABLE_NOTICE;
|
|
23
23
|
try {
|
|
24
|
-
const result = await control.
|
|
24
|
+
const result = await control.sessions.get(session).abort();
|
|
25
25
|
if (result.ok)
|
|
26
26
|
return STOPPED_NOTICE;
|
|
27
27
|
if (result.error.code === NO_ACTIVE_RUN_CODE)
|
|
28
28
|
return NOTHING_RUNNING_NOTICE;
|
|
29
|
-
log.warn(`${label} stop
|
|
29
|
+
log.warn(`${label} stop rejected for ${session}: ${result.error.code} — ${result.error.message}`);
|
|
30
30
|
return `⚠️ Could not stop (${result.error.code}).`;
|
|
31
31
|
}
|
|
32
32
|
catch (error) {
|
|
33
|
-
log.warn(`${label} stop
|
|
33
|
+
log.warn(`${label} stop failed for ${session}: ${String(error)}`);
|
|
34
34
|
return "⚠️ Could not stop — see the server logs.";
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
* SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
|
|
3
3
|
* feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
|
|
4
4
|
* (`turnsIdle`) — otherwise a reply in flight when the process exits is silently dropped. Error
|
|
5
|
-
* handling stays with the caller: track() only guarantees the drain sees the task settle
|
|
5
|
+
* handling stays with the caller: track() only guarantees the drain sees the task SETTLE, and settle
|
|
6
|
+
* includes reject. A caller that handles its error on a separate branch (`p.catch(log); track(p)`)
|
|
7
|
+
* still hands us a promise that rejects, and a drain that propagated it would fail the channel's whole
|
|
8
|
+
* `turnsIdle` over one side task. A rejection that reaches us is logged — we
|
|
9
|
+
* cannot tell a missing `.catch` from one on a separate branch, so the line is a visibility floor
|
|
10
|
+
* rather than a diagnosis, and without it a dropped side task leaves no trace anywhere.
|
|
6
11
|
*/
|
|
7
12
|
import { beginWork } from "../busy.js";
|
|
8
|
-
|
|
13
|
+
import { log } from "../../log.js";
|
|
14
|
+
export function createTaskTracker(label) {
|
|
9
15
|
const tasks = new Set();
|
|
10
16
|
return {
|
|
11
17
|
track(task) {
|
|
@@ -18,8 +24,8 @@ export function createTaskTracker() {
|
|
|
18
24
|
workDone();
|
|
19
25
|
tasks.delete(task);
|
|
20
26
|
})
|
|
21
|
-
.catch(() => {
|
|
27
|
+
.catch((error) => log.warn(`${label} side task rejected: ${String(error)}`));
|
|
22
28
|
},
|
|
23
|
-
drain: () => Promise.
|
|
29
|
+
drain: () => Promise.allSettled(tasks).then(() => undefined),
|
|
24
30
|
};
|
|
25
31
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ContextBuffer } from "./context-buffer.ts";
|
|
1
2
|
/** What every persisted turn record carries regardless of channel: identity, the session whose FIFO
|
|
2
3
|
* chain it runs on, and how many times it has STARTED executing without finishing (0 until its first
|
|
3
4
|
* run; bumped at each `startAttempt`). The channel's record type extends this with everything its
|
|
@@ -20,14 +21,14 @@ export interface TurnStore<T extends TurnRecordBase> {
|
|
|
20
21
|
recover(): T[];
|
|
21
22
|
/** Called when a turn is about to RUN (dequeued). Returns:
|
|
22
23
|
* - "run": bumped its persisted execution count; go ahead.
|
|
23
|
-
* - "exceeded": over
|
|
24
|
-
* cause); the record is dropped and the runner notifies the asker.
|
|
24
|
+
* - "exceeded": over {@link MAX_TURN_ATTEMPTS} starts without finishing (killed mid-run every time,
|
|
25
|
+
* whatever the cause); the record is dropped and the runner notifies the asker.
|
|
25
26
|
* - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
|
|
26
27
|
* would let a poison turn re-run forever); the record stays on disk and replays on the next start
|
|
27
28
|
* (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
|
|
28
29
|
* An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
|
|
29
30
|
* redelivery-double-run tail (see the header's pre-ACK window) lands here. */
|
|
30
|
-
startAttempt(id: string
|
|
31
|
+
startAttempt(id: string): "run" | "exceeded" | "defer";
|
|
31
32
|
}
|
|
32
33
|
export interface TurnStoreOptions<T extends TurnRecordBase> {
|
|
33
34
|
/** Log prefix naming the consumer (e.g. "[telegram]") — the store itself is channel-neutral. */
|
|
@@ -39,4 +40,19 @@ export interface TurnStoreOptions<T extends TurnRecordBase> {
|
|
|
39
40
|
* (telegram: numeric update_id; lark: an explicit per-record seq). */
|
|
40
41
|
order: (a: T, b: T) => number;
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
|
|
45
|
+
*
|
|
46
|
+
* The ORDER is the safety property, which is why this is a function and not two lines at each call
|
|
47
|
+
* site. A crash between the two writes may re-fold already-answered context into the next summon —
|
|
48
|
+
* additive and harmless. The reverse order leaves intent on disk with its context already consumed,
|
|
49
|
+
* so the replay runs the same turn with its context stripped.
|
|
50
|
+
*
|
|
51
|
+
* Called from the turn's `completed` event, when the fold provably lives in the durable session.
|
|
52
|
+
*/
|
|
53
|
+
export declare function commitAnsweredTurn<T extends TurnRecordBase, E>(store: TurnStore<T>, buffer: ContextBuffer<E>, turn: {
|
|
54
|
+
id: string;
|
|
55
|
+
bufferKey: string;
|
|
56
|
+
consumed: E[];
|
|
57
|
+
}): void;
|
|
42
58
|
export declare function createTurnStore<T extends TurnRecordBase>(path: string, opts: TurnStoreOptions<T>): TurnStore<T>;
|
|
@@ -51,6 +51,33 @@
|
|
|
51
51
|
*/
|
|
52
52
|
import { log } from "../../log.js";
|
|
53
53
|
import { loadStateFile, saveStateFile } from "./state.js";
|
|
54
|
+
/**
|
|
55
|
+
* How many times a turn may START without finishing before it is dropped rather than run again — the
|
|
56
|
+
* poison-turn ceiling described at length above. One value for every channel: it prices replay against
|
|
57
|
+
* DEPLOY frequency, which is a property of how fastagent is operated, not of which chat platform is in
|
|
58
|
+
* front of it.
|
|
59
|
+
*
|
|
60
|
+
* Known limitation: the count cannot tell a self-inflicted crash from an external SIGTERM (there is no
|
|
61
|
+
* graceful drain), so a legitimately long turn interrupted by this many successive deploys is dropped
|
|
62
|
+
* as if it were poison. Three is a bet that such a turn is an outlier, not a defence against one:
|
|
63
|
+
* catching SIGTERM to spare it would reintroduce the drain the design refuses — raise this constant
|
|
64
|
+
* instead if such turns are expected.
|
|
65
|
+
*/
|
|
66
|
+
const MAX_TURN_ATTEMPTS = 3;
|
|
67
|
+
/**
|
|
68
|
+
* End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
|
|
69
|
+
*
|
|
70
|
+
* The ORDER is the safety property, which is why this is a function and not two lines at each call
|
|
71
|
+
* site. A crash between the two writes may re-fold already-answered context into the next summon —
|
|
72
|
+
* additive and harmless. The reverse order leaves intent on disk with its context already consumed,
|
|
73
|
+
* so the replay runs the same turn with its context stripped.
|
|
74
|
+
*
|
|
75
|
+
* Called from the turn's `completed` event, when the fold provably lives in the durable session.
|
|
76
|
+
*/
|
|
77
|
+
export function commitAnsweredTurn(store, buffer, turn) {
|
|
78
|
+
store.remove(turn.id);
|
|
79
|
+
buffer.commit(turn.bufferKey, turn.consumed);
|
|
80
|
+
}
|
|
54
81
|
export function createTurnStore(path, opts) {
|
|
55
82
|
const { label, isRecord, order } = opts;
|
|
56
83
|
const load = () => {
|
|
@@ -102,12 +129,12 @@ export function createTurnStore(path, opts) {
|
|
|
102
129
|
// happening to survive the load's JSON round-trip.
|
|
103
130
|
return [...turns.values()].sort(order);
|
|
104
131
|
},
|
|
105
|
-
startAttempt(id
|
|
132
|
+
startAttempt(id) {
|
|
106
133
|
const rec = turns.get(id);
|
|
107
134
|
if (!rec)
|
|
108
135
|
return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
|
|
109
136
|
const attempts = rec.attempts + 1;
|
|
110
|
-
if (attempts >
|
|
137
|
+
if (attempts > MAX_TURN_ATTEMPTS) {
|
|
111
138
|
// State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
|
|
112
139
|
// this whether IT poisoned the process or a deploy/OOM took it down each time.
|
|
113
140
|
log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { Agent, AgentEvent } from "../../agent.ts";
|
|
3
3
|
import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
|
|
4
4
|
import type { SlackBufferedFileRef } from "./context-buffer.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type SlackApi } from "./slack-api.ts";
|
|
6
6
|
export interface SlackTurnTransport {
|
|
7
7
|
api: SlackApi;
|
|
8
8
|
channelId: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { log } from "../../log.js";
|
|
2
2
|
import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
|
|
3
|
+
import { SlackApiError } from "./slack-api.js";
|
|
3
4
|
const MARKDOWN_INSTRUCTION = "\n\n(Format your reply as standard Markdown. Slack renders it natively. Do not use HTML or Slack control-mention syntax such as <!here>, <!channel>, or <!everyone>.)";
|
|
4
5
|
async function resolveFile(transport, fileId) {
|
|
5
6
|
const info = await transport.api.fileInfo(fileId);
|
|
@@ -58,7 +59,10 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
|
|
|
58
59
|
resolved = await resolveInputs(transport, attachments);
|
|
59
60
|
}
|
|
60
61
|
catch (error) {
|
|
61
|
-
|
|
62
|
+
// transient (network, exhausted 429 retries, Slack 5xx) is worth re-sending; an access or shape
|
|
63
|
+
// error reads the same every time, and "try again in a moment" is the wrong thing to tell the user
|
|
64
|
+
const retryable = error instanceof SlackApiError && (error.status === 0 || error.status === 429 || error.status >= 500);
|
|
65
|
+
yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable };
|
|
62
66
|
return;
|
|
63
67
|
}
|
|
64
68
|
const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
|
|
@@ -22,7 +22,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
|
|
|
22
22
|
// Record BEFORE the non-idempotent API call. A transport/internal failure may have created the app;
|
|
23
23
|
// refusing a blind retry is safer than silently producing duplicates.
|
|
24
24
|
state = { ...state, createAttemptedAt: new Date().toISOString() };
|
|
25
|
-
|
|
25
|
+
writeSlackOnboardingState(input.stateRoot, state);
|
|
26
26
|
let created;
|
|
27
27
|
try {
|
|
28
28
|
created = await (deps.createApp ?? createSlackApp)(current.token, manifest);
|
|
@@ -32,7 +32,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
|
|
|
32
32
|
["fatal_error", "internal_error", "request_timeout", "service_unavailable", "failed_creating_app"].includes(error.code);
|
|
33
33
|
if (!ambiguous) {
|
|
34
34
|
state = { ...state, createAttemptedAt: undefined };
|
|
35
|
-
|
|
35
|
+
writeSlackOnboardingState(input.stateRoot, state);
|
|
36
36
|
}
|
|
37
37
|
throw error;
|
|
38
38
|
}
|
|
@@ -45,7 +45,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
|
|
|
45
45
|
signingSecret: created.signingSecret,
|
|
46
46
|
};
|
|
47
47
|
// Irreversible boundary first: a cancellation or .env write failure can resume without creating a duplicate.
|
|
48
|
-
|
|
48
|
+
writeSlackOnboardingState(input.stateRoot, state);
|
|
49
49
|
io.note(`Created Slack app ${created.appId}; credentials captured locally.`);
|
|
50
50
|
}
|
|
51
51
|
else {
|
|
@@ -59,7 +59,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
|
|
|
59
59
|
if (state.signingSecret) {
|
|
60
60
|
await io.writeRuntimeSecrets({ signingSecret: state.signingSecret });
|
|
61
61
|
state = { ...state, signingSecret: undefined };
|
|
62
|
-
|
|
62
|
+
writeSlackOnboardingState(input.stateRoot, state);
|
|
63
63
|
}
|
|
64
64
|
if (!state.appId || !state.clientId || !state.clientSecret) {
|
|
65
65
|
throw new Error("Slack onboarding state lost app OAuth credentials before installation");
|
|
@@ -104,7 +104,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
|
|
|
104
104
|
teamName: oauth.teamName,
|
|
105
105
|
installedAt: new Date().toISOString(),
|
|
106
106
|
};
|
|
107
|
-
|
|
107
|
+
writeSlackOnboardingState(input.stateRoot, state);
|
|
108
108
|
return state;
|
|
109
109
|
}
|
|
110
110
|
export function newSlackOnboardingState(input) {
|
|
@@ -18,9 +18,13 @@ export interface SlackOnboardingState {
|
|
|
18
18
|
teamName?: string;
|
|
19
19
|
installedAt?: string;
|
|
20
20
|
}
|
|
21
|
-
export declare function readSlackOnboardingState(stateRoot: string):
|
|
22
|
-
/** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
|
|
23
|
-
|
|
21
|
+
export declare function readSlackOnboardingState(stateRoot: string): SlackOnboardingState | undefined;
|
|
22
|
+
/** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
|
|
23
|
+
* token. Synchronous, through the shared writer, like every other piece of state this repo keeps
|
|
24
|
+
* (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
|
|
25
|
+
* rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
|
|
26
|
+
* and permission rules to keep true. */
|
|
27
|
+
export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): void;
|
|
24
28
|
export declare function currentSlackConfigToken(stateRoot: string, state: SlackOnboardingState, options?: {
|
|
25
29
|
now?: number;
|
|
26
30
|
apiBaseUrl?: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { writeFileAtomic } from "../../atomic-write.js";
|
|
3
4
|
import { rotateSlackConfigToken } from "./config-api.js";
|
|
4
5
|
function slackOnboardingStatePath(stateRoot) {
|
|
5
6
|
return join(stateRoot, "channels", "slack", "onboarding.json");
|
|
@@ -15,11 +16,11 @@ function validState(value) {
|
|
|
15
16
|
typeof state.configRefreshToken === "string" &&
|
|
16
17
|
typeof state.configTokenExpiresAt === "number");
|
|
17
18
|
}
|
|
18
|
-
export
|
|
19
|
+
export function readSlackOnboardingState(stateRoot) {
|
|
19
20
|
const file = slackOnboardingStatePath(stateRoot);
|
|
20
21
|
let raw;
|
|
21
22
|
try {
|
|
22
|
-
raw =
|
|
23
|
+
raw = readFileSync(file, "utf8");
|
|
23
24
|
}
|
|
24
25
|
catch (error) {
|
|
25
26
|
if (error.code === "ENOENT")
|
|
@@ -36,21 +37,13 @@ export async function readSlackOnboardingState(stateRoot) {
|
|
|
36
37
|
throw new Error(`invalid Slack onboarding state ${file}: ${error.message}`);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
/** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
await chmod(temp, 0o600);
|
|
47
|
-
await rename(temp, file);
|
|
48
|
-
await chmod(file, 0o600);
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
await rm(temp, { force: true }).catch(() => { });
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
40
|
+
/** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
|
|
41
|
+
* token. Synchronous, through the shared writer, like every other piece of state this repo keeps
|
|
42
|
+
* (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
|
|
43
|
+
* rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
|
|
44
|
+
* and permission rules to keep true. */
|
|
45
|
+
export function writeSlackOnboardingState(stateRoot, state) {
|
|
46
|
+
writeFileAtomic(slackOnboardingStatePath(stateRoot), `${JSON.stringify(state, null, 2)}\n`, 0o600);
|
|
54
47
|
}
|
|
55
48
|
export async function currentSlackConfigToken(stateRoot, state, options = {}) {
|
|
56
49
|
const now = options.now ?? Date.now();
|
|
@@ -64,6 +57,6 @@ export async function currentSlackConfigToken(stateRoot, state, options = {}) {
|
|
|
64
57
|
configTokenExpiresAt: rotated.expiresAt,
|
|
65
58
|
teamId: state.teamId ?? rotated.teamId,
|
|
66
59
|
};
|
|
67
|
-
|
|
60
|
+
writeSlackOnboardingState(stateRoot, next);
|
|
68
61
|
return { token: next.configToken, state: next };
|
|
69
62
|
}
|
|
@@ -8,7 +8,7 @@ export async function registerSlackWebhook(baseUrl, options) {
|
|
|
8
8
|
const publicBaseUrl = baseUrl.replace(/\/$/, "");
|
|
9
9
|
let state;
|
|
10
10
|
try {
|
|
11
|
-
state =
|
|
11
|
+
state = readSlackOnboardingState(options.stateRoot);
|
|
12
12
|
}
|
|
13
13
|
catch (error) {
|
|
14
14
|
note(`[fastagent] slack: cannot read local onboarding state: ${String(error)}`);
|
|
@@ -71,22 +71,6 @@ async function callSlack<T extends { ok?: boolean; error?: string }>(
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function trustedUploadUrl(value: string): URL {
|
|
75
|
-
const url = new URL(value);
|
|
76
|
-
const host = url.hostname.toLowerCase();
|
|
77
|
-
const slackHost =
|
|
78
|
-
host === "slack-files.com" ||
|
|
79
|
-
host.endsWith(".slack-files.com") ||
|
|
80
|
-
host === "slack.com" ||
|
|
81
|
-
host.endsWith(".slack.com") ||
|
|
82
|
-
host === "slack-edge.com" ||
|
|
83
|
-
host.endsWith(".slack-edge.com");
|
|
84
|
-
if (url.protocol !== "https:" || !slackHost) {
|
|
85
|
-
throw new Error(`Slack returned an untrusted upload URL host: ${host}`);
|
|
86
|
-
}
|
|
87
|
-
return url;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
74
|
export default defineTool({
|
|
91
75
|
description:
|
|
92
76
|
"Upload one local file to Slack (`path`), or send a message (`text`) for a turn NO channel is " +
|
|
@@ -140,7 +124,8 @@ export default defineTool({
|
|
|
140
124
|
let byteResponse: Response;
|
|
141
125
|
const handle = await open(filePath, "r");
|
|
142
126
|
try {
|
|
143
|
-
|
|
127
|
+
// where getUploadURLExternal points is Slack's call: these bytes are on their way to Slack either way
|
|
128
|
+
byteResponse = await fetch(upload.upload_url, {
|
|
144
129
|
method: "POST",
|
|
145
130
|
headers: { "content-type": "application/octet-stream" },
|
|
146
131
|
body: handle.readableWebStream(),
|