@foldrun/core 0.3.0 → 0.5.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 +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/dist/src/translator.js
CHANGED
|
@@ -29,6 +29,8 @@ import http from "node:http";
|
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
30
|
import { PROTECTED_PARAMS } from "./providers.js";
|
|
31
31
|
import { viaEgress } from "./egress.js";
|
|
32
|
+
import { trimSlashes } from "./paths.js";
|
|
33
|
+
import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.js";
|
|
32
34
|
function dropped() {
|
|
33
35
|
const seen = new Set();
|
|
34
36
|
return {
|
|
@@ -390,17 +392,42 @@ export function toResponses(req, opts, drop = dropped()) {
|
|
|
390
392
|
}
|
|
391
393
|
return out;
|
|
392
394
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Why a Chat Completion stopped, in Anthropic's words. `content_filter`
|
|
397
|
+
* (OpenAI, Azure, Gemini's safety block) is a refusal; Mistral's
|
|
398
|
+
* `model_length` is the context window filling, not max_tokens. A stop
|
|
399
|
+
* sequence is named only where the provider says which one matched — vLLM
|
|
400
|
+
* as `stop_reason`, SGLang as `matched_stop` — and only when it is one the
|
|
401
|
+
* request asked for; plain OpenAI says `stop` either way, which reads as
|
|
402
|
+
* end_turn. pause_turn has no counterpart: server-side tools are dropped.
|
|
403
|
+
*/
|
|
404
|
+
function chatStop(choice, sawTool, stops) {
|
|
405
|
+
const finish = choice.finish_reason;
|
|
406
|
+
if (finish === "content_filter")
|
|
407
|
+
return { reason: "refusal", sequence: null };
|
|
408
|
+
if (finish === "length")
|
|
409
|
+
return { reason: "max_tokens", sequence: null };
|
|
410
|
+
if (finish === "model_length")
|
|
411
|
+
return { reason: "model_context_window_exceeded", sequence: null };
|
|
412
|
+
if (sawTool || finish === "tool_calls" || finish === "function_call")
|
|
413
|
+
return { reason: "tool_use", sequence: null };
|
|
414
|
+
const matched = typeof choice.stop_reason === "string" ? choice.stop_reason : choice.matched_stop;
|
|
415
|
+
if (typeof matched === "string" && stops.includes(matched))
|
|
416
|
+
return { reason: "stop_sequence", sequence: matched };
|
|
417
|
+
return { reason: "end_turn", sequence: null };
|
|
418
|
+
}
|
|
419
|
+
/** Why a Response stopped: a refusal part or a content filter is a refusal. */
|
|
420
|
+
function responsesStop(incomplete, sawTool, sawRefusal) {
|
|
421
|
+
if (sawRefusal || incomplete === "content_filter")
|
|
422
|
+
return "refusal";
|
|
423
|
+
if (incomplete === "max_output_tokens")
|
|
424
|
+
return "max_tokens";
|
|
425
|
+
if (sawTool)
|
|
426
|
+
return "tool_use";
|
|
427
|
+
return "end_turn";
|
|
428
|
+
}
|
|
429
|
+
function stopList(stops) {
|
|
430
|
+
return Array.isArray(stops) ? stops.filter((s) => typeof s === "string") : [];
|
|
404
431
|
}
|
|
405
432
|
function parseArgs(raw) {
|
|
406
433
|
if (typeof raw !== "string" || !raw.trim())
|
|
@@ -413,7 +440,7 @@ function parseArgs(raw) {
|
|
|
413
440
|
}
|
|
414
441
|
}
|
|
415
442
|
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
416
|
-
export function fromChatCompletion(res, requestedModel) {
|
|
443
|
+
export function fromChatCompletion(res, requestedModel, stopSequences) {
|
|
417
444
|
const choice = (res.choices ?? [])[0] ?? {};
|
|
418
445
|
const msg = choice.message ?? {};
|
|
419
446
|
const content = [];
|
|
@@ -430,14 +457,15 @@ export function fromChatCompletion(res, requestedModel) {
|
|
|
430
457
|
});
|
|
431
458
|
}
|
|
432
459
|
const usage = res.usage ?? {};
|
|
460
|
+
const stop = chatStop(choice, content.some((c) => c.type === "tool_use"), stopList(stopSequences));
|
|
433
461
|
return {
|
|
434
462
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
435
463
|
type: "message",
|
|
436
464
|
role: "assistant",
|
|
437
465
|
model: String(res.model ?? requestedModel),
|
|
438
466
|
content,
|
|
439
|
-
stop_reason:
|
|
440
|
-
stop_sequence:
|
|
467
|
+
stop_reason: stop.reason,
|
|
468
|
+
stop_sequence: stop.sequence,
|
|
441
469
|
usage: {
|
|
442
470
|
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
443
471
|
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
@@ -457,12 +485,15 @@ export class StreamTranslator {
|
|
|
457
485
|
textIndex = null;
|
|
458
486
|
/** Chat-Completions tool index → Anthropic block index. */
|
|
459
487
|
tools = new Map();
|
|
460
|
-
|
|
488
|
+
/** The last choice's stop fields: finish_reason and any matched stop. */
|
|
489
|
+
finish = {};
|
|
461
490
|
usage = null;
|
|
462
491
|
id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
463
492
|
model;
|
|
464
|
-
|
|
493
|
+
stops;
|
|
494
|
+
constructor(model, stopSequences) {
|
|
465
495
|
this.model = model;
|
|
496
|
+
this.stops = stopList(stopSequences);
|
|
466
497
|
}
|
|
467
498
|
event(name, data) {
|
|
468
499
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
@@ -507,7 +538,7 @@ export class StreamTranslator {
|
|
|
507
538
|
if (!choice)
|
|
508
539
|
return out;
|
|
509
540
|
if (choice.finish_reason)
|
|
510
|
-
this.finish = choice.finish_reason;
|
|
541
|
+
this.finish = { finish_reason: choice.finish_reason, stop_reason: choice.stop_reason, matched_stop: choice.matched_stop };
|
|
511
542
|
const delta = choice.delta ?? {};
|
|
512
543
|
const text = typeof delta.content === "string" ? delta.content : "";
|
|
513
544
|
if (text) {
|
|
@@ -574,10 +605,10 @@ export class StreamTranslator {
|
|
|
574
605
|
}
|
|
575
606
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
576
607
|
}
|
|
577
|
-
const
|
|
608
|
+
const stop = chatStop(this.finish, this.tools.size > 0, this.stops);
|
|
578
609
|
out.push(this.event("message_delta", {
|
|
579
610
|
type: "message_delta",
|
|
580
|
-
delta: { stop_reason: reason, stop_sequence:
|
|
611
|
+
delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
|
|
581
612
|
usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
|
|
582
613
|
}));
|
|
583
614
|
out.push(this.event("message_stop", { type: "message_stop" }));
|
|
@@ -607,7 +638,8 @@ export function fromResponses(res, requestedModel) {
|
|
|
607
638
|
}
|
|
608
639
|
const usage = res.usage ?? {};
|
|
609
640
|
const incomplete = res.incomplete_details?.reason;
|
|
610
|
-
const
|
|
641
|
+
const refused = (Array.isArray(res.output) ? res.output : []).some((item) => item.type === "message" && Array.isArray(item.content) && item.content.some((c) => c.type === "refusal"));
|
|
642
|
+
const stop = responsesStop(incomplete, content.some((c) => c.type === "tool_use"), refused);
|
|
611
643
|
return {
|
|
612
644
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
613
645
|
type: "message",
|
|
@@ -637,6 +669,7 @@ export class ResponsesStreamTranslator {
|
|
|
637
669
|
usage = null;
|
|
638
670
|
incomplete = null;
|
|
639
671
|
sawTool = false;
|
|
672
|
+
sawRefusal = false;
|
|
640
673
|
id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
641
674
|
model;
|
|
642
675
|
constructor(model) {
|
|
@@ -699,6 +732,8 @@ export class ResponsesStreamTranslator {
|
|
|
699
732
|
if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
700
733
|
const entry = this.blocks.get(Number(ev.output_index));
|
|
701
734
|
const text = String(ev.delta ?? "");
|
|
735
|
+
if (type === "response.refusal.delta")
|
|
736
|
+
this.sawRefusal = true;
|
|
702
737
|
if (entry && entry.kind === "text" && text) {
|
|
703
738
|
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
|
|
704
739
|
}
|
|
@@ -751,7 +786,7 @@ export class ResponsesStreamTranslator {
|
|
|
751
786
|
}
|
|
752
787
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
753
788
|
}
|
|
754
|
-
const reason = this.
|
|
789
|
+
const reason = responsesStop(this.incomplete, this.sawTool, this.sawRefusal);
|
|
755
790
|
out.push(this.event("message_delta", {
|
|
756
791
|
type: "message_delta",
|
|
757
792
|
delta: { stop_reason: reason, stop_sequence: null },
|
|
@@ -806,7 +841,7 @@ function upstreamMessage(text, status) {
|
|
|
806
841
|
export async function startTranslator(spec) {
|
|
807
842
|
const key = `fr-${crypto.randomBytes(16).toString("hex")}`;
|
|
808
843
|
const log = [];
|
|
809
|
-
const base = spec.upstreamBase
|
|
844
|
+
const base = trimSlashes(spec.upstreamBase);
|
|
810
845
|
const label = spec.label ?? base;
|
|
811
846
|
const server = http.createServer(async (req, res) => {
|
|
812
847
|
const started = Date.now();
|
|
@@ -872,6 +907,18 @@ export async function startTranslator(spec) {
|
|
|
872
907
|
const text = await upstream.text();
|
|
873
908
|
const message = upstreamMessage(text, upstream.status);
|
|
874
909
|
log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
|
|
910
|
+
// The refusal in words, from the headers the response already
|
|
911
|
+
// carried. Only on a refusal, and only the allow-listed headers:
|
|
912
|
+
// a 400 about a malformed body is the work's problem, not the
|
|
913
|
+
// supply's, and explaining it as one would mislead.
|
|
914
|
+
if (isRefusalStatus(upstream.status)) {
|
|
915
|
+
log.push(refusalLine({
|
|
916
|
+
status: upstream.status,
|
|
917
|
+
headers: captureRefusalHeaders(upstream.headers),
|
|
918
|
+
timezone: spec.timezone,
|
|
919
|
+
provider: label,
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
875
922
|
// The status crosses unchanged, so a 401 or 429 from the provider
|
|
876
923
|
// reads as one to the SDK — and to the fallback decision upstream.
|
|
877
924
|
return send(upstream.status, errorBody(upstream.status, message));
|
|
@@ -887,10 +934,10 @@ export async function startTranslator(spec) {
|
|
|
887
934
|
return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
|
|
888
935
|
}
|
|
889
936
|
log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
|
|
890
|
-
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
|
|
937
|
+
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model, body.stop_sequences)));
|
|
891
938
|
}
|
|
892
939
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
893
|
-
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
|
|
940
|
+
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model, body.stop_sequences);
|
|
894
941
|
const reader = upstream.body?.getReader();
|
|
895
942
|
if (!reader) {
|
|
896
943
|
for (const e of machine.finishStream())
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** What became of one trigger firing. `started` is here too: without it the
|
|
2
|
+
* log answers "why did nothing run" and not "how often does anything",
|
|
3
|
+
* and the ratio is the whole point of the page. */
|
|
4
|
+
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "overlap-skipped" | "missed" | "failed";
|
|
5
|
+
export interface TriggerEvent {
|
|
6
|
+
t: string;
|
|
7
|
+
flow: string;
|
|
8
|
+
/** The flow's `trigger:` — schedule, webhook, storage, watch, flow, email. */
|
|
9
|
+
trigger: string;
|
|
10
|
+
outcome: TriggerOutcome;
|
|
11
|
+
/** The sentence a person reads. Always says which key decided, so the fix
|
|
12
|
+
* is one search away in their own flow file. */
|
|
13
|
+
detail?: string;
|
|
14
|
+
runId?: string;
|
|
15
|
+
}
|
|
16
|
+
/** Never throws. A diagnostic that can break a trigger is worse than no
|
|
17
|
+
* diagnostic: the whole point is to be reading it on the bad morning. */
|
|
18
|
+
export declare function recordTriggerEvent(tenant: string, workspace: string, event: TriggerEvent): void;
|
|
19
|
+
export declare function readTriggerEvents(tenant: string, workspace: string, limit?: number): TriggerEvent[];
|
|
20
|
+
export interface FlowTriggerSummary {
|
|
21
|
+
flow: string;
|
|
22
|
+
trigger: string;
|
|
23
|
+
fired: number;
|
|
24
|
+
started: number;
|
|
25
|
+
/** Every outcome that was not a start, by reason, biggest first. */
|
|
26
|
+
dropped: {
|
|
27
|
+
outcome: TriggerOutcome;
|
|
28
|
+
count: number;
|
|
29
|
+
detail: string;
|
|
30
|
+
}[];
|
|
31
|
+
lastFiredAt: string | null;
|
|
32
|
+
lastStartedAt: string | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* One row per flow: how often it fired, how often that became a run, and
|
|
36
|
+
* what happened to the difference.
|
|
37
|
+
*
|
|
38
|
+
* The gap between `fired` and `started` is the number someone is looking
|
|
39
|
+
* for. A flow with forty fires and two runs is either working exactly as
|
|
40
|
+
* configured or badly misconfigured, and the reasons column is what tells
|
|
41
|
+
* those apart without opening the flow file.
|
|
42
|
+
*/
|
|
43
|
+
export declare function summariseTriggers(tenant: string, workspace: string, sinceMs?: number | null): FlowTriggerSummary[];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Why nothing happened.
|
|
2
|
+
//
|
|
3
|
+
// A run that exists can be read. A run that does not exist leaves no record
|
|
4
|
+
// anywhere, and "the hook fired but nothing ran" is the hardest question the
|
|
5
|
+
// platform gets asked — because every honest answer to it is a thing that
|
|
6
|
+
// deliberately did NOT happen:
|
|
7
|
+
//
|
|
8
|
+
// the delivery was a duplicate idempotency:
|
|
9
|
+
// the burst is still being waited out debounce:
|
|
10
|
+
// it arrived inside the window throttle:
|
|
11
|
+
// the flow is switched off disable_after:
|
|
12
|
+
// yesterday's run is still going overlap: skip
|
|
13
|
+
// the platform was down at 05:00 catchup: none
|
|
14
|
+
//
|
|
15
|
+
// Six different reasons, all of them correct behaviour, none of them visible.
|
|
16
|
+
// Before this they were console lines on a box nobody reads.
|
|
17
|
+
//
|
|
18
|
+
// This is deliberately NOT the hook delivery log next door. That one answers
|
|
19
|
+
// "did the request reach us and was it authentic" — a bad token, a failed
|
|
20
|
+
// signature. This one answers "did an event become a run". A delivery can be
|
|
21
|
+
// perfectly authentic and still not start anything, which is exactly the
|
|
22
|
+
// case that needed writing down.
|
|
23
|
+
//
|
|
24
|
+
// One bounded JSONL per workspace, the same shape and the same compaction as
|
|
25
|
+
// the delivery log: this is diagnostics, not an audit trail, and it must
|
|
26
|
+
// never become a disk-full incident with a delay.
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { workspaceDir } from "./store.js";
|
|
30
|
+
function logFile(tenant, workspace) {
|
|
31
|
+
return path.join(workspaceDir(tenant, workspace), "trigger-log.jsonl");
|
|
32
|
+
}
|
|
33
|
+
const KEEP = 500;
|
|
34
|
+
/** Never throws. A diagnostic that can break a trigger is worse than no
|
|
35
|
+
* diagnostic: the whole point is to be reading it on the bad morning. */
|
|
36
|
+
export function recordTriggerEvent(tenant, workspace, event) {
|
|
37
|
+
try {
|
|
38
|
+
const file = logFile(tenant, workspace);
|
|
39
|
+
if (!fs.existsSync(path.dirname(file)))
|
|
40
|
+
return;
|
|
41
|
+
fs.appendFileSync(file, JSON.stringify(event) + "\n");
|
|
42
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
43
|
+
if (lines.length > KEEP * 2) {
|
|
44
|
+
const tmp = `${file}.tmp`;
|
|
45
|
+
fs.writeFileSync(tmp, lines.slice(-KEEP).join("\n") + "\n");
|
|
46
|
+
fs.renameSync(tmp, file);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// best effort, always
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function readTriggerEvents(tenant, workspace, limit = 200) {
|
|
54
|
+
let lines;
|
|
55
|
+
try {
|
|
56
|
+
lines = fs.readFileSync(logFile(tenant, workspace), "utf8").split("\n").filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
// One line at a time: a torn line — an append racing a compaction from
|
|
62
|
+
// another pod — must lose that line, not the whole log, on exactly the
|
|
63
|
+
// morning the log exists for.
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const line of lines.slice(-limit).reverse()) {
|
|
66
|
+
try {
|
|
67
|
+
const e = JSON.parse(line);
|
|
68
|
+
if (e && typeof e.t === "string")
|
|
69
|
+
out.push(e);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// skip the torn line
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* One row per flow: how often it fired, how often that became a run, and
|
|
79
|
+
* what happened to the difference.
|
|
80
|
+
*
|
|
81
|
+
* The gap between `fired` and `started` is the number someone is looking
|
|
82
|
+
* for. A flow with forty fires and two runs is either working exactly as
|
|
83
|
+
* configured or badly misconfigured, and the reasons column is what tells
|
|
84
|
+
* those apart without opening the flow file.
|
|
85
|
+
*/
|
|
86
|
+
export function summariseTriggers(tenant, workspace, sinceMs = null) {
|
|
87
|
+
const rows = new Map();
|
|
88
|
+
const reasons = new Map();
|
|
89
|
+
for (const e of readTriggerEvents(tenant, workspace, 500)) {
|
|
90
|
+
if (sinceMs !== null && new Date(e.t).getTime() < sinceMs)
|
|
91
|
+
continue;
|
|
92
|
+
let row = rows.get(e.flow);
|
|
93
|
+
if (!row) {
|
|
94
|
+
row = { flow: e.flow, trigger: e.trigger, fired: 0, started: 0, dropped: [], lastFiredAt: null, lastStartedAt: null };
|
|
95
|
+
rows.set(e.flow, row);
|
|
96
|
+
reasons.set(e.flow, new Map());
|
|
97
|
+
}
|
|
98
|
+
row.fired++;
|
|
99
|
+
// Events come back newest first, so the first of each is the latest.
|
|
100
|
+
row.lastFiredAt ??= e.t;
|
|
101
|
+
if (e.outcome === "started") {
|
|
102
|
+
row.started++;
|
|
103
|
+
row.lastStartedAt ??= e.t;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const byReason = reasons.get(e.flow);
|
|
107
|
+
const seen = byReason.get(e.outcome);
|
|
108
|
+
// Keep the newest detail: "the last run started 4 minutes ago" is more
|
|
109
|
+
// use than the same sentence from Tuesday.
|
|
110
|
+
if (seen)
|
|
111
|
+
seen.count++;
|
|
112
|
+
else
|
|
113
|
+
byReason.set(e.outcome, { count: 1, detail: e.detail ?? "" });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [flow, byReason] of reasons) {
|
|
117
|
+
rows.get(flow).dropped = [...byReason.entries()]
|
|
118
|
+
.map(([outcome, v]) => ({ outcome, ...v }))
|
|
119
|
+
.sort((a, b) => b.count - a.count);
|
|
120
|
+
}
|
|
121
|
+
return [...rows.values()].sort((a, b) => b.fired - a.fired || a.flow.localeCompare(b.flow));
|
|
122
|
+
}
|
package/dist/src/triggers.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
2
2
|
/** Staple a payload to the first step, the way the run route staples a task. */
|
|
3
3
|
export declare function withTask(steps: FlowStep[], tag: string, body: string): FlowStep[];
|
|
4
|
+
/** Which `on:` a settled run answers to: failed, blocked (completed, but
|
|
5
|
+
* its work refused itself), or completed. */
|
|
6
|
+
export declare function endingOf(run: RunRecord): "completed" | "failed" | "blocked";
|
|
4
7
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
5
8
|
export declare function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[];
|
|
6
9
|
/**
|
|
@@ -22,6 +25,26 @@ export declare function underPrefix(rel: string, prefix: string | null): boolean
|
|
|
22
25
|
* a loop — every other writer counts.
|
|
23
26
|
*/
|
|
24
27
|
export declare function fireStorageTriggers(tenant: string, workspace: string, paths: string[], by: string): Promise<string[]>;
|
|
28
|
+
/**
|
|
29
|
+
* The value that identifies this delivery, as the flow's `idempotency:`
|
|
30
|
+
* names it.
|
|
31
|
+
*
|
|
32
|
+
* Two shapes, and only two:
|
|
33
|
+
*
|
|
34
|
+
* idempotency: x-github-delivery a request header
|
|
35
|
+
* idempotency: body:id a top-level field of a JSON body
|
|
36
|
+
*
|
|
37
|
+
* A NAME, never an expression — the grammar has no expressions, and this is
|
|
38
|
+
* not the place to grow one. Nothing is computed, combined or nested: if the
|
|
39
|
+
* sender does not put an identifier somewhere a name can reach, the flow
|
|
40
|
+
* cannot dedupe on it, and saying so is better than inventing a syntax.
|
|
41
|
+
*
|
|
42
|
+
* Returns null when the flow asks for no key, or when the delivery does not
|
|
43
|
+
* carry the one it asked for. A missing key admits the delivery: refusing
|
|
44
|
+
* one because a sender omitted a header would turn "dedupe when you can"
|
|
45
|
+
* into an outage.
|
|
46
|
+
*/
|
|
47
|
+
export declare function deliveryKey(idempotency: string | null, header: (name: string) => string | null, body: string): string | null;
|
|
25
48
|
export interface SignatureVerdict {
|
|
26
49
|
ok: boolean;
|
|
27
50
|
reason: string;
|
package/dist/src/triggers.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// an agent reads it — the same rule as every other handoff in the format.
|
|
14
14
|
import crypto from "node:crypto";
|
|
15
15
|
import { platform } from "./platform.js";
|
|
16
|
-
import { listFlows, readRun } from "./store.js";
|
|
16
|
+
import { listFlows, readRun, runVerdict } from "./store.js";
|
|
17
17
|
import { getSecret } from "./secrets.js";
|
|
18
18
|
/** The last step's result — the same reading runner.ts makes, kept local so
|
|
19
19
|
* this module never imports the runner (the queue imports both). */
|
|
@@ -33,6 +33,13 @@ export function withTask(steps, tag, body) {
|
|
|
33
33
|
return steps.map((s, i) => (i === 0 ? { ...s, instruction: `${s.instruction}\n\n<${tag}>\n${text}\n</${tag}>` } : s));
|
|
34
34
|
}
|
|
35
35
|
// ------------------------------------------------------------ trigger: flow
|
|
36
|
+
/** Which `on:` a settled run answers to: failed, blocked (completed, but
|
|
37
|
+
* its work refused itself), or completed. */
|
|
38
|
+
export function endingOf(run) {
|
|
39
|
+
if (run.status === "failed")
|
|
40
|
+
return "failed";
|
|
41
|
+
return runVerdict(run) === "BLOCKED" ? "blocked" : "completed";
|
|
42
|
+
}
|
|
36
43
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
37
44
|
export function chainedFlows(tenant, workspace, finished) {
|
|
38
45
|
if (finished.status !== "completed" && finished.status !== "failed")
|
|
@@ -45,7 +52,7 @@ export function chainedFlows(tenant, workspace, finished) {
|
|
|
45
52
|
// A flow chained on itself is a loop with no bound — refused here and
|
|
46
53
|
// by foldrun check, never started.
|
|
47
54
|
f.name !== finished.flow &&
|
|
48
|
-
(f.on === "any" || f.on === finished
|
|
55
|
+
(f.on === "any" || f.on === endingOf(finished)));
|
|
49
56
|
}
|
|
50
57
|
/**
|
|
51
58
|
* Start every flow chained on a run that just settled. The finished run's
|
|
@@ -61,15 +68,29 @@ export async function fireChainedFlows(tenant, workspace, finished) {
|
|
|
61
68
|
const started = [];
|
|
62
69
|
const result = (runResult(finished) ?? "").slice(0, 20_000);
|
|
63
70
|
const body = `run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
|
|
71
|
+
(runVerdict(finished) ? `verdict: ${runVerdict(finished)}\n` : "") +
|
|
64
72
|
(finished.summary ? `summary: ${finished.summary}\n` : "") +
|
|
65
73
|
(result ? `\n${result}` : "");
|
|
66
74
|
for (const flow of flows) {
|
|
67
75
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
|
|
77
|
+
if (!gate.admit) {
|
|
78
|
+
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
// A test run's chain stays a test: the flow after it reads a result
|
|
83
|
+
// nothing real produced, and must not act on it for real either.
|
|
84
|
+
const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
|
|
85
|
+
`after:${finished.flow}`,
|
|
86
|
+
], null, finished.test ? { test: true } : {});
|
|
87
|
+
started.push(run.id);
|
|
88
|
+
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
73
94
|
}
|
|
74
95
|
catch (err) {
|
|
75
96
|
console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
|
|
@@ -115,10 +136,23 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
|
|
|
115
136
|
continue;
|
|
116
137
|
try {
|
|
117
138
|
const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
139
|
+
// A deploy that writes forty files under one prefix is one event, not
|
|
140
|
+
// forty runs — which is exactly what `debounce:` is for.
|
|
141
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
|
|
142
|
+
if (!gate.admit) {
|
|
143
|
+
console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
|
|
148
|
+
"storage",
|
|
149
|
+
]);
|
|
150
|
+
started.push(run.id);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
122
156
|
}
|
|
123
157
|
catch (err) {
|
|
124
158
|
console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
|
|
@@ -126,6 +160,61 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
|
|
|
126
160
|
}
|
|
127
161
|
return started;
|
|
128
162
|
}
|
|
163
|
+
/** HTML to the text a prompt reads. Style and script blocks are removed
|
|
164
|
+
* until none remain — one pass leaves `<sty<style>le>` behind, which is
|
|
165
|
+
* the shape a sanitiser checker (rightly) flags. Nothing here is rendered
|
|
166
|
+
* as HTML afterwards; it becomes a task for an agent. */
|
|
167
|
+
function stripTags(html) {
|
|
168
|
+
let out = html;
|
|
169
|
+
for (let prev = ""; prev !== out;) {
|
|
170
|
+
prev = out;
|
|
171
|
+
out = out.replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
|
|
172
|
+
}
|
|
173
|
+
return out.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
174
|
+
}
|
|
175
|
+
// ------------------------------------------------------- idempotency keys
|
|
176
|
+
/**
|
|
177
|
+
* The value that identifies this delivery, as the flow's `idempotency:`
|
|
178
|
+
* names it.
|
|
179
|
+
*
|
|
180
|
+
* Two shapes, and only two:
|
|
181
|
+
*
|
|
182
|
+
* idempotency: x-github-delivery a request header
|
|
183
|
+
* idempotency: body:id a top-level field of a JSON body
|
|
184
|
+
*
|
|
185
|
+
* A NAME, never an expression — the grammar has no expressions, and this is
|
|
186
|
+
* not the place to grow one. Nothing is computed, combined or nested: if the
|
|
187
|
+
* sender does not put an identifier somewhere a name can reach, the flow
|
|
188
|
+
* cannot dedupe on it, and saying so is better than inventing a syntax.
|
|
189
|
+
*
|
|
190
|
+
* Returns null when the flow asks for no key, or when the delivery does not
|
|
191
|
+
* carry the one it asked for. A missing key admits the delivery: refusing
|
|
192
|
+
* one because a sender omitted a header would turn "dedupe when you can"
|
|
193
|
+
* into an outage.
|
|
194
|
+
*/
|
|
195
|
+
export function deliveryKey(idempotency, header, body) {
|
|
196
|
+
if (!idempotency)
|
|
197
|
+
return null;
|
|
198
|
+
if (idempotency.startsWith("body:")) {
|
|
199
|
+
const field = idempotency.slice(5).trim();
|
|
200
|
+
if (!field)
|
|
201
|
+
return null;
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(body);
|
|
204
|
+
const value = parsed?.[field];
|
|
205
|
+
// A scalar only. An object stringifies to "[object Object]" for every
|
|
206
|
+
// delivery, which would make the second one and every one after it a
|
|
207
|
+
// "duplicate" of the first.
|
|
208
|
+
if (value === undefined || value === null || typeof value === "object")
|
|
209
|
+
return null;
|
|
210
|
+
return String(value).slice(0, 200);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null; // not JSON, so no field to read
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return header(idempotency)?.slice(0, 200) || null;
|
|
217
|
+
}
|
|
129
218
|
function safeEqual(a, b) {
|
|
130
219
|
const x = Buffer.from(a);
|
|
131
220
|
const y = Buffer.from(b);
|
|
@@ -259,7 +348,7 @@ export function normaliseInboundEmail(contentType, raw) {
|
|
|
259
348
|
const subject = pick("subject", "Subject");
|
|
260
349
|
const html = pick("html", "HtmlBody", "body-html");
|
|
261
350
|
const text = pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
|
|
262
|
-
html
|
|
351
|
+
stripTags(html);
|
|
263
352
|
if (!from && !subject && !text)
|
|
264
353
|
return null;
|
|
265
354
|
return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
2
|
+
* nothing a tenant points the platform at needs more than a couple. */
|
|
3
|
+
export declare const MAX_REDIRECTS = 5;
|
|
4
|
+
export declare class RedirectRefused extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
8
|
+
/** The request carries a credential (in its body or its headers):
|
|
9
|
+
* follow a redirect only within the origin it was sent to. */
|
|
10
|
+
sameOrigin?: boolean;
|
|
11
|
+
}): Promise<Response>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// fetch() for a URL a tenant chose, redirects included.
|
|
2
|
+
//
|
|
3
|
+
// platform.fetchUntrusted is the seam: on a platform it refuses private and
|
|
4
|
+
// cluster addresses (by name, by IP literal, and at connect time through a
|
|
5
|
+
// guarded DNS lookup). But it was handed fetch's default `redirect:
|
|
6
|
+
// "follow"`, and a redirect is followed inside fetch, past the seam: a
|
|
7
|
+
// public host answering `302 Location: http://169.254.169.254/…` sent the
|
|
8
|
+
// platform to the metadata endpoint, because an IP literal never reaches the
|
|
9
|
+
// DNS lookup the connect-time guard lives in. And the OAuth token POSTs
|
|
10
|
+
// carried a client secret to wherever a 307 pointed.
|
|
11
|
+
//
|
|
12
|
+
// So redirects are followed here, one hop at a time, each hop through the
|
|
13
|
+
// seam again — every address a request ends up at is checked the same way
|
|
14
|
+
// the first one was. A hop to another origin keeps only the headers that
|
|
15
|
+
// cannot carry a credential, and a caller whose request IS a credential
|
|
16
|
+
// (a token exchange, a provider key in a header) follows none at all.
|
|
17
|
+
import { platform } from "./platform.js";
|
|
18
|
+
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
19
|
+
* nothing a tenant points the platform at needs more than a couple. */
|
|
20
|
+
export const MAX_REDIRECTS = 5;
|
|
21
|
+
const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
22
|
+
/** Headers that may cross to another origin: none of them can carry a
|
|
23
|
+
* credential. Everything else — Authorization, a cookie, an x-api-key, a
|
|
24
|
+
* webhook's signature — stays with the origin it was meant for. */
|
|
25
|
+
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
26
|
+
export class RedirectRefused extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "RedirectRefused";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
33
|
+
let current = url;
|
|
34
|
+
let req = { ...init };
|
|
35
|
+
for (let hop = 0;; hop++) {
|
|
36
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
37
|
+
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
38
|
+
if (!location)
|
|
39
|
+
return res;
|
|
40
|
+
// The body of a redirect is never read; free the socket.
|
|
41
|
+
await res.body?.cancel().catch(() => { });
|
|
42
|
+
if (hop >= MAX_REDIRECTS)
|
|
43
|
+
throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
|
|
44
|
+
let next;
|
|
45
|
+
try {
|
|
46
|
+
next = new URL(location, current);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new RedirectRefused(`${current} redirected to "${location}", which is not a URL`);
|
|
50
|
+
}
|
|
51
|
+
if (next.protocol !== "https:" && next.protocol !== "http:") {
|
|
52
|
+
throw new RedirectRefused(`${current} redirected to a ${next.protocol} URL — not followed`);
|
|
53
|
+
}
|
|
54
|
+
const from = new URL(current);
|
|
55
|
+
const crossOrigin = next.origin !== from.origin;
|
|
56
|
+
if (crossOrigin && opts.sameOrigin) {
|
|
57
|
+
throw new RedirectRefused(`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`);
|
|
58
|
+
}
|
|
59
|
+
// fetch's own rewrite: a 303, and a 301/302 answering a POST, is
|
|
60
|
+
// followed as a GET with no body; a 307/308 repeats the request as it was.
|
|
61
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
62
|
+
const headers = new Headers(req.headers);
|
|
63
|
+
if ((res.status === 303 && method !== "GET" && method !== "HEAD") || ((res.status === 301 || res.status === 302) && method === "POST")) {
|
|
64
|
+
req = { ...req, method: "GET", body: undefined };
|
|
65
|
+
headers.delete("content-type");
|
|
66
|
+
}
|
|
67
|
+
if (crossOrigin) {
|
|
68
|
+
const names = [];
|
|
69
|
+
headers.forEach((_, name) => names.push(name));
|
|
70
|
+
for (const name of names)
|
|
71
|
+
if (!CROSS_ORIGIN_SAFE.has(name))
|
|
72
|
+
headers.delete(name);
|
|
73
|
+
}
|
|
74
|
+
req = { ...req, headers };
|
|
75
|
+
current = next.toString();
|
|
76
|
+
}
|
|
77
|
+
}
|