@qoniai/qoni 0.8.0 → 0.9.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/CHANGELOG.md +102 -2
- package/README.md +119 -72
- package/README.zh-CN.md +73 -44
- package/dist/index.cjs +355 -58
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +163 -42
- package/dist/index.d.ts +163 -42
- package/dist/index.js +355 -58
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/skills/qoni-sdk/SKILL.md +42 -20
package/dist/index.cjs
CHANGED
|
@@ -23,7 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
ActionKinds: () => ActionKinds,
|
|
24
24
|
InteractionHandle: () => InteractionHandle,
|
|
25
25
|
InteractionStatuses: () => InteractionStatuses,
|
|
26
|
-
InteractionTypes: () =>
|
|
26
|
+
InteractionTypes: () => InteractionTypes2,
|
|
27
27
|
MonitorHandle: () => MonitorHandle,
|
|
28
28
|
Qoni: () => Qoni,
|
|
29
29
|
QoniAuthError: () => QoniAuthError,
|
|
@@ -566,8 +566,22 @@ var QoniScopes = {
|
|
|
566
566
|
// interaction). Not part of any product bundle: the AccessKey policy must
|
|
567
567
|
// allow it, and the delegation must request it explicitly.
|
|
568
568
|
SITE_LOGIN_REQUEST: "webagent.site_login:request",
|
|
569
|
-
// Persist that login into the browser profile (`
|
|
570
|
-
|
|
569
|
+
// Persist that login into the browser profile (`InteractionHandle.confirmSignedIn()`
|
|
570
|
+
// after `openLogin()`).
|
|
571
|
+
SITE_LOGIN_CONFIRM: "webagent.site_login:confirm",
|
|
572
|
+
// The delegating user's own data and the Agent's own mailbox (Personal
|
|
573
|
+
// Agent guide). Not part of any product bundle: request them explicitly;
|
|
574
|
+
// the AccessKey policy must allow them.
|
|
575
|
+
// Read the user's Profile (name, phone, shipping address, card brand + last 4).
|
|
576
|
+
USER_PROFILE_READ: "user.profile:read",
|
|
577
|
+
// Write answers back into the user's Profile.
|
|
578
|
+
USER_PROFILE_WRITE: "user.profile:write",
|
|
579
|
+
// Use the user's saved payment card once the user approves a payment.
|
|
580
|
+
USER_PAYMENT_USE: "user.payment:use",
|
|
581
|
+
// Read mail received by the Agent's own mailbox (for example verification codes).
|
|
582
|
+
AGENT_MAIL_READ: "agent.mail:read",
|
|
583
|
+
// Send mail from the Agent's own mailbox.
|
|
584
|
+
AGENT_MAIL_SEND: "agent.mail:send"
|
|
571
585
|
};
|
|
572
586
|
var QoniProductScopes = {
|
|
573
587
|
doAnything: [
|
|
@@ -620,6 +634,15 @@ var QoniScopeBundles = {
|
|
|
620
634
|
};
|
|
621
635
|
|
|
622
636
|
// src/interactions.ts
|
|
637
|
+
var InteractionTypes2 = {
|
|
638
|
+
SiteLogin: "site_login",
|
|
639
|
+
TakeControl: "take_control",
|
|
640
|
+
AskUser: "ask_user",
|
|
641
|
+
Confirmation: "confirmation",
|
|
642
|
+
Wait: "wait",
|
|
643
|
+
/** @deprecated `clarification` is presented as `ask_user`; use `InteractionTypes.AskUser`. */
|
|
644
|
+
Clarification: "ask_user"
|
|
645
|
+
};
|
|
623
646
|
function asString2(value) {
|
|
624
647
|
return typeof value === "string" && value ? value : void 0;
|
|
625
648
|
}
|
|
@@ -648,6 +671,49 @@ function normalizeEvidence(c) {
|
|
|
648
671
|
const artifactId = asString2(c.evidenceArtifactId) ?? (isJsonObject(c.evidence) ? asString2(c.evidence.artifactId) : void 0);
|
|
649
672
|
return artifactId ? { artifactId } : void 0;
|
|
650
673
|
}
|
|
674
|
+
var ANSWER_TYPES = /* @__PURE__ */ new Set([
|
|
675
|
+
"text",
|
|
676
|
+
"number",
|
|
677
|
+
"boolean",
|
|
678
|
+
"single_choice",
|
|
679
|
+
"multiple_choice"
|
|
680
|
+
]);
|
|
681
|
+
function normalizeOptions(value) {
|
|
682
|
+
if (!Array.isArray(value)) return [];
|
|
683
|
+
return value.flatMap((raw) => {
|
|
684
|
+
if (typeof raw === "string") return [{ value: raw, label: raw }];
|
|
685
|
+
if (!isJsonObject(raw) || typeof raw.value !== "string") return [];
|
|
686
|
+
return [{ value: raw.value, label: typeof raw.label === "string" ? raw.label : raw.value }];
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function isAnswerType(value) {
|
|
690
|
+
return value !== void 0 && ANSWER_TYPES.has(value);
|
|
691
|
+
}
|
|
692
|
+
function askUserPayload(c) {
|
|
693
|
+
const options = normalizeOptions(c.options);
|
|
694
|
+
const native = asString2(c.answerType);
|
|
695
|
+
const hint = asString2(c.inputType);
|
|
696
|
+
let answerType;
|
|
697
|
+
if (isAnswerType(native)) answerType = native;
|
|
698
|
+
else if (isAnswerType(hint) && hint !== "single_choice" && hint !== "multiple_choice") answerType = hint;
|
|
699
|
+
else answerType = options.length > 0 ? hint === "multiple_choice" ? "multiple_choice" : "single_choice" : "text";
|
|
700
|
+
const payload = { question: asString2(c.question) ?? "", answerType };
|
|
701
|
+
if (options.length > 0) payload.options = options;
|
|
702
|
+
return payload;
|
|
703
|
+
}
|
|
704
|
+
var BASE_FIELDS = /* @__PURE__ */ new Set([
|
|
705
|
+
"id",
|
|
706
|
+
"type",
|
|
707
|
+
"status",
|
|
708
|
+
"createdAt",
|
|
709
|
+
"resolvedAt",
|
|
710
|
+
"expiresAt",
|
|
711
|
+
"title",
|
|
712
|
+
"prompt",
|
|
713
|
+
"evidence",
|
|
714
|
+
"evidenceArtifactId",
|
|
715
|
+
"actions"
|
|
716
|
+
]);
|
|
651
717
|
function shapePayload(type, c) {
|
|
652
718
|
switch (type) {
|
|
653
719
|
case "site_login": {
|
|
@@ -664,14 +730,10 @@ function shapePayload(type, c) {
|
|
|
664
730
|
const payload = { sites };
|
|
665
731
|
const monitorId = asString2(c.monitorId);
|
|
666
732
|
if (monitorId) payload.monitorId = monitorId;
|
|
667
|
-
return payload;
|
|
668
|
-
}
|
|
669
|
-
case "clarification": {
|
|
670
|
-
const payload = { question: asString2(c.question) ?? "" };
|
|
671
|
-
const inputType = asString2(c.inputType);
|
|
672
|
-
if (inputType) payload.inputType = inputType;
|
|
673
|
-
return payload;
|
|
733
|
+
return { ...payload };
|
|
674
734
|
}
|
|
735
|
+
case "ask_user":
|
|
736
|
+
return { ...askUserPayload(c) };
|
|
675
737
|
case "confirmation":
|
|
676
738
|
return { summary: asString2(c.summary) ?? "" };
|
|
677
739
|
case "take_control": {
|
|
@@ -680,7 +742,7 @@ function shapePayload(type, c) {
|
|
|
680
742
|
const reason = asString2(c.reason);
|
|
681
743
|
if (surface) payload.surface = surface;
|
|
682
744
|
if (reason) payload.reason = reason;
|
|
683
|
-
return payload;
|
|
745
|
+
return { ...payload };
|
|
684
746
|
}
|
|
685
747
|
case "wait": {
|
|
686
748
|
const payload = {
|
|
@@ -690,14 +752,20 @@ function shapePayload(type, c) {
|
|
|
690
752
|
const retryable = asBool(c.retryable);
|
|
691
753
|
if (until) payload.until = until;
|
|
692
754
|
if (retryable !== void 0) payload.retryable = retryable;
|
|
755
|
+
return { ...payload };
|
|
756
|
+
}
|
|
757
|
+
default: {
|
|
758
|
+
const payload = {};
|
|
759
|
+
for (const [key, value] of Object.entries(c)) if (!BASE_FIELDS.has(key)) payload[key] = value;
|
|
693
760
|
return payload;
|
|
694
761
|
}
|
|
695
762
|
}
|
|
696
763
|
}
|
|
697
764
|
function normalizeInteraction(payload) {
|
|
698
765
|
const c = camelizeRecord(payload);
|
|
699
|
-
const
|
|
700
|
-
if (!
|
|
766
|
+
const wireType = asString2(c.type);
|
|
767
|
+
if (!wireType) return null;
|
|
768
|
+
const type = wireType === "clarification" ? "ask_user" : wireType;
|
|
701
769
|
const interaction = {
|
|
702
770
|
id: asString2(c.id) ?? "",
|
|
703
771
|
type,
|
|
@@ -717,15 +785,109 @@ function normalizeInteraction(payload) {
|
|
|
717
785
|
if (evidence) interaction.evidence = evidence;
|
|
718
786
|
return interaction;
|
|
719
787
|
}
|
|
788
|
+
var SETTLED_INTERACTION_STATUSES = /* @__PURE__ */ new Set(["resolved", "expired", "canceled"]);
|
|
789
|
+
function isSettlingShell(next) {
|
|
790
|
+
if (!SETTLED_INTERACTION_STATUSES.has(next.status) || next.actions.length > 0) return false;
|
|
791
|
+
switch (next.type) {
|
|
792
|
+
case "ask_user":
|
|
793
|
+
return next.payload.question === "";
|
|
794
|
+
case "site_login":
|
|
795
|
+
return next.payload.sites.every((site) => !site.siteId);
|
|
796
|
+
case "confirmation":
|
|
797
|
+
return next.payload.summary === "";
|
|
798
|
+
default:
|
|
799
|
+
return false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function foldInteraction(previous, next) {
|
|
803
|
+
if (!previous || previous.id !== next.id || !isSettlingShell(next)) return next;
|
|
804
|
+
const folded = { ...previous, status: next.status, actions: next.actions };
|
|
805
|
+
if (next.resolvedAt) folded.resolvedAt = next.resolvedAt;
|
|
806
|
+
return folded;
|
|
807
|
+
}
|
|
720
808
|
function interactionActionScopes(action, productManageScope) {
|
|
721
809
|
return action.kind === ActionKinds.openLogin ? [QoniScopes.SITE_LOGIN_REQUEST] : [productManageScope];
|
|
722
810
|
}
|
|
811
|
+
function loginSlotIdle(slot) {
|
|
812
|
+
return slot.inflight === 0 && (!slot.session || slot.ended === true || slot.session.completed === true);
|
|
813
|
+
}
|
|
814
|
+
function markLoginEnded(store, interaction) {
|
|
815
|
+
if (!SETTLED_INTERACTION_STATUSES.has(interaction.status)) return;
|
|
816
|
+
const slot = store.get(interaction.id);
|
|
817
|
+
if (slot) slot.ended = true;
|
|
818
|
+
}
|
|
819
|
+
var SAVE_RETRY_PROBES = /* @__PURE__ */ new Set(["release_failed"]);
|
|
820
|
+
function isNoPendingLogin(err) {
|
|
821
|
+
return err instanceof QoniError && err.status === 409 && err.code === "no_pending_login";
|
|
822
|
+
}
|
|
823
|
+
var INTERACTION_MEMORY_LIMIT = 256;
|
|
824
|
+
function rememberBounded(map, key, value, limit = INTERACTION_MEMORY_LIMIT, evictable = () => true) {
|
|
825
|
+
map.delete(key);
|
|
826
|
+
map.set(key, value);
|
|
827
|
+
if (map.size <= limit) return;
|
|
828
|
+
for (const [candidate, entry] of map) {
|
|
829
|
+
if (map.size <= limit) break;
|
|
830
|
+
if (candidate !== key && evictable(entry)) map.delete(candidate);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
function interactionSettled(interaction) {
|
|
834
|
+
return SETTLED_INTERACTION_STATUSES.has(interaction.status);
|
|
835
|
+
}
|
|
836
|
+
function controlSession(response) {
|
|
837
|
+
const raw = isJsonObject(response) ? response : {};
|
|
838
|
+
const fields = camelizeRecord(raw);
|
|
839
|
+
const session = { liveUrl: asString2(fields.standaloneControlUrl) ?? asString2(fields.liveUrl) ?? "", raw };
|
|
840
|
+
const expiresAt = asString2(fields.controlTokenExpiresAt) ?? asString2(fields.expiresAt);
|
|
841
|
+
const status = asString2(fields.status);
|
|
842
|
+
if (expiresAt) session.expiresAt = expiresAt;
|
|
843
|
+
if (status) session.status = status;
|
|
844
|
+
return session;
|
|
845
|
+
}
|
|
846
|
+
function describeOptions(payload) {
|
|
847
|
+
return (payload.options ?? []).map((option) => option.value).join(", ");
|
|
848
|
+
}
|
|
849
|
+
function checkAnswer(payload, value) {
|
|
850
|
+
const fail = (expected) => {
|
|
851
|
+
throw new QoniValidationError(`answer() for a ${payload.answerType} question expects ${expected}`);
|
|
852
|
+
};
|
|
853
|
+
const values = new Set((payload.options ?? []).map((option) => option.value));
|
|
854
|
+
switch (payload.answerType) {
|
|
855
|
+
case "text":
|
|
856
|
+
if (typeof value !== "string") fail("a string");
|
|
857
|
+
return;
|
|
858
|
+
case "number":
|
|
859
|
+
if (typeof value !== "number" || !Number.isFinite(value)) fail("a finite number");
|
|
860
|
+
return;
|
|
861
|
+
case "boolean":
|
|
862
|
+
if (typeof value !== "boolean") fail("a boolean");
|
|
863
|
+
return;
|
|
864
|
+
case "single_choice":
|
|
865
|
+
if (typeof value !== "string" || values.size > 0 && !values.has(value)) {
|
|
866
|
+
fail(`one option value: ${describeOptions(payload)}`);
|
|
867
|
+
}
|
|
868
|
+
return;
|
|
869
|
+
case "multiple_choice":
|
|
870
|
+
if (!Array.isArray(value)) fail(`an array of option values: ${describeOptions(payload)}`);
|
|
871
|
+
if (value.some((item) => typeof item !== "string" || values.size > 0 && !values.has(item))) {
|
|
872
|
+
fail(`option values only: ${describeOptions(payload)}`);
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
723
877
|
var InteractionHandle = class {
|
|
724
|
-
|
|
878
|
+
/**
|
|
879
|
+
* @param loginSessions Login sessions opened on interactions, by interaction
|
|
880
|
+
* id. A RunHandle / MonitorHandle shares one store across the handles it
|
|
881
|
+
* hands out, so `confirmSignedIn()` finds the login `openLogin()` opened
|
|
882
|
+
* even on the handle of a later event.
|
|
883
|
+
*/
|
|
884
|
+
constructor(interaction, post, loginSessions = /* @__PURE__ */ new Map()) {
|
|
725
885
|
this.post = post;
|
|
886
|
+
this.loginSessions = loginSessions;
|
|
726
887
|
this.interaction = interaction;
|
|
727
888
|
}
|
|
728
889
|
post;
|
|
890
|
+
loginSessions;
|
|
729
891
|
interaction;
|
|
730
892
|
get id() {
|
|
731
893
|
return this.interaction.id;
|
|
@@ -743,9 +905,18 @@ var InteractionHandle = class {
|
|
|
743
905
|
can(kind) {
|
|
744
906
|
return this.interaction.actions.some((a) => a.kind === kind);
|
|
745
907
|
}
|
|
746
|
-
/**
|
|
747
|
-
|
|
748
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Answer an `ask_user` question. The value follows `payload.answerType`:
|
|
910
|
+
* `text` → string, `number` → number, `boolean` → boolean, `single_choice` →
|
|
911
|
+
* one option `value`, `multiple_choice` → an array of option values. A value
|
|
912
|
+
* that does not fit throws `QoniValidationError` before anything is sent.
|
|
913
|
+
*/
|
|
914
|
+
async answer(value) {
|
|
915
|
+
const action = this.#declaredAction(ActionKinds.answer);
|
|
916
|
+
if (this.interaction.type === "ask_user") {
|
|
917
|
+
checkAnswer(this.interaction.payload, value);
|
|
918
|
+
}
|
|
919
|
+
await this.post(action, { response: value });
|
|
749
920
|
}
|
|
750
921
|
/** Skip / dismiss the interaction (proceed without acting on it). */
|
|
751
922
|
async skip() {
|
|
@@ -763,8 +934,8 @@ var InteractionHandle = class {
|
|
|
763
934
|
* Start an interactive login session for a `site_login` interaction. The
|
|
764
935
|
* backend needs the site to sign in to: pass `siteId`, or omit it when the
|
|
765
936
|
* interaction lists exactly one site. Show `liveUrl` to the user; when they
|
|
766
|
-
*
|
|
767
|
-
*
|
|
937
|
+
* say they are signed in, call {@link confirmSignedIn}, which saves the login
|
|
938
|
+
* and lets the agent continue.
|
|
768
939
|
* The delegation must include `QoniScopes.SITE_LOGIN_REQUEST` and
|
|
769
940
|
* `QoniScopes.SITE_LOGIN_CONFIRM`; the AccessKey policy must allow both.
|
|
770
941
|
*/
|
|
@@ -777,57 +948,82 @@ var InteractionHandle = class {
|
|
|
777
948
|
}
|
|
778
949
|
body.profile_id = options.profileId;
|
|
779
950
|
}
|
|
780
|
-
const
|
|
951
|
+
const slot = this.#loginSlot();
|
|
952
|
+
const call = ++slot.calls;
|
|
953
|
+
slot.inflight += 1;
|
|
954
|
+
let response;
|
|
955
|
+
try {
|
|
956
|
+
response = await this.invoke(ActionKinds.openLogin, body);
|
|
957
|
+
} finally {
|
|
958
|
+
slot.inflight -= 1;
|
|
959
|
+
}
|
|
781
960
|
const raw = isJsonObject(response) ? response : {};
|
|
782
961
|
const fields = camelizeRecord(raw);
|
|
783
962
|
const profileId = asString2(fields.profileId) ?? "";
|
|
784
963
|
const siteId = asString2(fields.siteId) ?? String(body.site_id);
|
|
964
|
+
const sessionId = asString2(fields.sessionId) ?? "";
|
|
965
|
+
const current = slot.session;
|
|
966
|
+
const reused = current !== void 0 && sessionId !== "" && current.sessionId === sessionId && current.profileId === profileId && current.siteId === siteId;
|
|
967
|
+
const session = reused ? current : { profileId, siteId, sessionId, call };
|
|
968
|
+
if (reused) {
|
|
969
|
+
current.call = Math.max(current.call, call);
|
|
970
|
+
} else if (profileId && (current?.call ?? 0) < call) {
|
|
971
|
+
if (current) current.replaced = true;
|
|
972
|
+
slot.session = session;
|
|
973
|
+
} else {
|
|
974
|
+
session.replaced = true;
|
|
975
|
+
}
|
|
785
976
|
return {
|
|
786
977
|
profileId,
|
|
787
978
|
siteId,
|
|
788
|
-
sessionId
|
|
979
|
+
sessionId,
|
|
789
980
|
liveUrl: asString2(fields.liveUrl) ?? "",
|
|
790
981
|
raw,
|
|
791
982
|
confirm: async (confirmOptions = {}) => {
|
|
792
983
|
if (!profileId) {
|
|
793
984
|
throw new QoniValidationError("openLogin response did not include a profileId to confirm");
|
|
794
985
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
method: "POST",
|
|
802
|
-
endpoint: `/profiles/${encodeURIComponent(profileId)}/confirm`
|
|
803
|
-
},
|
|
804
|
-
confirmBody,
|
|
805
|
-
{ requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
|
|
806
|
-
);
|
|
807
|
-
const raw2 = isJsonObject(response2) ? response2 : {};
|
|
808
|
-
const fields2 = camelizeRecord(raw2);
|
|
809
|
-
const state = asString2(fields2.state) ?? "";
|
|
810
|
-
return {
|
|
811
|
-
profileId: asString2(fields2.profileId) ?? profileId,
|
|
812
|
-
active: state === "active",
|
|
813
|
-
state,
|
|
814
|
-
probeResult: asString2(fields2.probeResult) ?? "",
|
|
815
|
-
raw: raw2
|
|
816
|
-
};
|
|
986
|
+
if (session.replaced) {
|
|
987
|
+
throw new QoniValidationError(
|
|
988
|
+
"this login session was replaced by a newer openLogin(); confirm the newer one (or call confirmSignedIn(), which saves the current session)"
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return this.#saveOnce(session, confirmOptions);
|
|
817
992
|
}
|
|
818
993
|
};
|
|
819
994
|
}
|
|
820
|
-
/**
|
|
995
|
+
/**
|
|
996
|
+
* The user finished signing in through the live browser `openLogin()`
|
|
997
|
+
* opened. Saves that login (so later tasks reuse it), then tells the agent
|
|
998
|
+
* to recheck the site and continue. If the user is not signed in after all,
|
|
999
|
+
* the agent raises a new `site_login` interaction.
|
|
1000
|
+
*
|
|
1001
|
+
* Saving needs `QoniScopes.SITE_LOGIN_CONFIRM` on the delegation; without
|
|
1002
|
+
* `openLogin()` on this interaction, only the recheck is sent.
|
|
1003
|
+
*/
|
|
821
1004
|
async confirmSignedIn() {
|
|
822
|
-
|
|
1005
|
+
const action = this.#declaredAction(ActionKinds.confirmSignedIn);
|
|
1006
|
+
const session = this.loginSessions.get(this.id)?.session;
|
|
1007
|
+
if (!session) {
|
|
1008
|
+
await this.post(action, void 0);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
session.done ??= this.#saveThenRecheck(session, action).catch((err) => {
|
|
1012
|
+
session.done = void 0;
|
|
1013
|
+
throw err;
|
|
1014
|
+
});
|
|
1015
|
+
await session.done;
|
|
823
1016
|
}
|
|
824
|
-
/**
|
|
1017
|
+
/**
|
|
1018
|
+
* Take over the live browser for a take_control interaction. Resolves to the
|
|
1019
|
+
* signed control URL to show the user (`liveUrl`) and when it expires.
|
|
1020
|
+
*/
|
|
825
1021
|
async connectControl() {
|
|
826
|
-
await this.invoke(ActionKinds.connectControl);
|
|
1022
|
+
return controlSession(await this.invoke(ActionKinds.connectControl));
|
|
827
1023
|
}
|
|
828
|
-
/**
|
|
1024
|
+
/** Get a fresh control URL when the current one expired. */
|
|
829
1025
|
async refreshControl() {
|
|
830
|
-
await this.invoke(ActionKinds.refreshControl);
|
|
1026
|
+
return controlSession(await this.invoke(ActionKinds.refreshControl));
|
|
831
1027
|
}
|
|
832
1028
|
/** Hand control back to the agent. */
|
|
833
1029
|
async releaseControl() {
|
|
@@ -841,6 +1037,81 @@ var InteractionHandle = class {
|
|
|
841
1037
|
async switchProfile() {
|
|
842
1038
|
await this.invoke(ActionKinds.switchProfile);
|
|
843
1039
|
}
|
|
1040
|
+
/** `POST /profiles/{profileId}/confirm` — persist the login of `session`. */
|
|
1041
|
+
async #confirmLogin(session, options = {}) {
|
|
1042
|
+
const confirmBody = { site_id: session.siteId };
|
|
1043
|
+
if (options.displayLabel !== void 0) confirmBody.display_label = options.displayLabel;
|
|
1044
|
+
const response = await this.post(
|
|
1045
|
+
{
|
|
1046
|
+
kind: ActionKinds.openLogin,
|
|
1047
|
+
label: "Confirm login",
|
|
1048
|
+
method: "POST",
|
|
1049
|
+
endpoint: `/profiles/${encodeURIComponent(session.profileId)}/confirm`
|
|
1050
|
+
},
|
|
1051
|
+
confirmBody,
|
|
1052
|
+
{ requiredScopes: [QoniScopes.SITE_LOGIN_CONFIRM] }
|
|
1053
|
+
);
|
|
1054
|
+
const raw = isJsonObject(response) ? response : {};
|
|
1055
|
+
const fields = camelizeRecord(raw);
|
|
1056
|
+
const state = asString2(fields.state) ?? "";
|
|
1057
|
+
return {
|
|
1058
|
+
profileId: asString2(fields.profileId) ?? session.profileId,
|
|
1059
|
+
active: state === "active",
|
|
1060
|
+
state,
|
|
1061
|
+
probeResult: asString2(fields.probeResult) ?? "",
|
|
1062
|
+
raw
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/** The save for `session`, started once; a failed attempt is forgotten so a retry saves again. */
|
|
1066
|
+
#saveOnce(session, options) {
|
|
1067
|
+
session.save ??= this.#confirmLogin(session, options).then(
|
|
1068
|
+
(result) => {
|
|
1069
|
+
if (SAVE_RETRY_PROBES.has(result.probeResult)) session.save = void 0;
|
|
1070
|
+
return result;
|
|
1071
|
+
},
|
|
1072
|
+
(err) => {
|
|
1073
|
+
if (!isNoPendingLogin(err)) session.save = void 0;
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
1076
|
+
);
|
|
1077
|
+
return session.save;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Save the login, then let the agent recheck. A user who is not signed in
|
|
1081
|
+
* yet still gets a recheck (the agent asks again), and so does 409
|
|
1082
|
+
* `no_pending_login`. A save the backend could not complete (`release_failed`)
|
|
1083
|
+
* throws a retryable error instead of telling the agent to go on.
|
|
1084
|
+
*/
|
|
1085
|
+
async #saveThenRecheck(session, action) {
|
|
1086
|
+
let result;
|
|
1087
|
+
try {
|
|
1088
|
+
result = await this.#saveOnce(session);
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
if (!isNoPendingLogin(err)) throw err;
|
|
1091
|
+
}
|
|
1092
|
+
if (result && SAVE_RETRY_PROBES.has(result.probeResult)) {
|
|
1093
|
+
throw new QoniError(
|
|
1094
|
+
`the login could not be saved yet (probeResult "${result.probeResult}"); the login browser is kept \u2014 call confirmSignedIn() again to retry`,
|
|
1095
|
+
{ code: "site_login.save_failed", retryable: true }
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (session.replaced) {
|
|
1099
|
+
throw new QoniError(
|
|
1100
|
+
"the login was reopened while it was being saved; call confirmSignedIn() again to save the newer login",
|
|
1101
|
+
{ code: "site_login.session_replaced", retryable: true }
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
await this.post(action, void 0);
|
|
1105
|
+
session.completed = true;
|
|
1106
|
+
}
|
|
1107
|
+
#loginSlot() {
|
|
1108
|
+
let slot = this.loginSessions.get(this.id);
|
|
1109
|
+
if (!slot) {
|
|
1110
|
+
slot = { calls: 0, inflight: 0 };
|
|
1111
|
+
rememberBounded(this.loginSessions, this.id, slot, INTERACTION_MEMORY_LIMIT, loginSlotIdle);
|
|
1112
|
+
}
|
|
1113
|
+
return slot;
|
|
1114
|
+
}
|
|
844
1115
|
/** Resolve the site for `openLogin`: explicit, or the only listed site. */
|
|
845
1116
|
#loginSiteId(requested) {
|
|
846
1117
|
const payload = this.interaction.payload;
|
|
@@ -896,6 +1167,10 @@ var RunHandle = class {
|
|
|
896
1167
|
/** Pass back to `run({ session })` to reuse the same backend session. */
|
|
897
1168
|
sessionRef;
|
|
898
1169
|
capture;
|
|
1170
|
+
/** Login sessions opened on this run's interactions — see {@link interactionHandle}. */
|
|
1171
|
+
#loginSessions = /* @__PURE__ */ new Map();
|
|
1172
|
+
/** Latest card per interaction id, so lifecycle updates fold onto it. */
|
|
1173
|
+
#interactions = /* @__PURE__ */ new Map();
|
|
899
1174
|
/** Refresh and return the run's current state. */
|
|
900
1175
|
async status() {
|
|
901
1176
|
const detail = await this.ops.getDetail();
|
|
@@ -909,8 +1184,14 @@ var RunHandle = class {
|
|
|
909
1184
|
async *events(opts = {}) {
|
|
910
1185
|
for await (const wire of this.ops.streamEvents(opts)) {
|
|
911
1186
|
await opts.onWireEvent?.(wire);
|
|
912
|
-
|
|
1187
|
+
let event = normalizeRunEvent(wire, { topRunId: this.id });
|
|
913
1188
|
if (!event) continue;
|
|
1189
|
+
if (event.type === "interaction") {
|
|
1190
|
+
const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
|
|
1191
|
+
rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
|
|
1192
|
+
markLoginEnded(this.#loginSessions, card);
|
|
1193
|
+
event = { ...event, data: card };
|
|
1194
|
+
}
|
|
914
1195
|
if (event.type === "screenshot" && !this.capture.screenshots) continue;
|
|
915
1196
|
if (isVideoFrame(wire) && !this.capture.videoFrames) continue;
|
|
916
1197
|
yield event;
|
|
@@ -983,12 +1264,16 @@ var RunHandle = class {
|
|
|
983
1264
|
* Wrap an {@link Interaction} (from `event.data` of an `interaction` event)
|
|
984
1265
|
* in a typed {@link InteractionHandle} bound to this run. The handle's action
|
|
985
1266
|
* methods (`answer` / `confirmSignedIn` / `releaseControl` / …) post to the
|
|
986
|
-
* endpoint the backend declared for that action.
|
|
1267
|
+
* endpoint the backend declared for that action. Handles this RunHandle
|
|
1268
|
+
* hands out share the login sessions `openLogin()` opens, so
|
|
1269
|
+
* `confirmSignedIn()` on the handle of a later event still saves that login.
|
|
1270
|
+
* A RunHandle from another `attach()` (or process) does not see them.
|
|
987
1271
|
*/
|
|
988
1272
|
interactionHandle(interaction) {
|
|
989
1273
|
return new InteractionHandle(
|
|
990
1274
|
interaction,
|
|
991
|
-
(action, body, options) => this.ops.postAction(action, body, options)
|
|
1275
|
+
(action, body, options) => this.ops.postAction(action, body, options),
|
|
1276
|
+
this.#loginSessions
|
|
992
1277
|
);
|
|
993
1278
|
}
|
|
994
1279
|
/**
|
|
@@ -1813,6 +2098,10 @@ var MonitorHandle = class {
|
|
|
1813
2098
|
}
|
|
1814
2099
|
ops;
|
|
1815
2100
|
id;
|
|
2101
|
+
/** Login sessions opened on this monitor's interactions — see {@link interactionHandle}. */
|
|
2102
|
+
#loginSessions = /* @__PURE__ */ new Map();
|
|
2103
|
+
/** Latest card per interaction id, so settling shells fold onto it. */
|
|
2104
|
+
#interactions = /* @__PURE__ */ new Map();
|
|
1816
2105
|
/** Refresh and return the monitor (normalized camelCase fields). */
|
|
1817
2106
|
async get() {
|
|
1818
2107
|
return normalizeMonitorRecord(await this.ops.get());
|
|
@@ -1857,7 +2146,13 @@ var MonitorHandle = class {
|
|
|
1857
2146
|
*/
|
|
1858
2147
|
async *events(opts = {}) {
|
|
1859
2148
|
for await (const wire of this.ops.streamEvents(opts)) {
|
|
1860
|
-
|
|
2149
|
+
let event = normalizeRunEvent(wire);
|
|
2150
|
+
if (event?.type === "interaction") {
|
|
2151
|
+
const card = foldInteraction(this.#interactions.get(event.data.id), event.data);
|
|
2152
|
+
rememberBounded(this.#interactions, card.id, card, INTERACTION_MEMORY_LIMIT, interactionSettled);
|
|
2153
|
+
markLoginEnded(this.#loginSessions, card);
|
|
2154
|
+
event = { ...event, data: card };
|
|
2155
|
+
}
|
|
1861
2156
|
if (event) yield event;
|
|
1862
2157
|
}
|
|
1863
2158
|
}
|
|
@@ -1865,12 +2160,14 @@ var MonitorHandle = class {
|
|
|
1865
2160
|
* Wrap an {@link Interaction} (from `event.data` of an `interaction` event on
|
|
1866
2161
|
* the monitor stream — e.g. a `site_login` to unlock a `needs_reauth` park)
|
|
1867
2162
|
* in a typed {@link InteractionHandle}. The handle's action methods post to
|
|
1868
|
-
* the endpoint the backend declared for that action.
|
|
2163
|
+
* the endpoint the backend declared for that action. Handles of one
|
|
2164
|
+
* MonitorHandle share the login sessions `openLogin()` opens.
|
|
1869
2165
|
*/
|
|
1870
2166
|
interactionHandle(interaction) {
|
|
1871
2167
|
return new InteractionHandle(
|
|
1872
2168
|
interaction,
|
|
1873
|
-
(action, body, options) => this.ops.postAction(action, body, options)
|
|
2169
|
+
(action, body, options) => this.ops.postAction(action, body, options),
|
|
2170
|
+
this.#loginSessions
|
|
1874
2171
|
);
|
|
1875
2172
|
}
|
|
1876
2173
|
/**
|
|
@@ -3035,10 +3332,10 @@ function validateScopeFormat(scope) {
|
|
|
3035
3332
|
);
|
|
3036
3333
|
}
|
|
3037
3334
|
if (KNOWN_SCOPES.includes(scope)) return;
|
|
3038
|
-
const prefixed = KNOWN_SCOPES.
|
|
3039
|
-
if (prefixed) {
|
|
3335
|
+
const prefixed = KNOWN_SCOPES.filter((known) => known.endsWith(`.${scope}`));
|
|
3336
|
+
if (prefixed.length > 0) {
|
|
3040
3337
|
throw new QoniValidationError(
|
|
3041
|
-
`delegateToken: scope "${scope}" is missing its service prefix \u2014 write "${
|
|
3338
|
+
`delegateToken: scope "${scope}" is missing its service prefix \u2014 write ` + prefixed.map((known) => `"${known}"`).join(" or ")
|
|
3042
3339
|
);
|
|
3043
3340
|
}
|
|
3044
3341
|
if (!/^[a-z0-9_]+(\.[a-z0-9_]+)+:[a-z0-9_]+$/.test(scope)) {
|