stageflow 0.36.0 → 0.40.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 +61 -26
- package/dist/agent/claudeAdapter.js +21 -1
- package/dist/agent/claudeTools.d.ts +13 -0
- package/dist/agent/claudeTools.js +98 -0
- package/dist/agent/piAdapter.d.ts +9 -1
- package/dist/agent/piAdapter.js +59 -18
- package/dist/agent/piIsolatedMcp.js +14 -2
- package/dist/agent/piIsolatedMcpProbe.d.ts +5 -1
- package/dist/agent/piIsolatedMcpProbe.js +16 -30
- package/dist/agent/port.d.ts +5 -0
- package/dist/agent/sandboxPiTools.d.ts +99 -0
- package/dist/agent/sandboxPiTools.js +109 -0
- package/dist/agents/agentId.d.ts +8 -0
- package/dist/agents/agentId.js +27 -0
- package/dist/agents/approvalBroker.d.ts +16 -0
- package/dist/agents/approvalBroker.js +45 -0
- package/dist/agents/authorChat.d.ts +19 -0
- package/dist/agents/authorChat.js +63 -0
- package/dist/agents/chatTurn.d.ts +23 -0
- package/dist/agents/chatTurn.js +77 -0
- package/dist/agents/communications/agentmail.d.ts +19 -0
- package/dist/agents/communications/agentmail.js +46 -0
- package/dist/agents/communications/agentmailListener.d.ts +59 -0
- package/dist/agents/communications/agentmailListener.js +183 -0
- package/dist/agents/communications/allowlist.d.ts +10 -0
- package/dist/agents/communications/allowlist.js +36 -0
- package/dist/agents/communications/channelConnections.d.ts +3 -0
- package/dist/agents/communications/channelConnections.js +51 -0
- package/dist/agents/communications/delivery.d.ts +53 -0
- package/dist/agents/communications/delivery.js +142 -0
- package/dist/agents/communications/email.d.ts +26 -0
- package/dist/agents/communications/email.js +20 -0
- package/dist/agents/communications/identity.d.ts +13 -0
- package/dist/agents/communications/identity.js +18 -0
- package/dist/agents/communications/imessageListener.d.ts +41 -0
- package/dist/agents/communications/imessageListener.js +92 -0
- package/dist/agents/communications/inbound.d.ts +63 -0
- package/dist/agents/communications/inbound.js +143 -0
- package/dist/agents/communications/listener.d.ts +36 -0
- package/dist/agents/communications/listener.js +38 -0
- package/dist/agents/communications/photon.d.ts +19 -0
- package/dist/agents/communications/photon.js +38 -0
- package/dist/agents/communications/port.d.ts +27 -0
- package/dist/agents/communications/port.js +6 -0
- package/dist/agents/communications/slack.d.ts +28 -0
- package/dist/agents/communications/slack.js +65 -0
- package/dist/agents/communications/supervisor.d.ts +36 -0
- package/dist/agents/communications/supervisor.js +96 -0
- package/dist/agents/communications/telegram.d.ts +19 -0
- package/dist/agents/communications/telegram.js +52 -0
- package/dist/agents/definitionSummary.d.ts +13 -0
- package/dist/agents/definitionSummary.js +91 -0
- package/dist/agents/delegate.d.ts +3 -0
- package/dist/agents/delegate.js +8 -0
- package/dist/agents/errors.d.ts +10 -0
- package/dist/agents/errors.js +32 -0
- package/dist/agents/guardrails.d.ts +24 -0
- package/dist/agents/guardrails.js +39 -0
- package/dist/agents/historyFit.d.ts +10 -0
- package/dist/agents/historyFit.js +26 -0
- package/dist/agents/hostContext.d.ts +54 -0
- package/dist/agents/hostContext.js +156 -0
- package/dist/agents/ledgers.d.ts +29 -0
- package/dist/agents/ledgers.js +31 -0
- package/dist/agents/lineDiff.d.ts +12 -0
- package/dist/agents/lineDiff.js +46 -0
- package/dist/agents/ndjson.d.ts +9 -0
- package/dist/agents/ndjson.js +47 -0
- package/dist/agents/otlpExport.d.ts +9 -0
- package/dist/agents/otlpExport.js +58 -0
- package/dist/agents/otlpSink.d.ts +7 -0
- package/dist/agents/otlpSink.js +23 -0
- package/dist/agents/policy.d.ts +31 -0
- package/dist/agents/policy.js +84 -0
- package/dist/agents/projectRoot.d.ts +5 -0
- package/dist/agents/projectRoot.js +15 -0
- package/dist/agents/runtime/piRuntime.d.ts +52 -0
- package/dist/agents/runtime/piRuntime.js +474 -0
- package/dist/agents/runtime/types.d.ts +77 -0
- package/dist/agents/runtime/types.js +26 -0
- package/dist/agents/sandbox.d.ts +17 -0
- package/dist/agents/sandbox.js +10 -0
- package/dist/agents/scheduleSource.d.ts +32 -0
- package/dist/agents/scheduleSource.js +100 -0
- package/dist/agents/service.d.ts +298 -0
- package/dist/agents/service.js +1105 -0
- package/dist/agents/sessions.d.ts +38 -0
- package/dist/agents/sessions.js +321 -0
- package/dist/agents/store.d.ts +277 -0
- package/dist/agents/store.js +643 -0
- package/dist/agents/trace.d.ts +27 -0
- package/dist/agents/trace.js +29 -0
- package/dist/agents/turnLifecycle.d.ts +72 -0
- package/dist/agents/turnLifecycle.js +157 -0
- package/dist/agents/webChat/publicChat.d.ts +36 -0
- package/dist/agents/webChat/publicChat.js +290 -0
- package/dist/agents/webChat/rateLimit.d.ts +15 -0
- package/dist/agents/webChat/rateLimit.js +33 -0
- package/dist/agents/webChat/slug.d.ts +2 -0
- package/dist/agents/webChat/slug.js +9 -0
- package/dist/channels/providers/agentmail.js +14 -1
- package/dist/channels/providers/photonImessage.js +36 -0
- package/dist/channels/providers/slack.js +2 -1
- package/dist/channels/providers/telegram.js +2 -1
- package/dist/channels/types.d.ts +27 -0
- package/dist/cli/agentsCommand.d.ts +14 -0
- package/dist/cli/agentsCommand.js +291 -0
- package/dist/cli.js +9 -0
- package/dist/config/browseCatalog.js +4 -11
- package/dist/config/draftPackage.d.ts +1 -0
- package/dist/config/draftPackage.js +4 -0
- package/dist/config/hostConfig.js +9 -0
- package/dist/config/loadAgent.d.ts +266 -0
- package/dist/config/loadAgent.js +374 -0
- package/dist/config/loadPipeline.js +5 -2
- package/dist/config/loadTask.js +34 -1
- package/dist/config/mcpHostCatalog.d.ts +50 -0
- package/dist/config/mcpHostCatalog.js +405 -0
- package/dist/config/mcpOAuth.d.ts +37 -0
- package/dist/config/mcpOAuth.js +263 -0
- package/dist/config/mcpRedaction.d.ts +42 -0
- package/dist/config/mcpRedaction.js +314 -0
- package/dist/config/mcpToolCache.d.ts +20 -0
- package/dist/config/mcpToolCache.js +43 -0
- package/dist/config/mergePipelineIncludes.d.ts +2 -0
- package/dist/config/mergePipelineIncludes.js +25 -0
- package/dist/config/resolveStageMcpServers.d.ts +35 -3
- package/dist/config/resolveStageMcpServers.js +124 -9
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +22 -1
- package/dist/mcp/agentTools.d.ts +8 -0
- package/dist/mcp/agentTools.js +132 -0
- package/dist/mcp/catalogTools.js +17 -10
- package/dist/mcp/deps.d.ts +6 -0
- package/dist/mcp/pipelineAuthorSupport.d.ts +19 -0
- package/dist/mcp/pipelineAuthorSupport.js +10 -0
- package/dist/mcp/pipelineAuthorTools.d.ts +8 -0
- package/dist/mcp/pipelineAuthorTools.js +109 -0
- package/dist/mcp/server.js +5 -0
- package/dist/mcp/tools.js +4 -0
- package/dist/mcp/triggerTools.js +32 -0
- package/dist/memory/access.d.ts +36 -0
- package/dist/memory/access.js +116 -0
- package/dist/memory/agentIssues.d.ts +3 -0
- package/dist/memory/agentIssues.js +23 -0
- package/dist/memory/bashDeny.d.ts +6 -0
- package/dist/memory/bashDeny.js +35 -0
- package/dist/memory/host.d.ts +51 -0
- package/dist/memory/host.js +394 -0
- package/dist/memory/ids.d.ts +15 -0
- package/dist/memory/ids.js +31 -0
- package/dist/memory/worker.d.ts +1 -0
- package/dist/memory/worker.js +84 -0
- package/dist/models/catalog.d.ts +113 -0
- package/dist/models/catalog.js +238 -0
- package/dist/models/response.d.ts +29 -0
- package/dist/models/response.js +25 -0
- package/dist/operatorAgent/agentAuthorContext.d.ts +17 -0
- package/dist/operatorAgent/agentAuthorContext.js +32 -0
- package/dist/operatorAgent/fakeHost.js +2 -0
- package/dist/operatorAgent/host.js +6 -3
- package/dist/operatorAgent/index.d.ts +4 -0
- package/dist/operatorAgent/index.js +4 -0
- package/dist/operatorAgent/piModel.js +2 -0
- package/dist/operatorAgent/profiles/agentAuthor.d.ts +11 -0
- package/dist/operatorAgent/profiles/agentAuthor.js +192 -0
- package/dist/operatorAgent/profiles/agentAuthorPlaybook.d.ts +1 -0
- package/dist/operatorAgent/profiles/agentAuthorPlaybook.js +29 -0
- package/dist/operatorAgent/profiles/workshopAuthor.js +2 -2
- package/dist/operatorAgent/profiles/workshopAuthorTools.js +3 -3
- package/dist/operatorAgent/proposals.d.ts +1 -0
- package/dist/operatorAgent/proposals.js +23 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/preflight/pipelinePreflight.js +1 -0
- package/dist/runstore/createStore.d.ts +2 -0
- package/dist/runstore/createStore.js +8 -0
- package/dist/runstore/overviewSummary.d.ts +56 -0
- package/dist/runstore/overviewSummary.js +134 -0
- package/dist/runstore/port.d.ts +3 -0
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -0
- package/dist/runstore/sqlite/SqliteRunStore.js +18 -0
- package/dist/runstore/sqlite/migrations/013-agents.d.ts +12 -0
- package/dist/runstore/sqlite/migrations/013-agents.js +156 -0
- package/dist/runstore/sqlite/migrations/014-agent-deliveries.d.ts +11 -0
- package/dist/runstore/sqlite/migrations/014-agent-deliveries.js +32 -0
- package/dist/runstore/sqlite/migrations/index.js +4 -0
- package/dist/runtime/gitCheckoutCapability.d.ts +1 -0
- package/dist/runtime/gitCheckoutCapability.js +1 -1
- package/dist/runtime/gitWorktreeCheckout.d.ts +22 -0
- package/dist/runtime/gitWorktreeCheckout.js +120 -0
- package/dist/runtime/sandboxContainer.d.ts +62 -0
- package/dist/runtime/sandboxContainer.js +151 -0
- package/dist/runtime/sandboxDesk.d.ts +28 -0
- package/dist/runtime/sandboxDesk.js +242 -0
- package/dist/runtime/sandboxFiles.d.ts +20 -0
- package/dist/runtime/sandboxFiles.js +150 -0
- package/dist/runtime/stageAttemptBootstrap.js +15 -1
- package/dist/runtime/stageEnvironment.d.ts +2 -0
- package/dist/runtime/stageEnvironment.js +8 -1
- package/dist/runtime/stageProcessLauncher.js +23 -0
- package/dist/runtime/stageRoots.d.ts +9 -0
- package/dist/runtime/stageRoots.js +14 -0
- package/dist/runtime/stageRunner.d.ts +5 -0
- package/dist/runtime/stageRunner.js +38 -4
- package/dist/runtime/stageSandbox.d.ts +16 -0
- package/dist/runtime/stageSandbox.js +54 -0
- package/dist/runtime/stageSecrets.d.ts +6 -1
- package/dist/runtime/stageSecrets.js +34 -1
- package/dist/runtime/stageWorker.js +138 -105
- package/dist/runtime/workspaceBinding.d.ts +2 -1
- package/dist/runtime/workspaceBinding.js +20 -4
- package/dist/server/agentAuthorRoutes.d.ts +5 -0
- package/dist/server/agentAuthorRoutes.js +64 -0
- package/dist/server/agentRoutes.d.ts +5 -0
- package/dist/server/agentRoutes.js +323 -0
- package/dist/server/bootstrap.d.ts +8 -0
- package/dist/server/bootstrap.js +36 -10
- package/dist/server/controlToken.js +8 -0
- package/dist/server/http.d.ts +8 -1
- package/dist/server/http.js +172 -20
- package/dist/server/mcpHost.js +1 -0
- package/dist/server/memoryRoutes.d.ts +5 -0
- package/dist/server/memoryRoutes.js +146 -0
- package/dist/server/projectMcpRoutes.d.ts +1 -0
- package/dist/server/projectMcpRoutes.js +241 -3
- package/dist/toolkit/guard.d.ts +27 -0
- package/dist/toolkit/guard.js +98 -0
- package/dist/toolkit/index.d.ts +48 -0
- package/dist/toolkit/index.js +82 -0
- package/dist/toolkit/meta.d.ts +36 -0
- package/dist/toolkit/meta.js +117 -0
- package/dist/toolkit/profiles.d.ts +13 -0
- package/dist/toolkit/profiles.js +25 -0
- package/dist/toolkit/record.d.ts +15 -0
- package/dist/toolkit/record.js +17 -0
- package/dist/tools/memoryTools.d.ts +95 -0
- package/dist/tools/memoryTools.js +179 -0
- package/dist/types/pipeline.d.ts +3 -0
- package/dist/types/stage.d.ts +2 -0
- package/dist/types/task.d.ts +11 -1
- package/dist/ui/assets/AssistantRuntimeProvider-CsiPFhCT.js +9 -0
- package/dist/ui/assets/MarkdownText-D7tYaIFO.js +29 -0
- package/dist/ui/assets/ReadonlyStore-BfXqHykm.js +6 -0
- package/dist/ui/assets/Tooltip-DrpoRRNi.js +1 -0
- package/dist/ui/assets/chat-BbLukq9W.js +5 -0
- package/dist/ui/assets/chat-zIZU_bP1.css +2 -0
- package/dist/ui/assets/jsx-runtime-iVoFAvQ_.js +1 -0
- package/dist/ui/assets/{index-Clv4sJpU.css → main-BFo79krC.css} +1 -1
- package/dist/ui/assets/main-CijYMeM0.js +259 -0
- package/dist/ui/chat.html +25 -0
- package/dist/ui/index.html +6 -2
- package/dist/workshop/modelSettings.d.ts +2 -2
- package/dist/workshop/modelSettings.js +5 -11
- package/examples/README.md +1 -0
- package/examples/channels-smoke/README.md +12 -1
- package/examples/github-release/README.md +1 -1
- package/examples/memory/README.md +101 -0
- package/examples/memory/desk-outage.task.yaml +15 -0
- package/examples/memory/desk-refund.task.yaml +15 -0
- package/examples/memory/desk-revisit-outage.task.yaml +11 -0
- package/examples/memory/desk-revisit.pipeline.yaml +123 -0
- package/examples/memory/desk.pipeline.yaml +192 -0
- package/examples/memory/file-card.pipeline.yaml +39 -0
- package/examples/memory/file-ledger.task.yaml +22 -0
- package/examples/memory/file-release.task.yaml +24 -0
- package/examples/memory/file-rich.pipeline.yaml +40 -0
- package/examples/memory/file-ticket.task.yaml +7 -0
- package/examples/memory/file-timeline.task.yaml +28 -0
- package/examples/memory/find-card.pipeline.yaml +46 -0
- package/examples/memory/find-ticket.task.yaml +7 -0
- package/examples/memory/missing.pipeline.yaml +16 -0
- package/examples/memory/probe-bash.task.yaml +3 -0
- package/examples/memory/recall.pipeline.yaml +24 -0
- package/examples/memory/recall.task.yaml +5 -0
- package/examples/memory/remember-ada.task.yaml +6 -0
- package/examples/memory/remember-grace.task.yaml +6 -0
- package/examples/memory/remember.pipeline.yaml +58 -0
- package/examples/memory/sandbox-memory-bash.pipeline.yaml +28 -0
- package/examples/memory/stageflow.yaml +12 -0
- package/examples/memory/update-card.pipeline.yaml +43 -0
- package/examples/memory/update-ledger.task.yaml +23 -0
- package/examples/memory/update-rich.pipeline.yaml +44 -0
- package/examples/memory/update-ticket.task.yaml +7 -0
- package/examples/ship-feature/README.md +15 -0
- package/examples/stage-mcp/README.md +1 -1
- package/package.json +2 -1
- package/dist/ui/assets/index-BOhqnPNg.js +0 -260
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { redactSecrets } from "../agent/streamLogRedact.js";
|
|
3
|
+
/**
|
|
4
|
+
* Apply the revision's capture mode to a span before it is stored:
|
|
5
|
+
* `off` records nothing, `metadata` drops prompt/argument/result bodies, and
|
|
6
|
+
* `full` keeps them after secret redaction. Redaction happens here, before
|
|
7
|
+
* storage, so export never sees an unredacted body.
|
|
8
|
+
*/
|
|
9
|
+
export function prepareSpan(capture, draft) {
|
|
10
|
+
if (capture === "off")
|
|
11
|
+
return null;
|
|
12
|
+
const full = capture === "full";
|
|
13
|
+
return {
|
|
14
|
+
id: randomBytes(8).toString("hex"),
|
|
15
|
+
turnId: draft.turnId,
|
|
16
|
+
parentSpanId: draft.parentSpanId ?? null,
|
|
17
|
+
name: draft.name,
|
|
18
|
+
kind: draft.kind,
|
|
19
|
+
status: draft.status,
|
|
20
|
+
startedAt: draft.startedAt.toISOString(),
|
|
21
|
+
endedAt: draft.endedAt?.toISOString() ?? null,
|
|
22
|
+
durationMs: draft.endedAt ? Math.max(0, draft.endedAt.getTime() - draft.startedAt.getTime()) : null,
|
|
23
|
+
inputTokens: draft.usage?.inputTokens ?? null,
|
|
24
|
+
outputTokens: draft.usage?.outputTokens ?? null,
|
|
25
|
+
attributes: draft.attributes ?? {},
|
|
26
|
+
inputText: full && draft.input !== undefined ? redactSecrets(draft.input) : null,
|
|
27
|
+
outputText: full && draft.output !== undefined ? redactSecrets(draft.output) : null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ApprovalBroker, ApprovalOutcome } from "./approvalBroker.js";
|
|
2
|
+
import type { AgentApproval, AgentStore, ApprovalActor, SessionStatus, TurnStatus } from "./store.js";
|
|
3
|
+
/** Turn statuses nothing can leave. */
|
|
4
|
+
export type TerminalTurnStatus = Exclude<TurnStatus, "running" | "waiting">;
|
|
5
|
+
export declare function isTerminalTurnStatus(status: TurnStatus): status is TerminalTurnStatus;
|
|
6
|
+
export type FinishInput = {
|
|
7
|
+
status: TerminalTurnStatus;
|
|
8
|
+
errorCode?: string | null;
|
|
9
|
+
/** The assistant's reply, written with the turn so the transcript and the outcome never disagree. */
|
|
10
|
+
assistant?: {
|
|
11
|
+
id: string;
|
|
12
|
+
author: string;
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
usage?: {
|
|
16
|
+
inputTokens?: number;
|
|
17
|
+
outputTokens?: number;
|
|
18
|
+
};
|
|
19
|
+
costUsd?: number | null;
|
|
20
|
+
evalScores?: Array<{
|
|
21
|
+
metric: string;
|
|
22
|
+
score: number;
|
|
23
|
+
}>;
|
|
24
|
+
sessionStatus?: SessionStatus;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The single owner of what may happen to a turn, its session and its approval. Every change of those statuses goes
|
|
28
|
+
* through here, so the rows and the broker wake-up move together and an illegal move is refused instead of applied.
|
|
29
|
+
*
|
|
30
|
+
* turn: running <-> waiting -> succeeded | failed | cancelled (terminal states are final)
|
|
31
|
+
* approval: pending -> approved | denied | expired (decided approvals are final)
|
|
32
|
+
* session: open -> running <-> waiting -> open (follows its one active turn)
|
|
33
|
+
*
|
|
34
|
+
* The methods are synchronous database work; callers serialise them (AgentService runs them in its exclusive section).
|
|
35
|
+
*/
|
|
36
|
+
export declare class TurnLifecycle {
|
|
37
|
+
private readonly store;
|
|
38
|
+
private readonly broker;
|
|
39
|
+
private readonly nowIso;
|
|
40
|
+
constructor(store: AgentStore, broker: ApprovalBroker, nowIso: () => string);
|
|
41
|
+
/** The turn is paused on a person. Refused (false) unless it is running; an already decided approval returns its outcome instead. */
|
|
42
|
+
wait(turnId: string, approvalId: string): "waiting" | ApprovalOutcome | "inactive";
|
|
43
|
+
/** The decision arrived (or the wait ended): back to running. No-op unless the turn is waiting. */
|
|
44
|
+
resume(turnId: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* End a turn: succeeded, failed or cancelled. Writes the reply, the outcome, scores and session status together, and
|
|
47
|
+
* closes any approval the turn still has open so it can no longer be approved. Returns false (and changes nothing)
|
|
48
|
+
* when the turn is unknown or already over.
|
|
49
|
+
*/
|
|
50
|
+
finish(turnId: string, input: FinishInput): boolean;
|
|
51
|
+
/** The Host restarted: nothing can resume running turns, so they fail and their approvals close. */
|
|
52
|
+
interruptedByRestart(): number;
|
|
53
|
+
/** Close one open approval without a decision (its turn was stopped). Returns false when it was already decided. */
|
|
54
|
+
closeApproval(approvalId: string): boolean;
|
|
55
|
+
/** Expire every approval past its deadline and wake whoever waits on it. */
|
|
56
|
+
expireDue(): void;
|
|
57
|
+
/**
|
|
58
|
+
* Record a decision on the session's pending approval. An approval whose turn is already over is closed instead of
|
|
59
|
+
* approved, so a late click cannot run an action nobody is waiting for or pre-approve a later identical call.
|
|
60
|
+
* Returns the decided approval, or "stale" when the approval had to be closed.
|
|
61
|
+
*/
|
|
62
|
+
decide(pending: AgentApproval, decision: "approve" | "deny", actor: ApprovalActor): {
|
|
63
|
+
approval: AgentApproval;
|
|
64
|
+
} | "stale";
|
|
65
|
+
/**
|
|
66
|
+
* A channel reply that could not be delivered turns a succeeded turn into a failure, and one that finally arrives puts
|
|
67
|
+
* it right. Only that one marker moves; a turn that is unfinished, cancelled or failed for another reason is left alone.
|
|
68
|
+
*/
|
|
69
|
+
replyDelivery(turnId: string, delivered: boolean): void;
|
|
70
|
+
/** Delivery sends a restart cut off: their turns wait for a person. Only finished (succeeded) turns are marked. */
|
|
71
|
+
replyUnconfirmed(turnIds: string[]): void;
|
|
72
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export function isTerminalTurnStatus(status) {
|
|
2
|
+
return status !== "running" && status !== "waiting";
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The single owner of what may happen to a turn, its session and its approval. Every change of those statuses goes
|
|
6
|
+
* through here, so the rows and the broker wake-up move together and an illegal move is refused instead of applied.
|
|
7
|
+
*
|
|
8
|
+
* turn: running <-> waiting -> succeeded | failed | cancelled (terminal states are final)
|
|
9
|
+
* approval: pending -> approved | denied | expired (decided approvals are final)
|
|
10
|
+
* session: open -> running <-> waiting -> open (follows its one active turn)
|
|
11
|
+
*
|
|
12
|
+
* The methods are synchronous database work; callers serialise them (AgentService runs them in its exclusive section).
|
|
13
|
+
*/
|
|
14
|
+
export class TurnLifecycle {
|
|
15
|
+
store;
|
|
16
|
+
broker;
|
|
17
|
+
nowIso;
|
|
18
|
+
constructor(store, broker, nowIso) {
|
|
19
|
+
this.store = store;
|
|
20
|
+
this.broker = broker;
|
|
21
|
+
this.nowIso = nowIso;
|
|
22
|
+
}
|
|
23
|
+
/** The turn is paused on a person. Refused (false) unless it is running; an already decided approval returns its outcome instead. */
|
|
24
|
+
wait(turnId, approvalId) {
|
|
25
|
+
const turn = this.store.getTurn(turnId);
|
|
26
|
+
if (!turn || turn.status !== "running")
|
|
27
|
+
return "inactive";
|
|
28
|
+
const approval = this.store.getApproval(approvalId);
|
|
29
|
+
// Decided between the request and this call: do not park on a decision that already happened.
|
|
30
|
+
if (approval && approval.status !== "pending")
|
|
31
|
+
return approval.status;
|
|
32
|
+
this.store.transaction(() => {
|
|
33
|
+
this.store.setTurnStatus(turnId, "waiting");
|
|
34
|
+
this.store.updateSession(turn.sessionId, { status: "waiting" }, this.nowIso());
|
|
35
|
+
});
|
|
36
|
+
return "waiting";
|
|
37
|
+
}
|
|
38
|
+
/** The decision arrived (or the wait ended): back to running. No-op unless the turn is waiting. */
|
|
39
|
+
resume(turnId) {
|
|
40
|
+
const turn = this.store.getTurn(turnId);
|
|
41
|
+
if (!turn || turn.status !== "waiting")
|
|
42
|
+
return false;
|
|
43
|
+
this.store.transaction(() => {
|
|
44
|
+
this.store.setTurnStatus(turnId, "running");
|
|
45
|
+
this.store.updateSession(turn.sessionId, { status: "running" }, this.nowIso());
|
|
46
|
+
});
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* End a turn: succeeded, failed or cancelled. Writes the reply, the outcome, scores and session status together, and
|
|
51
|
+
* closes any approval the turn still has open so it can no longer be approved. Returns false (and changes nothing)
|
|
52
|
+
* when the turn is unknown or already over.
|
|
53
|
+
*/
|
|
54
|
+
finish(turnId, input) {
|
|
55
|
+
const turn = this.store.getTurn(turnId);
|
|
56
|
+
if (!turn || isTerminalTurnStatus(turn.status))
|
|
57
|
+
return false;
|
|
58
|
+
const now = this.nowIso();
|
|
59
|
+
const closed = this.store.transaction(() => {
|
|
60
|
+
if (input.assistant && input.assistant.text.length > 0) {
|
|
61
|
+
this.store.appendMessage({
|
|
62
|
+
id: input.assistant.id,
|
|
63
|
+
sessionId: turn.sessionId,
|
|
64
|
+
turnId,
|
|
65
|
+
role: "assistant",
|
|
66
|
+
author: input.assistant.author,
|
|
67
|
+
text: input.assistant.text,
|
|
68
|
+
createdAt: now,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
this.store.finishTurn(turnId, {
|
|
72
|
+
status: input.status,
|
|
73
|
+
errorCode: input.errorCode ?? null,
|
|
74
|
+
inputTokens: input.usage?.inputTokens ?? null,
|
|
75
|
+
outputTokens: input.usage?.outputTokens ?? null,
|
|
76
|
+
costUsd: input.costUsd ?? null,
|
|
77
|
+
endedAt: now,
|
|
78
|
+
});
|
|
79
|
+
for (const score of input.evalScores ?? []) {
|
|
80
|
+
this.store.recordEvalScore({ turnId, metric: score.metric, score: score.score, recordedAt: now });
|
|
81
|
+
}
|
|
82
|
+
const ids = this.store.closePendingApprovalsOfTurn(turnId, now);
|
|
83
|
+
this.store.updateSession(turn.sessionId, { status: input.sessionStatus ?? "open" }, now);
|
|
84
|
+
return ids;
|
|
85
|
+
});
|
|
86
|
+
for (const approvalId of closed)
|
|
87
|
+
this.broker.settle(approvalId, "expired");
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/** The Host restarted: nothing can resume running turns, so they fail and their approvals close. */
|
|
91
|
+
interruptedByRestart() {
|
|
92
|
+
return this.store.failInterruptedTurns(this.nowIso());
|
|
93
|
+
}
|
|
94
|
+
/** Close one open approval without a decision (its turn was stopped). Returns false when it was already decided. */
|
|
95
|
+
closeApproval(approvalId) {
|
|
96
|
+
const approval = this.store.getApproval(approvalId);
|
|
97
|
+
if (!approval || approval.status !== "pending")
|
|
98
|
+
return false;
|
|
99
|
+
this.store.decideApproval(approvalId, { status: "expired", decidedAt: this.nowIso(), actor: null });
|
|
100
|
+
this.broker.settle(approvalId, "expired");
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** Expire every approval past its deadline and wake whoever waits on it. */
|
|
104
|
+
expireDue() {
|
|
105
|
+
const now = this.nowIso();
|
|
106
|
+
for (const { approvalId, sessionId } of this.store.expireApprovals(now)) {
|
|
107
|
+
const session = this.store.getSession(sessionId);
|
|
108
|
+
if (session?.status === "waiting")
|
|
109
|
+
this.store.updateSession(sessionId, { status: "open" }, now);
|
|
110
|
+
this.broker.settle(approvalId, "expired");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Record a decision on the session's pending approval. An approval whose turn is already over is closed instead of
|
|
115
|
+
* approved, so a late click cannot run an action nobody is waiting for or pre-approve a later identical call.
|
|
116
|
+
* Returns the decided approval, or "stale" when the approval had to be closed.
|
|
117
|
+
*/
|
|
118
|
+
decide(pending, decision, actor) {
|
|
119
|
+
const now = this.nowIso();
|
|
120
|
+
const turn = pending.turnId ? this.store.getTurn(pending.turnId) : undefined;
|
|
121
|
+
if (turn && isTerminalTurnStatus(turn.status)) {
|
|
122
|
+
this.closeApproval(pending.id);
|
|
123
|
+
return "stale";
|
|
124
|
+
}
|
|
125
|
+
const status = decision === "approve" ? "approved" : "denied";
|
|
126
|
+
this.store.transaction(() => {
|
|
127
|
+
this.store.decideApproval(pending.id, { status, decidedAt: now, actor });
|
|
128
|
+
if (decision === "approve")
|
|
129
|
+
this.store.markPayloadExecuted(pending.sessionId, pending.payloadHash, now);
|
|
130
|
+
this.store.updateSession(pending.sessionId, { status: "open" }, now);
|
|
131
|
+
});
|
|
132
|
+
this.broker.settle(pending.id, status);
|
|
133
|
+
return { approval: { ...pending, status, decidedAt: now, actor } };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* A channel reply that could not be delivered turns a succeeded turn into a failure, and one that finally arrives puts
|
|
137
|
+
* it right. Only that one marker moves; a turn that is unfinished, cancelled or failed for another reason is left alone.
|
|
138
|
+
*/
|
|
139
|
+
replyDelivery(turnId, delivered) {
|
|
140
|
+
const turn = this.store.getTurn(turnId);
|
|
141
|
+
if (!turn)
|
|
142
|
+
return;
|
|
143
|
+
const undelivered = turn.status === "failed" && turn.errorCode === "agent.reply_undelivered";
|
|
144
|
+
if (delivered) {
|
|
145
|
+
if (undelivered)
|
|
146
|
+
this.store.setTurnOutcome(turnId, "succeeded", null);
|
|
147
|
+
}
|
|
148
|
+
else if (turn.status === "succeeded") {
|
|
149
|
+
this.store.setTurnOutcome(turnId, "failed", "agent.reply_undelivered");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Delivery sends a restart cut off: their turns wait for a person. Only finished (succeeded) turns are marked. */
|
|
153
|
+
replyUnconfirmed(turnIds) {
|
|
154
|
+
for (const turnId of turnIds)
|
|
155
|
+
this.replyDelivery(turnId, false);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { AgentChatController } from "../chatTurn.js";
|
|
3
|
+
import type { AgentService } from "../service.js";
|
|
4
|
+
export type PublicChatOptions = {
|
|
5
|
+
service: AgentService;
|
|
6
|
+
chat: AgentChatController;
|
|
7
|
+
projectRoot: string;
|
|
8
|
+
/** Extra origins allowed to call the API from a browser (a page hosted elsewhere). Same-origin always works. */
|
|
9
|
+
allowedOrigins?: string[];
|
|
10
|
+
now?: () => number;
|
|
11
|
+
logError?: (message: string) => void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The public, anonymous chat surface. The link slug is the credential; a visitor id the page keeps in
|
|
15
|
+
* the browser scopes conversations. Nothing here returns tool payloads, traces or other visitors' data.
|
|
16
|
+
*/
|
|
17
|
+
export declare class PublicWebChat {
|
|
18
|
+
private readonly options;
|
|
19
|
+
private readonly limiter;
|
|
20
|
+
private readonly resolved;
|
|
21
|
+
private readonly now;
|
|
22
|
+
constructor(options: PublicChatOptions);
|
|
23
|
+
/** True when `pathname` belongs to this surface (the caller then must not fall through). */
|
|
24
|
+
static owns(pathname: string): boolean;
|
|
25
|
+
handle(req: IncomingMessage, res: ServerResponse, method: string, pathname: string): Promise<boolean>;
|
|
26
|
+
private collection;
|
|
27
|
+
private threadView;
|
|
28
|
+
private ownedSession;
|
|
29
|
+
private send;
|
|
30
|
+
private resolve;
|
|
31
|
+
private visitorOf;
|
|
32
|
+
/** Messages and new conversations have their own budgets (`kind`); thread creation gets twice the message limit. */
|
|
33
|
+
private throttle;
|
|
34
|
+
private applyCors;
|
|
35
|
+
private json;
|
|
36
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { isAgentError } from "../errors.js";
|
|
2
|
+
import { BodyError, readJsonBody, writeFrame } from "../ndjson.js";
|
|
3
|
+
import { SlidingWindowLimiter } from "./rateLimit.js";
|
|
4
|
+
const SOURCE = "web";
|
|
5
|
+
const MAX_THREADS = 50;
|
|
6
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
7
|
+
const RESOLVE_TTL_MS = 1_000;
|
|
8
|
+
const WAIT_POLL_MS = 1_000;
|
|
9
|
+
const VISITOR_ID = /^[A-Za-z0-9_-]{16,64}$/;
|
|
10
|
+
const FRIENDLY = {
|
|
11
|
+
"web_chat.unavailable": { status: 404, message: "This chat isn't available." },
|
|
12
|
+
"web_chat.rate_limited": { status: 429, message: "You're sending messages too quickly. Please wait a moment." },
|
|
13
|
+
"web_chat.message_too_long": { status: 413, message: "That message is too long." },
|
|
14
|
+
"web_chat.busy": { status: 409, message: "Still working on your last message." },
|
|
15
|
+
"web_chat.limit_reached": { status: 409, message: "This conversation has reached its limit. Start a new one." },
|
|
16
|
+
"web_chat.invalid": { status: 400, message: "That request wasn't understood." },
|
|
17
|
+
"web_chat.failed": { status: 500, message: "Something went wrong. Please try again." },
|
|
18
|
+
};
|
|
19
|
+
class WebChatError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
retryAfterSec;
|
|
22
|
+
constructor(code, retryAfterSec, message) {
|
|
23
|
+
super(message ?? FRIENDLY[code]?.message ?? code);
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.retryAfterSec = retryAfterSec;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function errorFrame(error) {
|
|
29
|
+
return {
|
|
30
|
+
type: "error",
|
|
31
|
+
code: error.code,
|
|
32
|
+
message: error.message,
|
|
33
|
+
...(error.retryAfterSec !== undefined ? { retryAfterSec: error.retryAfterSec } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function toWebChatError(error) {
|
|
37
|
+
if (error instanceof WebChatError)
|
|
38
|
+
return error;
|
|
39
|
+
if (isAgentError(error)) {
|
|
40
|
+
if (error.code === "agent.limit_reached")
|
|
41
|
+
return new WebChatError("web_chat.limit_reached");
|
|
42
|
+
if (error.code === "agent.session_busy")
|
|
43
|
+
return new WebChatError("web_chat.busy");
|
|
44
|
+
if (error.code === "agent.disabled" || error.code === "agent.not_found" || error.code === "agent.archived") {
|
|
45
|
+
return new WebChatError("web_chat.unavailable");
|
|
46
|
+
}
|
|
47
|
+
if (error.code === "agent.guardrail_blocked") {
|
|
48
|
+
return new WebChatError("web_chat.failed", undefined, "I can't help with that. Try asking differently.");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return new WebChatError("web_chat.failed");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The public, anonymous chat surface. The link slug is the credential; a visitor id the page keeps in
|
|
55
|
+
* the browser scopes conversations. Nothing here returns tool payloads, traces or other visitors' data.
|
|
56
|
+
*/
|
|
57
|
+
export class PublicWebChat {
|
|
58
|
+
options;
|
|
59
|
+
limiter = new SlidingWindowLimiter();
|
|
60
|
+
resolved = new Map();
|
|
61
|
+
now;
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this.options = options;
|
|
64
|
+
this.now = options.now ?? Date.now;
|
|
65
|
+
}
|
|
66
|
+
/** True when `pathname` belongs to this surface (the caller then must not fall through). */
|
|
67
|
+
static owns(pathname) {
|
|
68
|
+
return pathname.startsWith("/api/public/chat/");
|
|
69
|
+
}
|
|
70
|
+
async handle(req, res, method, pathname) {
|
|
71
|
+
if (!PublicWebChat.owns(pathname))
|
|
72
|
+
return false;
|
|
73
|
+
this.applyCors(req, res);
|
|
74
|
+
if (method === "OPTIONS") {
|
|
75
|
+
res.writeHead(204);
|
|
76
|
+
res.end();
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parts = pathname.slice("/api/public/chat/".length).replace(/\/+$/, "").split("/");
|
|
81
|
+
const [slug = "", threads, threadId, messages] = parts;
|
|
82
|
+
const shapeOk = parts.length === 1 || (threads === "threads" && (parts.length === 2 || parts.length === 3 || (parts.length === 4 && messages === "messages")));
|
|
83
|
+
if (!shapeOk)
|
|
84
|
+
throw new WebChatError("web_chat.unavailable");
|
|
85
|
+
const target = await this.resolve(slug);
|
|
86
|
+
if (parts.length === 1) {
|
|
87
|
+
if (method !== "GET")
|
|
88
|
+
throw new WebChatError("web_chat.invalid");
|
|
89
|
+
return this.json(res, 200, { agent: { name: target.definition.name, greeting: target.definition.greeting ?? null } });
|
|
90
|
+
}
|
|
91
|
+
const visitor = this.visitorOf(req);
|
|
92
|
+
if (parts.length === 2)
|
|
93
|
+
return this.collection(req, res, method, target, visitor);
|
|
94
|
+
const session = this.ownedSession(target, visitor, decodeURIComponent(threadId));
|
|
95
|
+
if (parts.length === 4) {
|
|
96
|
+
if (method !== "POST")
|
|
97
|
+
throw new WebChatError("web_chat.invalid");
|
|
98
|
+
return await this.send(req, res, target, visitor, session);
|
|
99
|
+
}
|
|
100
|
+
if (method !== "GET")
|
|
101
|
+
throw new WebChatError("web_chat.invalid");
|
|
102
|
+
const detail = await this.options.service.getSession(this.options.projectRoot, session.id);
|
|
103
|
+
return this.json(res, 200, {
|
|
104
|
+
thread: this.threadView(detail.session),
|
|
105
|
+
status: detail.session.status === "running" || detail.session.status === "waiting" ? detail.session.status : "open",
|
|
106
|
+
messages: detail.messages.map((message) => ({ id: message.id, role: message.role, text: message.text, createdAt: message.createdAt })),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const failure = toWebChatError(error);
|
|
111
|
+
if (failure.code === "web_chat.failed")
|
|
112
|
+
this.options.logError?.(`public web chat failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
113
|
+
if (res.headersSent) {
|
|
114
|
+
if (!res.writableEnded)
|
|
115
|
+
res.end(`${JSON.stringify(errorFrame(failure))}\n`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (failure.retryAfterSec !== undefined)
|
|
119
|
+
res.setHeader("Retry-After", String(failure.retryAfterSec));
|
|
120
|
+
const { type: _type, ...body } = errorFrame(failure);
|
|
121
|
+
return this.json(res, (FRIENDLY[failure.code] ?? FRIENDLY["web_chat.failed"]).status, body);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async collection(req, res, method, target, visitor) {
|
|
125
|
+
const { service, projectRoot } = this.options;
|
|
126
|
+
if (method === "GET") {
|
|
127
|
+
const sessions = service.listSessionsForRef(projectRoot, target.agentId, SOURCE, visitor, MAX_THREADS);
|
|
128
|
+
return this.json(res, 200, { threads: sessions.map((session) => this.threadView(session)) });
|
|
129
|
+
}
|
|
130
|
+
if (method !== "POST")
|
|
131
|
+
throw new WebChatError("web_chat.invalid");
|
|
132
|
+
this.throttle(target.definition.web_chat.slug ?? "", visitor, req, target, "thread");
|
|
133
|
+
const existing = service.listSessionsForRef(projectRoot, target.agentId, SOURCE, visitor, MAX_THREADS);
|
|
134
|
+
if (existing.length >= MAX_THREADS) {
|
|
135
|
+
throw new WebChatError("web_chat.limit_reached", undefined, "You have too many conversations. Please reuse an earlier one.");
|
|
136
|
+
}
|
|
137
|
+
const session = await service.openSession(projectRoot, target.agentId, { source: SOURCE, sourceRef: visitor });
|
|
138
|
+
return this.json(res, 201, { thread: this.threadView(session) });
|
|
139
|
+
}
|
|
140
|
+
threadView(session) {
|
|
141
|
+
return { id: session.id, title: session.title ?? "New chat", updatedAt: session.updatedAt };
|
|
142
|
+
}
|
|
143
|
+
ownedSession(target, visitor, threadId) {
|
|
144
|
+
const found = this.options.service.listSessionsForRef(this.options.projectRoot, target.agentId, SOURCE, visitor, 500).find((session) => session.id === threadId);
|
|
145
|
+
if (!found)
|
|
146
|
+
throw new WebChatError("web_chat.unavailable");
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
/* ---------- sending ---------- */
|
|
150
|
+
async send(req, res, target, visitor, session) {
|
|
151
|
+
const { service, chat, projectRoot } = this.options;
|
|
152
|
+
const slug = target.definition.web_chat.slug ?? "";
|
|
153
|
+
this.throttle(slug, visitor, req, target, "message");
|
|
154
|
+
const body = await readJson(req);
|
|
155
|
+
const text = typeof body.text === "string" ? body.text.trim() : "";
|
|
156
|
+
if (text.length === 0)
|
|
157
|
+
throw new WebChatError("web_chat.invalid");
|
|
158
|
+
if (text.length > target.definition.web_chat.max_message_chars)
|
|
159
|
+
throw new WebChatError("web_chat.message_too_long");
|
|
160
|
+
const current = await service.getSession(projectRoot, session.id);
|
|
161
|
+
if (current.session.status === "running" || current.session.status === "waiting")
|
|
162
|
+
throw new WebChatError("web_chat.busy");
|
|
163
|
+
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-store", "X-Accel-Buffering": "no" });
|
|
164
|
+
let finished = false;
|
|
165
|
+
let writes = Promise.resolve();
|
|
166
|
+
const write = (frame) => {
|
|
167
|
+
writes = writes.then(() => writeFrame(res, frame));
|
|
168
|
+
};
|
|
169
|
+
res.once("close", () => {
|
|
170
|
+
if (!finished)
|
|
171
|
+
chat.stop(session.id);
|
|
172
|
+
});
|
|
173
|
+
// An operator may need to approve a tool: tell the visitor once per pending approval, never what it is.
|
|
174
|
+
let announced = null;
|
|
175
|
+
const watcher = setInterval(() => {
|
|
176
|
+
try {
|
|
177
|
+
const pending = service.pendingApprovalFor(projectRoot, session.id);
|
|
178
|
+
if (pending && pending !== announced) {
|
|
179
|
+
announced = pending;
|
|
180
|
+
write({ type: "waiting" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// a lookup failure must not fail the turn
|
|
185
|
+
}
|
|
186
|
+
}, WAIT_POLL_MS);
|
|
187
|
+
try {
|
|
188
|
+
const detail = await chat.send({
|
|
189
|
+
projectRoot,
|
|
190
|
+
agentId: target.agentId,
|
|
191
|
+
sessionId: session.id,
|
|
192
|
+
text,
|
|
193
|
+
source: SOURCE,
|
|
194
|
+
onDelta: (delta) => write({ type: "delta", text: delta }),
|
|
195
|
+
});
|
|
196
|
+
finished = true;
|
|
197
|
+
const reply = [...detail.messages].reverse().find((message) => message.role === "assistant");
|
|
198
|
+
write(reply
|
|
199
|
+
? { type: "done", message: { id: reply.id, role: "assistant", text: reply.text, createdAt: reply.createdAt } }
|
|
200
|
+
: { type: "done", message: null });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
finished = true;
|
|
204
|
+
const failure = toWebChatError(error);
|
|
205
|
+
if (failure.code === "web_chat.failed")
|
|
206
|
+
this.options.logError?.(`public web chat turn failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
207
|
+
write(errorFrame(failure));
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
clearInterval(watcher);
|
|
211
|
+
await writes.catch(() => undefined);
|
|
212
|
+
if (!res.writableEnded && !res.destroyed)
|
|
213
|
+
res.end();
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
/* ---------- plumbing ---------- */
|
|
218
|
+
async resolve(slug) {
|
|
219
|
+
const now = this.now();
|
|
220
|
+
const cached = this.resolved.get(slug);
|
|
221
|
+
if (cached && now - cached.at < RESOLVE_TTL_MS) {
|
|
222
|
+
if (!cached.value)
|
|
223
|
+
throw new WebChatError("web_chat.unavailable");
|
|
224
|
+
return cached.value;
|
|
225
|
+
}
|
|
226
|
+
const value = /^[a-z0-9]{16,32}$/.test(slug) ? await this.options.service.findWebChatAgent(this.options.projectRoot, slug) : null;
|
|
227
|
+
this.resolved.set(slug, { at: now, value });
|
|
228
|
+
if (this.resolved.size > 500)
|
|
229
|
+
this.resolved.clear();
|
|
230
|
+
if (!value)
|
|
231
|
+
throw new WebChatError("web_chat.unavailable");
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
234
|
+
visitorOf(req) {
|
|
235
|
+
const raw = req.headers["x-visitor-id"];
|
|
236
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
237
|
+
if (!value || !VISITOR_ID.test(value))
|
|
238
|
+
throw new WebChatError("web_chat.invalid");
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
/** Messages and new conversations have their own budgets (`kind`); thread creation gets twice the message limit. */
|
|
242
|
+
throttle(slug, visitor, req, target, kind) {
|
|
243
|
+
const limit = target.definition.web_chat.rate_limit_per_minute * (kind === "thread" ? 2 : 1);
|
|
244
|
+
const now = this.now();
|
|
245
|
+
const perVisitor = this.limiter.take(`${kind}:v:${slug}:${visitor}`, limit, now);
|
|
246
|
+
if (!perVisitor.ok)
|
|
247
|
+
throw new WebChatError("web_chat.rate_limited", perVisitor.retryAfterSec);
|
|
248
|
+
const address = req.socket.remoteAddress ?? "unknown";
|
|
249
|
+
const perAddress = this.limiter.take(`${kind}:ip:${slug}:${address}`, limit * 3, now);
|
|
250
|
+
if (!perAddress.ok)
|
|
251
|
+
throw new WebChatError("web_chat.rate_limited", perAddress.retryAfterSec);
|
|
252
|
+
}
|
|
253
|
+
applyCors(req, res) {
|
|
254
|
+
const origin = req.headers.origin;
|
|
255
|
+
res.setHeader("Vary", "Origin");
|
|
256
|
+
res.setHeader("Cache-Control", "no-store");
|
|
257
|
+
if (!origin)
|
|
258
|
+
return;
|
|
259
|
+
// `Origin: null` (sandboxed frames, file pages) and malformed values are not same-origin and get no CORS headers.
|
|
260
|
+
let sameOrigin = false;
|
|
261
|
+
try {
|
|
262
|
+
sameOrigin = req.headers.host !== undefined && new URL(origin).host === req.headers.host;
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
sameOrigin = false;
|
|
266
|
+
}
|
|
267
|
+
if (sameOrigin || (this.options.allowedOrigins ?? []).includes(origin)) {
|
|
268
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
269
|
+
res.setHeader("Access-Control-Allow-Headers", "content-type, x-visitor-id");
|
|
270
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
271
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
json(res, status, body) {
|
|
275
|
+
if (!res.headersSent)
|
|
276
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
277
|
+
res.end(JSON.stringify(body));
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function readJson(req) {
|
|
282
|
+
try {
|
|
283
|
+
return await readJsonBody(req, MAX_BODY_BYTES);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (error instanceof BodyError && error.reason === "too_large")
|
|
287
|
+
throw new WebChatError("web_chat.message_too_long");
|
|
288
|
+
throw new WebChatError("web_chat.invalid");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Fixed-size sliding window per key. In memory: a Host restart forgives everyone, which is fine for abuse control. */
|
|
2
|
+
export declare class SlidingWindowLimiter {
|
|
3
|
+
private readonly windowMs;
|
|
4
|
+
private readonly hits;
|
|
5
|
+
private lastPrune;
|
|
6
|
+
constructor(windowMs?: number);
|
|
7
|
+
/** Records one hit unless the key is over `limit`; otherwise says how long until a slot frees up. */
|
|
8
|
+
take(key: string, limit: number, now: number): {
|
|
9
|
+
ok: true;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
retryAfterSec: number;
|
|
13
|
+
};
|
|
14
|
+
private prune;
|
|
15
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Fixed-size sliding window per key. In memory: a Host restart forgives everyone, which is fine for abuse control. */
|
|
2
|
+
export class SlidingWindowLimiter {
|
|
3
|
+
windowMs;
|
|
4
|
+
hits = new Map();
|
|
5
|
+
lastPrune = 0;
|
|
6
|
+
constructor(windowMs = 60_000) {
|
|
7
|
+
this.windowMs = windowMs;
|
|
8
|
+
}
|
|
9
|
+
/** Records one hit unless the key is over `limit`; otherwise says how long until a slot frees up. */
|
|
10
|
+
take(key, limit, now) {
|
|
11
|
+
this.prune(now);
|
|
12
|
+
const recent = (this.hits.get(key) ?? []).filter((at) => now - at < this.windowMs);
|
|
13
|
+
if (recent.length >= limit) {
|
|
14
|
+
this.hits.set(key, recent);
|
|
15
|
+
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((recent[0] + this.windowMs - now) / 1000)) };
|
|
16
|
+
}
|
|
17
|
+
recent.push(now);
|
|
18
|
+
this.hits.set(key, recent);
|
|
19
|
+
return { ok: true };
|
|
20
|
+
}
|
|
21
|
+
prune(now) {
|
|
22
|
+
if (now - this.lastPrune < this.windowMs)
|
|
23
|
+
return;
|
|
24
|
+
this.lastPrune = now;
|
|
25
|
+
for (const [key, list] of this.hits) {
|
|
26
|
+
const recent = list.filter((at) => now - at < this.windowMs);
|
|
27
|
+
if (recent.length === 0)
|
|
28
|
+
this.hits.delete(key);
|
|
29
|
+
else
|
|
30
|
+
this.hits.set(key, recent);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { randomInt } from "node:crypto";
|
|
2
|
+
const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
3
|
+
/** A 16-character random link token. The link is the credential, so it comes from the CSPRNG. */
|
|
4
|
+
export function newWebChatSlug() {
|
|
5
|
+
let slug = "";
|
|
6
|
+
for (let index = 0; index < 16; index += 1)
|
|
7
|
+
slug += ALPHABET[randomInt(ALPHABET.length)];
|
|
8
|
+
return slug;
|
|
9
|
+
}
|