@fastagent-sh/fastagent 0.19.0 → 0.21.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/README.md +5 -1
- 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-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- 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-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- 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.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- 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 +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- 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 +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- 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 +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- 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 +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- 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 +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -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
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ContextBuffer } from "./context-buffer.ts";
|
|
2
|
+
import { type TurnRecordBase, type TurnStore } from "./turn-store.ts";
|
|
3
|
+
/** A pending turn is the persisted intent minus its attempt count, plus live-only fields the
|
|
4
|
+
* channel adds (a notice's message id) — never persisted, reconstructed fresh on replay. */
|
|
5
|
+
export type PendingBase<S extends TurnRecordBase> = Omit<S, "attempts">;
|
|
6
|
+
export interface TurnRunnerOptions<R extends PendingBase<S>, S extends TurnRecordBase, E> {
|
|
7
|
+
label: string;
|
|
8
|
+
store: TurnStore<S>;
|
|
9
|
+
buffer: ContextBuffer<E>;
|
|
10
|
+
/** Delivery dedup by platform id, recorded post-persist (Slack, Feishu). */
|
|
11
|
+
seen?: {
|
|
12
|
+
add(id: string): void;
|
|
13
|
+
};
|
|
14
|
+
/** The persisted intent for a pending turn — drops the live-only fields. */
|
|
15
|
+
toStored(rec: R): S;
|
|
16
|
+
/** A recovered intent as a pending turn — live-only fields start absent. */
|
|
17
|
+
fromStored(stored: S): R;
|
|
18
|
+
/** The context-buffer bucket this turn folds. */
|
|
19
|
+
bufferKey(rec: R): string;
|
|
20
|
+
/** The place, for the lifecycle log line (`chat=… thread=…`). */
|
|
21
|
+
where(rec: R): string;
|
|
22
|
+
/** Queue feedback when a turn is scheduled BEHIND an active one. Returns what the runner awaits at
|
|
23
|
+
* dequeue (so the turn reliably takes the notice over instead of racing it) and, optionally, how
|
|
24
|
+
* to cancel a notice that has not fired yet. `done` may reject — posting the notice is a platform
|
|
25
|
+
* call — and the runner logs that and runs the turn anyway. */
|
|
26
|
+
onQueuedBehind?(rec: R): {
|
|
27
|
+
done: Promise<void>;
|
|
28
|
+
cancel?: () => void;
|
|
29
|
+
};
|
|
30
|
+
/** Runs before the attempt is counted. Answer false to leave the intent untouched for a later run
|
|
31
|
+
* (Slack: its transport is known to be down, so an Agent turn now would have nowhere to answer). */
|
|
32
|
+
beforeRun?(rec: R): Promise<boolean>;
|
|
33
|
+
/** The attempt could not be recorded (disk failure): a restart replays the turn, so say so on any
|
|
34
|
+
* notice it holds rather than leaving it pinned at "Queued". */
|
|
35
|
+
onDeferred(rec: R): void;
|
|
36
|
+
/** The turn started the ceiling's worth of times without finishing: tell the asker. */
|
|
37
|
+
notifyDropped(rec: R): void;
|
|
38
|
+
/** Run the turn. `onCompleted` is the durable-commit point (the turn's `completed` event): it drops
|
|
39
|
+
* the intent and commits the folded discussion, in that order. A throw is logged as the turn's
|
|
40
|
+
* failure; the intent is dropped either way. */
|
|
41
|
+
execute(rec: R, discussion: {
|
|
42
|
+
text: string;
|
|
43
|
+
consumed: E[];
|
|
44
|
+
}, onCompleted: () => void): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
export interface TurnRunner<R, S> {
|
|
47
|
+
/** Accept a turn: persist its intent (pre-ACK — a failed write throws so the platform redelivers),
|
|
48
|
+
* record the delivery id, enqueue. Recovery re-enqueues without re-persisting. */
|
|
49
|
+
submit(rec: R, persist: boolean): void;
|
|
50
|
+
/** Re-enqueue the turns a prior crash left mid-flight; returns them so a channel can continue its
|
|
51
|
+
* arrival counter. */
|
|
52
|
+
recover(): S[];
|
|
53
|
+
/** Resolve once no turn is in flight — the test/observability seam. */
|
|
54
|
+
idle(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export declare function createTurnRunner<R extends PendingBase<S> & {
|
|
57
|
+
id: string;
|
|
58
|
+
session: string;
|
|
59
|
+
}, S extends TurnRecordBase, E>(options: TurnRunnerOptions<R, S, E>): TurnRunner<R, S>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable-turn LIFECYCLE the stateful chat channels share, over the kit's parts: accept
|
|
3
|
+
* (persist pre-ACK, dedup, enqueue) → dequeue (settle the queue notice, count the attempt against
|
|
4
|
+
* the poison ceiling, fold the buffered discussion) → execute → end (log, drop the intent). Telegram,
|
|
5
|
+
* Slack and Feishu each wrote this out; the copies had already drifted in small ways that were not
|
|
6
|
+
* decisions (which one deletes its notice on defer, which one logs the duration on failure).
|
|
7
|
+
*
|
|
8
|
+
* What stays with the platform is everything that names a platform object: how a queue notice is
|
|
9
|
+
* mounted and taken over, what the prompt looks like, how attachments resolve, what a dropped turn
|
|
10
|
+
* says and where. Those arrive as hooks; the ORDER they run in is this module's.
|
|
11
|
+
*/
|
|
12
|
+
import { log } from "../../log.js";
|
|
13
|
+
import { createTurnQueue } from "./turn-queue.js";
|
|
14
|
+
import { commitAnsweredTurn } from "./turn-store.js";
|
|
15
|
+
export function createTurnRunner(options) {
|
|
16
|
+
const { label, store, buffer, seen, onQueuedBehind } = options;
|
|
17
|
+
const notices = new Map();
|
|
18
|
+
const queue = createTurnQueue({
|
|
19
|
+
label,
|
|
20
|
+
onQueuedBehind: onQueuedBehind && ((rec) => notices.set(rec.id, onQueuedBehind(rec))),
|
|
21
|
+
run: async (rec) => {
|
|
22
|
+
// Runs at DEQUEUE time (serialized). The queue wait is over: cancel a notice that has not
|
|
23
|
+
// fired, then settle so the turn's preview handle is final — in the common path this await is
|
|
24
|
+
// instant. BEFORE the ceiling check so a dropped or deferred turn can take the notice over too.
|
|
25
|
+
const notice = notices.get(rec.id);
|
|
26
|
+
notice?.cancel?.();
|
|
27
|
+
// The notice is the PLATFORM's feedback, not the turn: a failed post must not throw out of the
|
|
28
|
+
// run, which would leak the persisted intent (removed only below) into a replay that burns an
|
|
29
|
+
// attempt against the ceiling. Logged, then the turn proceeds without its handle.
|
|
30
|
+
await notice?.done.catch((error) => log.warn(`${label} queue notice failed: turn=${rec.id} session=${rec.session}: ${String(error)}`));
|
|
31
|
+
notices.delete(rec.id);
|
|
32
|
+
if (options.beforeRun && !(await options.beforeRun(rec)))
|
|
33
|
+
return;
|
|
34
|
+
const decision = store.startAttempt(rec.id);
|
|
35
|
+
if (decision === "exceeded") {
|
|
36
|
+
options.notifyDropped(rec);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (decision === "defer") {
|
|
40
|
+
options.onDeferred(rec);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const startedAt = Date.now();
|
|
44
|
+
log.info(`${label} turn start: turn=${rec.id} session=${rec.session} ${options.where(rec)}`);
|
|
45
|
+
// Snapshot the discussion at dequeue; commit only this snapshot on `completed`, so a message
|
|
46
|
+
// arriving while the turn runs stays buffered for the next answered turn.
|
|
47
|
+
const bufferKey = options.bufferKey(rec);
|
|
48
|
+
const discussion = buffer.peek(bufferKey);
|
|
49
|
+
try {
|
|
50
|
+
await options.execute(rec, discussion, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey, consumed: discussion.consumed }));
|
|
51
|
+
log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
// Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
|
|
58
|
+
// never reach the completed hook. Idempotent. Only an INTERRUPTED run (this finally never
|
|
59
|
+
// runs — a crash or SIGTERM deploy) leaves the record for replay; a transport throw is
|
|
60
|
+
// dropped, not retried (safe retry needs an L2 delivery key).
|
|
61
|
+
store.remove(rec.id);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const submit = (rec, persist) => {
|
|
66
|
+
if (persist) {
|
|
67
|
+
store.add(options.toStored(rec)); // pre-ACK: a failed write throws → 500 → redelivery
|
|
68
|
+
seen?.add(rec.id); // post-persist — recording first could turn a failed write into silent loss
|
|
69
|
+
}
|
|
70
|
+
queue.accept(rec);
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
submit,
|
|
74
|
+
recover() {
|
|
75
|
+
const recovered = store.recover();
|
|
76
|
+
if (recovered.length > 0)
|
|
77
|
+
log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
|
|
78
|
+
for (const stored of recovered)
|
|
79
|
+
submit(options.fromStored(stored), false);
|
|
80
|
+
return recovered;
|
|
81
|
+
},
|
|
82
|
+
idle: () => queue.idle(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -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 ` +
|
|
@@ -1,58 +1,8 @@
|
|
|
1
1
|
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
+
import { larkTransport } from "@fastagent-sh/fastagent/lark";
|
|
2
3
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [lark: chat …]
|
|
6
|
-
// context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
|
|
7
|
-
// the target chat id. tools/ is auto-discovered.
|
|
8
|
-
|
|
9
|
-
// Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
|
|
10
|
-
// the tool call (and the turn), named errors, and success gated on the body's own code===0.
|
|
11
|
-
// Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
|
|
12
|
-
// fail-fast beats a silently sleeping tool.
|
|
13
|
-
const BASE = "https://open.larksuite.com";
|
|
14
|
-
|
|
15
|
-
async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
|
|
16
|
-
let res: Response;
|
|
17
|
-
let raw: string;
|
|
18
|
-
try {
|
|
19
|
-
res = await fetch(`${BASE}${path}`, {
|
|
20
|
-
method: "POST",
|
|
21
|
-
headers: {
|
|
22
|
-
"content-type": "application/json; charset=utf-8",
|
|
23
|
-
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
24
|
-
},
|
|
25
|
-
body: JSON.stringify(body),
|
|
26
|
-
signal: AbortSignal.timeout(30_000),
|
|
27
|
-
});
|
|
28
|
-
raw = await res.text();
|
|
29
|
-
} catch (e) {
|
|
30
|
-
throw new Error(`lark ${path}: ${String(e)}`, { cause: e });
|
|
31
|
-
}
|
|
32
|
-
let data: { code?: number; msg?: string; [k: string]: unknown };
|
|
33
|
-
try {
|
|
34
|
-
data = JSON.parse(raw) as typeof data;
|
|
35
|
-
} catch {
|
|
36
|
-
data = {};
|
|
37
|
-
}
|
|
38
|
-
if (!res.ok || data.code !== 0) {
|
|
39
|
-
throw new Error(`lark ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
|
|
40
|
-
}
|
|
41
|
-
return data;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function tenantToken(): Promise<string> {
|
|
45
|
-
const appId = process.env.LARK_APP_ID;
|
|
46
|
-
const appSecret = process.env.LARK_APP_SECRET;
|
|
47
|
-
if (!appId || !appSecret) throw new Error("LARK_APP_ID / LARK_APP_SECRET are not set");
|
|
48
|
-
const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
|
|
49
|
-
app_id: appId,
|
|
50
|
-
app_secret: appSecret,
|
|
51
|
-
});
|
|
52
|
-
const token = data.tenant_access_token;
|
|
53
|
-
if (typeof token !== "string") throw new Error("lark tenant_access_token: response carried no token");
|
|
54
|
-
return token;
|
|
55
|
-
}
|
|
4
|
+
// Proactive delivery uses the mounted channel's credentials, gateway and token cache. With no
|
|
5
|
+
// channel mounted (fire/invoke/tool), it reads LARK_APP_ID / LARK_APP_SECRET from the environment.
|
|
56
6
|
|
|
57
7
|
export default defineTool({
|
|
58
8
|
description:
|
|
@@ -70,23 +20,18 @@ export default defineTool({
|
|
|
70
20
|
text: z.string().optional().describe("plain text message to send"),
|
|
71
21
|
markdown: z.string().optional().describe("markdown to send as a card"),
|
|
72
22
|
}),
|
|
73
|
-
async execute({ chatId, text, markdown }) {
|
|
23
|
+
async execute({ chatId, text, markdown }, ctx) {
|
|
74
24
|
if ((text === undefined) === (markdown === undefined)) {
|
|
75
25
|
throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
|
|
76
26
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
schema: "2.0",
|
|
86
|
-
body: { elements: [{ tag: "markdown", content: markdown }] },
|
|
87
|
-
}),
|
|
88
|
-
};
|
|
89
|
-
await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
|
|
27
|
+
const api = larkTransport(ctx.cwd);
|
|
28
|
+
if (text !== undefined) await api.sendText({ chatId }, text);
|
|
29
|
+
else {
|
|
30
|
+
await api.sendMessage(chatId, "interactive", JSON.stringify({
|
|
31
|
+
schema: "2.0",
|
|
32
|
+
body: { elements: [{ tag: "markdown", content: markdown }] },
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
90
35
|
return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
|
|
91
36
|
},
|
|
92
37
|
});
|
|
@@ -10,6 +10,41 @@
|
|
|
10
10
|
* unreachable) and a cloud without the config API (the Lark cloud-lag 404 — no re-run can ever
|
|
11
11
|
* register it; the console is the only path).
|
|
12
12
|
* - "failed": this run ends with the webhook NOT registered, and acting + re-running can fix it
|
|
13
|
-
* (
|
|
13
|
+
* (a permanent config error, or the platform still unable to reach the URL when the retries ran out).
|
|
14
14
|
*/
|
|
15
15
|
export type RegistrationOutcome = "registered" | "manual" | "failed";
|
|
16
|
+
export declare const REGISTRATION_RETRY_MS = 10000;
|
|
17
|
+
/**
|
|
18
|
+
* What `deploy --run` spends instead: 180s, because a host CLI returns before the deployment serves —
|
|
19
|
+
* the same gap a tunnel has, on a scale no local signal reports (`docker compose --tunnel` is the one
|
|
20
|
+
* host whose ingress does report it, and its driver waits on it the way the tunnel does).
|
|
21
|
+
*
|
|
22
|
+
* `railway up --ci` returns when the BUILD ends — container start, healthcheck and a freshly minted
|
|
23
|
+
* domain's DNS all happen after that, and railway-deploy.live.test.ts allows 180s for exactly this.
|
|
24
|
+
* Registration failure GATES the deploy (registration-gate.ts), so a budget shorter than the host's
|
|
25
|
+
* own start-up turns a working deployment into a re-run instruction.
|
|
26
|
+
*
|
|
27
|
+
* N attempts buy (N - 1) waits — {@link retryWhile} only waits BETWEEN calls — so 180s of patience is
|
|
28
|
+
* 19, not 18. At 18 the last call went out at t=170s and a host that started answering in the final
|
|
29
|
+
* ten seconds was still reported as a deploy to re-run.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEPLOY_REGISTRATION_ATTEMPTS = 19;
|
|
32
|
+
/**
|
|
33
|
+
* Call, and while `retryable` says the failure is the platform not reaching the URL YET, call again —
|
|
34
|
+
* the one retry loop every registrar spends {@link REGISTRATION_ATTEMPTS} through. Retryability and
|
|
35
|
+
* what a final failure MEANS stay with each platform (their vocabularies differ, and one registrar's
|
|
36
|
+
* terminal state is another's `"manual"`); the counting, the announcement and the wait do not.
|
|
37
|
+
*
|
|
38
|
+
* `onRetry` runs BEFORE the wait — registrars announce there (a silent minute reads as a hang), and
|
|
39
|
+
* `add slack` also drops its duplicate-guard marker so it never spans a sleep. The last error is
|
|
40
|
+
* thrown, so a caller can tell "still unreachable" from "a config error" in one place.
|
|
41
|
+
*/
|
|
42
|
+
export declare function retryWhile<T>(call: () => Promise<T>, retryable: (error: unknown) => boolean, options?: {
|
|
43
|
+
attempts?: number;
|
|
44
|
+
retryMs?: number;
|
|
45
|
+
onRetry?: (info: {
|
|
46
|
+
attempt: number;
|
|
47
|
+
attempts: number;
|
|
48
|
+
error: unknown;
|
|
49
|
+
}) => void;
|
|
50
|
+
}): Promise<T>;
|
|
@@ -1 +1,57 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SHARED: how long a registrar waits for the PLATFORM to be able to reach a freshly minted public URL.
|
|
3
|
+
* One judgement (a new tunnel's warm-up), so one pair of numbers — telegram, feishu, slack registration
|
|
4
|
+
* and `add slack` all spend it.
|
|
5
|
+
*
|
|
6
|
+
* Sized for a tunnel, which is live before its URL is HANDED OVER — not before it is printed, which is
|
|
7
|
+
* what this budget once had to absorb: a quick tunnel's hostname is published when the tunnel registers
|
|
8
|
+
* an edge connection, seconds after cloudflared prints it, and no budget covers a platform that already
|
|
9
|
+
* answered "cannot resolve" (#435). `startCloudflareTunnel` waits for that connection, so what is left
|
|
10
|
+
* here is the platform's own warm-up. Applied by {@link retryWhile} as its default; a DEPLOY is slower
|
|
11
|
+
* and its callers pass {@link DEPLOY_REGISTRATION_ATTEMPTS} instead.
|
|
12
|
+
*/
|
|
13
|
+
const REGISTRATION_ATTEMPTS = 8;
|
|
14
|
+
export const REGISTRATION_RETRY_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* What `deploy --run` spends instead: 180s, because a host CLI returns before the deployment serves —
|
|
17
|
+
* the same gap a tunnel has, on a scale no local signal reports (`docker compose --tunnel` is the one
|
|
18
|
+
* host whose ingress does report it, and its driver waits on it the way the tunnel does).
|
|
19
|
+
*
|
|
20
|
+
* `railway up --ci` returns when the BUILD ends — container start, healthcheck and a freshly minted
|
|
21
|
+
* domain's DNS all happen after that, and railway-deploy.live.test.ts allows 180s for exactly this.
|
|
22
|
+
* Registration failure GATES the deploy (registration-gate.ts), so a budget shorter than the host's
|
|
23
|
+
* own start-up turns a working deployment into a re-run instruction.
|
|
24
|
+
*
|
|
25
|
+
* N attempts buy (N - 1) waits — {@link retryWhile} only waits BETWEEN calls — so 180s of patience is
|
|
26
|
+
* 19, not 18. At 18 the last call went out at t=170s and a host that started answering in the final
|
|
27
|
+
* ten seconds was still reported as a deploy to re-run.
|
|
28
|
+
*/
|
|
29
|
+
export const DEPLOY_REGISTRATION_ATTEMPTS = 19;
|
|
30
|
+
/** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers — the
|
|
31
|
+
* same reason feishu-api.ts does. What must happen BEFORE this wait is the point of `onRetry`. */
|
|
32
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
/**
|
|
34
|
+
* Call, and while `retryable` says the failure is the platform not reaching the URL YET, call again —
|
|
35
|
+
* the one retry loop every registrar spends {@link REGISTRATION_ATTEMPTS} through. Retryability and
|
|
36
|
+
* what a final failure MEANS stay with each platform (their vocabularies differ, and one registrar's
|
|
37
|
+
* terminal state is another's `"manual"`); the counting, the announcement and the wait do not.
|
|
38
|
+
*
|
|
39
|
+
* `onRetry` runs BEFORE the wait — registrars announce there (a silent minute reads as a hang), and
|
|
40
|
+
* `add slack` also drops its duplicate-guard marker so it never spans a sleep. The last error is
|
|
41
|
+
* thrown, so a caller can tell "still unreachable" from "a config error" in one place.
|
|
42
|
+
*/
|
|
43
|
+
export async function retryWhile(call, retryable, options = {}) {
|
|
44
|
+
const attempts = options.attempts ?? REGISTRATION_ATTEMPTS;
|
|
45
|
+
const retryMs = options.retryMs ?? REGISTRATION_RETRY_MS;
|
|
46
|
+
for (let attempt = 1;; attempt++) {
|
|
47
|
+
try {
|
|
48
|
+
return await call();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (attempt >= attempts || !retryable(error))
|
|
52
|
+
throw error;
|
|
53
|
+
options.onRetry?.({ attempt, attempts, error });
|
|
54
|
+
await wait(retryMs);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function secretEquals(given: unknown, expected: string | undefined): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE constant-time comparison every shared-secret gate uses: a webhook secret token, a
|
|
3
|
+
* signature, a bearer token, an envelope secret. Timing-safe on equal lengths; a length mismatch
|
|
4
|
+
* answers false without leaking a prefix. Non-string input reads as no secret — and an EMPTY
|
|
5
|
+
* expected value never matches, so a gate whose secret was never configured cannot be passed by
|
|
6
|
+
* sending none.
|
|
7
|
+
*/
|
|
8
|
+
import { Buffer } from "node:buffer";
|
|
9
|
+
import { timingSafeEqual } from "node:crypto";
|
|
10
|
+
export function secretEquals(given, expected) {
|
|
11
|
+
if (typeof given !== "string" || !expected)
|
|
12
|
+
return false;
|
|
13
|
+
const a = Buffer.from(given);
|
|
14
|
+
const b = Buffer.from(expected);
|
|
15
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
16
|
+
}
|
|
@@ -10,8 +10,40 @@ interface SlackErrorShape {
|
|
|
10
10
|
export declare class SlackConfigApiError extends Error {
|
|
11
11
|
readonly code: string;
|
|
12
12
|
readonly status: number;
|
|
13
|
+
/** Kept, not just formatted into the message: which FIELD Slack rejected is how a caller tells a
|
|
14
|
+
* not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
|
|
15
|
+
readonly errors: {
|
|
16
|
+
message?: string;
|
|
17
|
+
pointer?: string;
|
|
18
|
+
}[];
|
|
13
19
|
constructor(method: string, status: number, data: SlackErrorShape, fallback: string);
|
|
14
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
|
|
23
|
+
* platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
|
|
24
|
+
* poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
|
|
25
|
+
* ends here changed nothing and is safe to repeat.
|
|
26
|
+
*
|
|
27
|
+
* Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
|
|
28
|
+
* appearing anywhere in the formatted string: that also matched errors which merely mention the field.
|
|
29
|
+
*
|
|
30
|
+
* KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
|
|
31
|
+
* arrive the same way, and the wording that would tell them apart has not been observed against the
|
|
32
|
+
* real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
|
|
33
|
+
* the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
|
|
34
|
+
* https by construction; narrow this the day a real rejection is captured.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isSlackRequestUrlUnverified(error: unknown): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Whether a manifest write was refused because the app has token rotation on, which Slack never lets
|
|
39
|
+
* off again — the refusal every app created by a release up to 0.20 meets, since this release sends
|
|
40
|
+
* `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
|
|
41
|
+
* `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
|
|
42
|
+
* read: a miss here sends the operator to repair configuration tokens that are not the problem.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isSlackRotationLocked(error: unknown): boolean;
|
|
45
|
+
/** The upgrade every rotating-app refusal points at. */
|
|
46
|
+
export declare const SLACK_ROTATING_APP_UPGRADE = "this app has token rotation on, which this release no longer uses and Slack cannot turn off \u2014 create a new app (docs/slack.md \u2192 \"Upgrading from a rotating-token app\")";
|
|
15
47
|
export interface SlackAppCredentials {
|
|
16
48
|
appId: string;
|
|
17
49
|
clientId: string;
|
|
@@ -19,7 +51,9 @@ export interface SlackAppCredentials {
|
|
|
19
51
|
signingSecret: string;
|
|
20
52
|
oauthAuthorizeUrl?: string;
|
|
21
53
|
}
|
|
22
|
-
/**
|
|
54
|
+
/** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
|
|
55
|
+
* retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
|
|
56
|
+
* rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
|
|
23
57
|
export declare function createSlackApp(configToken: string, manifest: SlackAppManifest, options?: {
|
|
24
58
|
apiBaseUrl?: string;
|
|
25
59
|
fetch?: typeof fetch;
|
|
@@ -40,8 +74,6 @@ export declare function rotateSlackConfigToken(refreshToken: string, options?: {
|
|
|
40
74
|
}): Promise<RotatedSlackConfigToken>;
|
|
41
75
|
export interface SlackOAuthResult {
|
|
42
76
|
botToken: string;
|
|
43
|
-
botRefreshToken: string;
|
|
44
|
-
botTokenExpiresAt: number;
|
|
45
77
|
appId: string;
|
|
46
78
|
teamId: string;
|
|
47
79
|
teamName?: string;
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
const SLACK_API = "https://slack.com/api";
|
|
2
2
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
3
|
+
/** One `errors[]` entry that means "Slack could not verify this URL", by either signal it may carry.
|
|
4
|
+
* The pointer is the precise one but an INFERENCE — the only reply captured from the real API is the
|
|
5
|
+
* PERMANENT shape error, which reports on the parent pointer (`/settings/event_subscriptions`). If the
|
|
6
|
+
* unverifiable case reports there too, a pointer-only check would retry zero times and gate the deploy
|
|
7
|
+
* immediately, which is the failure this whole path exists to prevent. So a message about VERIFICATION
|
|
8
|
+
* (challenge wording included) counts as well, while one that merely names the field does not — the
|
|
9
|
+
* captured permanent error says "Event Subscription requires a Request URL". */
|
|
10
|
+
const rejectsRequestUrl = (e) => e.pointer?.endsWith("/request_url") === true ||
|
|
11
|
+
(/request.?url/i.test(e.message ?? "") && /verif|challenge/i.test(e.message ?? ""));
|
|
3
12
|
export class SlackConfigApiError extends Error {
|
|
4
13
|
code;
|
|
5
14
|
status;
|
|
15
|
+
/** Kept, not just formatted into the message: which FIELD Slack rejected is how a caller tells a
|
|
16
|
+
* not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
|
|
17
|
+
errors;
|
|
6
18
|
constructor(method, status, data, fallback) {
|
|
7
19
|
const code = data.error ?? fallback;
|
|
8
20
|
const fields = data.errors
|
|
@@ -12,8 +24,42 @@ export class SlackConfigApiError extends Error {
|
|
|
12
24
|
this.name = "SlackConfigApiError";
|
|
13
25
|
this.code = code;
|
|
14
26
|
this.status = status;
|
|
27
|
+
this.errors = data.errors ?? [];
|
|
15
28
|
}
|
|
16
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
|
|
32
|
+
* platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
|
|
33
|
+
* poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
|
|
34
|
+
* ends here changed nothing and is safe to repeat.
|
|
35
|
+
*
|
|
36
|
+
* Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
|
|
37
|
+
* appearing anywhere in the formatted string: that also matched errors which merely mention the field.
|
|
38
|
+
*
|
|
39
|
+
* KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
|
|
40
|
+
* arrive the same way, and the wording that would tell them apart has not been observed against the
|
|
41
|
+
* real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
|
|
42
|
+
* the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
|
|
43
|
+
* https by construction; narrow this the day a real rejection is captured.
|
|
44
|
+
*/
|
|
45
|
+
export function isSlackRequestUrlUnverified(error) {
|
|
46
|
+
return (error instanceof SlackConfigApiError && error.code === "invalid_manifest" && error.errors.some(rejectsRequestUrl));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether a manifest write was refused because the app has token rotation on, which Slack never lets
|
|
50
|
+
* off again — the refusal every app created by a release up to 0.20 meets, since this release sends
|
|
51
|
+
* `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
|
|
52
|
+
* `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
|
|
53
|
+
* read: a miss here sends the operator to repair configuration tokens that are not the problem.
|
|
54
|
+
*/
|
|
55
|
+
export function isSlackRotationLocked(error) {
|
|
56
|
+
if (!(error instanceof SlackConfigApiError))
|
|
57
|
+
return false;
|
|
58
|
+
return (error.code === "cannot_disable_once_enabled" ||
|
|
59
|
+
error.errors.some((e) => e.message?.includes("cannot_disable_once_enabled") || e.pointer?.endsWith("token_rotation_enabled")));
|
|
60
|
+
}
|
|
61
|
+
/** The upgrade every rotating-app refusal points at. */
|
|
62
|
+
export const SLACK_ROTATING_APP_UPGRADE = 'this app has token rotation on, which this release no longer uses and Slack cannot turn off — create a new app (docs/slack.md → "Upgrading from a rotating-token app")';
|
|
17
63
|
async function slackJson(method, body, options = {}) {
|
|
18
64
|
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
19
65
|
let response;
|
|
@@ -45,7 +91,9 @@ async function slackJson(method, body, options = {}) {
|
|
|
45
91
|
}
|
|
46
92
|
return data;
|
|
47
93
|
}
|
|
48
|
-
/**
|
|
94
|
+
/** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
|
|
95
|
+
* retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
|
|
96
|
+
* rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
|
|
49
97
|
export async function createSlackApp(configToken, manifest, options = {}) {
|
|
50
98
|
const data = await slackJson("apps.manifest.create", { manifest: JSON.stringify(manifest) }, { ...options, token: configToken });
|
|
51
99
|
const credentials = data.credentials;
|
|
@@ -126,17 +174,11 @@ export async function exchangeSlackOAuthCode(input, options = {}) {
|
|
|
126
174
|
}
|
|
127
175
|
if (!response.ok || data.ok !== true)
|
|
128
176
|
throw slackOAuthFailure(response.status, data.error);
|
|
129
|
-
if (!data.access_token ||
|
|
130
|
-
|
|
131
|
-
typeof data.expires_in !== "number" ||
|
|
132
|
-
!data.app_id ||
|
|
133
|
-
!data.team?.id) {
|
|
134
|
-
throw new Error("Slack oauth.v2.access succeeded but returned incomplete rotating bot credentials/app identity");
|
|
177
|
+
if (!data.access_token || !data.app_id || !data.team?.id) {
|
|
178
|
+
throw new Error("Slack oauth.v2.access succeeded but returned incomplete bot credentials/app identity");
|
|
135
179
|
}
|
|
136
180
|
return {
|
|
137
181
|
botToken: data.access_token,
|
|
138
|
-
botRefreshToken: data.refresh_token,
|
|
139
|
-
botTokenExpiresAt: Date.now() + data.expires_in * 1_000,
|
|
140
182
|
appId: data.app_id,
|
|
141
183
|
teamId: data.team.id,
|
|
142
184
|
teamName: data.team.name,
|