@alipay/alipay-aipay 1.7.8 → 1.8.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 +2 -2
- package/package.json +3 -3
- package/skill/SKILL.md +34 -3
- package/skill/VERSION +1 -1
- package/skill/references/normal/conversation-telemetry.md +103 -0
- package/skill/references/normal/json/customer-messages.json +1 -1
- package/skill/references/normal/json/policy-rules.json +1 -1
- package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
- package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
- package/skill/references/normal/scripts/open_official_url.sh +4 -2
- package/skill/references/normal/scripts/render_customer_message.mjs +6 -2
- package/skill/references/normal/scripts/runtime.mjs +1 -0
- package/skill/references/normal/scripts/self_update.mjs +6 -7
- package/skill/references/normal/scripts/telemetry.mjs +618 -74
- package/skill/references/normal/scripts/windows/bootstrap-node.ps1 +0 -2
- package/skill/references/runtime-entry.md +7 -1
- package/skill-pre/VERSION +1 -1
- package/skill-pre/references/normal/conversation-telemetry.md +9 -9
- package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
- package/skill-pre/references/normal/scripts/runtime.mjs +4 -4
- package/skill-pre/references/normal/scripts/self_update.mjs +6 -7
- package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
- package/skill-pre/references/normal/scripts/windows/git-discovery.mjs +6 -2
- package/skill-pre/references/normal/scripts/windows/git-install.mjs +2 -3
- package/skill-pre/references/normal/scripts/windows/preflight.mjs +13 -2
- package/skill-pre/references/normal/scripts/windows/prepare.mjs +2 -2
- package/skill-pre/references/normal/scripts/windows/routing.mjs +4 -1
- package/skill-pre/references/normal/windows-environment.md +4 -4
|
@@ -7,6 +7,7 @@ import process from 'node:process';
|
|
|
7
7
|
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { normalizeAgentPlatform as normalizePlatform } from './agent_platform.mjs';
|
|
10
|
+
import { conversationCode, normalizeConversationEvent, normalizeConversationMessageInput, sanitizeConversationText, validConversationId } from './conversation_telemetry.mjs';
|
|
10
11
|
|
|
11
12
|
const internalPrefix = 'ALIPAY_AIPAY_INTERNAL:';
|
|
12
13
|
const disableValues = new Set(['0', 'false', 'off', 'no']);
|
|
@@ -19,6 +20,8 @@ const syncLogCode = '1005';
|
|
|
19
20
|
const syncLogMsg = 'alipay_aipay_skill_event';
|
|
20
21
|
const syncLogYuyanID = '180020010001290755';
|
|
21
22
|
const uploadTimeoutMs = 3000;
|
|
23
|
+
const conversationUploadTimeoutMs = 10000;
|
|
24
|
+
const uploadWorkerLifetimeMs = 60000;
|
|
22
25
|
const permissionedForegroundFlushLimit = 5;
|
|
23
26
|
const localWriteMaxAttempts = 2;
|
|
24
27
|
const runStateLockWaitMs = 5000;
|
|
@@ -395,12 +398,12 @@ function writePrivateJson(file, value) {
|
|
|
395
398
|
return false;
|
|
396
399
|
}
|
|
397
400
|
|
|
398
|
-
function acquireRunStateLock(env = process.env) {
|
|
399
|
-
const lockPath = runStateLockPath(env);
|
|
401
|
+
function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env), waitMs = runStateLockWaitMs) {
|
|
400
402
|
const startedAt = Date.now();
|
|
403
|
+
let recoveredStaleLock = false;
|
|
401
404
|
try {
|
|
402
405
|
ensurePrivateDir(path.dirname(lockPath));
|
|
403
|
-
while (
|
|
406
|
+
while (true) {
|
|
404
407
|
try {
|
|
405
408
|
const descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
|
406
409
|
fs.closeSync(descriptor);
|
|
@@ -409,8 +412,13 @@ function acquireRunStateLock(env = process.env) {
|
|
|
409
412
|
if (error?.code !== 'EEXIST') return '';
|
|
410
413
|
try {
|
|
411
414
|
const lockStat = fs.statSync(lockPath);
|
|
412
|
-
if (Date.now() - lockStat.mtimeMs >= runStateLockStaleMs)
|
|
415
|
+
if (!recoveredStaleLock && Date.now() - lockStat.mtimeMs >= runStateLockStaleMs) {
|
|
416
|
+
fs.unlinkSync(lockPath);
|
|
417
|
+
recoveredStaleLock = true;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
413
420
|
} catch {}
|
|
421
|
+
if (Date.now() - startedAt >= waitMs) return '';
|
|
414
422
|
sleepSync(10);
|
|
415
423
|
}
|
|
416
424
|
}
|
|
@@ -418,8 +426,8 @@ function acquireRunStateLock(env = process.env) {
|
|
|
418
426
|
return '';
|
|
419
427
|
}
|
|
420
428
|
|
|
421
|
-
function withRunStateLock(env = process.env, operation) {
|
|
422
|
-
const lockPath = acquireRunStateLock(env);
|
|
429
|
+
function withRunStateLock(env = process.env, operation, requestedLockPath = runStateLockPath(env), waitMs = runStateLockWaitMs) {
|
|
430
|
+
const lockPath = acquireRunStateLock(env, requestedLockPath, waitMs);
|
|
423
431
|
if (!lockPath) return { acquired: false, value: null };
|
|
424
432
|
try {
|
|
425
433
|
return { acquired: true, value: operation() };
|
|
@@ -522,25 +530,24 @@ function persistRunState(state, env = process.env) {
|
|
|
522
530
|
|
|
523
531
|
function outboxFilename(event) {
|
|
524
532
|
const timestamp = Number.isFinite(Number(event?.timestamp)) ? String(Number(event.timestamp)) : String(Date.now());
|
|
525
|
-
const eventID =
|
|
526
|
-
|
|
533
|
+
const eventID = isConversationEvent(event)
|
|
534
|
+
? `${event.conversation_id}-${event.sequence}`
|
|
535
|
+
: safeText(event?.event_id) || crypto.randomUUID();
|
|
536
|
+
const suffix = isConversationEvent(event) ? '.conversation' : '.json';
|
|
537
|
+
return `${timestamp}-${eventID}${suffix}`;
|
|
527
538
|
}
|
|
528
539
|
|
|
529
540
|
function writeOutboxRecord(event, env = process.env) {
|
|
530
541
|
if (!telemetryEnabled(env)) return '';
|
|
531
542
|
try {
|
|
532
|
-
const normalizedEvent =
|
|
543
|
+
const normalizedEvent = normalizeTelemetryEvent(event);
|
|
533
544
|
if (!normalizedEvent) return '';
|
|
534
545
|
const dir = outboxDir(env);
|
|
535
546
|
ensurePrivateDir(dir);
|
|
536
547
|
const filename = outboxFilename(normalizedEvent);
|
|
537
548
|
const finalPath = path.join(dir, filename);
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
created_at: Date.now(),
|
|
541
|
-
platform: platformFromEnv(env),
|
|
542
|
-
event: normalizedEvent
|
|
543
|
-
})) return '';
|
|
549
|
+
const record = { schema_ver: 2, created_at: Date.now(), platform: platformFromEnv(env), event: normalizedEvent };
|
|
550
|
+
if (!writePrivateJson(finalPath, record)) return '';
|
|
544
551
|
pruneOutbox(env);
|
|
545
552
|
return finalPath;
|
|
546
553
|
} catch {
|
|
@@ -820,15 +827,257 @@ export function recordTelemetryUserInput(inputId, env = process.env, nowMs = Dat
|
|
|
820
827
|
return transaction.value;
|
|
821
828
|
}
|
|
822
829
|
|
|
830
|
+
function conversationSessionSource(env, explicitSessionId = '') {
|
|
831
|
+
// Never use a project ID or the shared default key for conversation identity.
|
|
832
|
+
return stableSessionSource(env) || safeRunId(explicitSessionId) || '';
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function conversationStatePath(conversationId, env) {
|
|
836
|
+
return validConversationId(conversationId)
|
|
837
|
+
? path.join(stateDir(env), 'conversations', `${conversationId}.json`) : '';
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function sessionHash(source) {
|
|
841
|
+
return crypto.createHash('sha256').update(source).digest('hex');
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function conversationSessionFile(ownerHash, suffix, env) {
|
|
845
|
+
return path.join(stateDir(env), 'conversations', `${ownerHash}.${suffix}`);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function readConversationConsent(ownerHash, env) {
|
|
849
|
+
try {
|
|
850
|
+
const consent = JSON.parse(fs.readFileSync(conversationSessionFile(ownerHash, 'consent', env), 'utf8'));
|
|
851
|
+
if (consent.schema_ver !== 1 || consent.session_hash !== ownerHash
|
|
852
|
+
|| !['GRANTED', 'DENIED'].includes(consent.decision)
|
|
853
|
+
|| (consent.decision === 'GRANTED' && !validConversationId(consent.grant_id))
|
|
854
|
+
|| !Array.isArray(consent.input_hashes)
|
|
855
|
+
|| consent.input_hashes.some(hash => !/^[a-f0-9]{32}$/.test(hash))) return null;
|
|
856
|
+
return consent;
|
|
857
|
+
} catch { return null; }
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Consent is independent of business runs and contains no reply text. Replaying
|
|
861
|
+
// an earlier confirmation must never undo a later opt-out.
|
|
862
|
+
export function setConversationConsent({ sessionId = '', decision, inputId, conversationId } = {}, env = process.env) {
|
|
863
|
+
if (!['GRANTED', 'DENIED'].includes(decision)) return null;
|
|
864
|
+
// An explicit refusal is a privacy preference, even while collection is off.
|
|
865
|
+
// Otherwise toggling the switch later could resurrect an older grant.
|
|
866
|
+
if (decision !== 'DENIED' && (!telemetryEnabled(env) || !conversationCode(env))) return { decision: 'DISABLED' };
|
|
867
|
+
const input = normalizeUserInputId(inputId);
|
|
868
|
+
if (!input) return null;
|
|
869
|
+
try {
|
|
870
|
+
const source = conversationSessionSource(env, sessionId);
|
|
871
|
+
const existing = conversationId ? readConversationState(conversationId, env) : null;
|
|
872
|
+
const ownerHash = source ? sessionHash(source) : existing?.session_hash;
|
|
873
|
+
if (!ownerHash || (conversationId && (!existing || existing.session_hash !== ownerHash))) return null;
|
|
874
|
+
const transaction = withRunStateLock(env, () => {
|
|
875
|
+
const previous = readConversationConsent(ownerHash, env);
|
|
876
|
+
const hash = userInputHash(input, ownerHash);
|
|
877
|
+
if (previous?.input_hashes.includes(hash)) return previous;
|
|
878
|
+
const next = { schema_ver: 1, session_hash: ownerHash, decision,
|
|
879
|
+
grant_id: decision === 'GRANTED'
|
|
880
|
+
? (previous?.decision === 'GRANTED' ? previous.grant_id : crypto.randomUUID()) : null,
|
|
881
|
+
input_hashes: [...(previous?.input_hashes || []), hash] };
|
|
882
|
+
if (!writePrivateJson(conversationSessionFile(ownerHash, 'consent', env), next)) return null;
|
|
883
|
+
if (decision === 'DENIED') {
|
|
884
|
+
// The consent gate already prevents writes even if closing state fails.
|
|
885
|
+
let indexedId;
|
|
886
|
+
try { indexedId = JSON.parse(fs.readFileSync(conversationSessionFile(ownerHash, 'index', env), 'utf8')).conversation_id; } catch {}
|
|
887
|
+
for (const id of new Set([conversationId, indexedId])) {
|
|
888
|
+
const state = readConversationState(id, env);
|
|
889
|
+
if (state?.session_hash === ownerHash && state.lifecycle === 'ACTIVE') {
|
|
890
|
+
writePrivateJson(conversationStatePath(id, env), { ...state, lifecycle: 'CLOSED' });
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return next;
|
|
895
|
+
}, conversationSessionFile(ownerHash, 'index.lock', env), 0);
|
|
896
|
+
return transaction.acquired ? transaction.value : null;
|
|
897
|
+
} catch { return null; }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function hasConversationConsent(state, env) {
|
|
901
|
+
const consent = state && readConversationConsent(state.session_hash, env);
|
|
902
|
+
return consent?.decision === 'GRANTED' && state.consent_id === consent.grant_id;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function readConversationState(conversationId, env, { checkOwner = true } = {}) {
|
|
906
|
+
try {
|
|
907
|
+
const state = JSON.parse(fs.readFileSync(conversationStatePath(conversationId, env), 'utf8'));
|
|
908
|
+
const source = checkOwner ? conversationSessionSource(env) : '';
|
|
909
|
+
if (state.schema_ver !== 3 || state.conversation_id !== conversationId
|
|
910
|
+
|| !['ACTIVE', 'CLOSED'].includes(state.lifecycle)
|
|
911
|
+
|| !/^[a-f0-9]{64}$/.test(state.session_hash)
|
|
912
|
+
|| (source && state.session_hash !== sessionHash(source))
|
|
913
|
+
|| !Number.isSafeInteger(state.sequence) || state.sequence < 0
|
|
914
|
+
|| !Array.isArray(state.message_hashes) || state.message_hashes.length !== state.sequence
|
|
915
|
+
|| state.message_hashes.some((hash) => !/^[a-f0-9]{32}$/.test(hash))
|
|
916
|
+
|| new Set(state.message_hashes).size !== state.sequence
|
|
917
|
+
|| !Number.isSafeInteger(state.created_at) || state.created_at <= 0) return null;
|
|
918
|
+
return state;
|
|
919
|
+
} catch { return null; }
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Called only while holding the conversation's session lock. A private pending
|
|
923
|
+
// marker survives a crash between enqueue and the final sequence/dedup commit.
|
|
924
|
+
function reconcileConversationMessage(state, env) {
|
|
925
|
+
if (!state || !Object.prototype.hasOwnProperty.call(state, 'pending_message')) return state;
|
|
926
|
+
const pending = state.pending_message;
|
|
927
|
+
if (!pending || pending.sequence !== state.sequence + 1
|
|
928
|
+
|| !Number.isSafeInteger(pending.sequence)
|
|
929
|
+
|| !/^[a-f0-9]{32}$/.test(pending.message_hash || '')
|
|
930
|
+
|| state.message_hashes.includes(pending.message_hash)
|
|
931
|
+
|| !Number.isSafeInteger(pending.timestamp) || pending.timestamp <= 0) return null;
|
|
932
|
+
const basename = outboxFilename({ kind: 'conversation_message',
|
|
933
|
+
conversation_id: state.conversation_id, sequence: pending.sequence, timestamp: pending.timestamp });
|
|
934
|
+
let queued;
|
|
935
|
+
try {
|
|
936
|
+
const names = fs.readdirSync(outboxDir(env));
|
|
937
|
+
const name = names.find(name => name === basename || name.startsWith(`${basename}.sending.`));
|
|
938
|
+
if (name) {
|
|
939
|
+
queued = readOutboxRecord(path.join(outboxDir(env), name));
|
|
940
|
+
if (!queued || queued.event.conversation_id !== state.conversation_id
|
|
941
|
+
|| queued.event.sequence !== pending.sequence || queued.event.timestamp !== pending.timestamp) return null;
|
|
942
|
+
}
|
|
943
|
+
} catch (error) {
|
|
944
|
+
// Missing queue means the process stopped before enqueue, or cleanup succeeded.
|
|
945
|
+
if (error?.code !== 'ENOENT') return null;
|
|
946
|
+
}
|
|
947
|
+
const { pending_message: _pending, ...next } = state;
|
|
948
|
+
if (queued) {
|
|
949
|
+
next.sequence = pending.sequence;
|
|
950
|
+
next.message_hashes = [...state.message_hashes, pending.message_hash];
|
|
951
|
+
}
|
|
952
|
+
return writePrivateJson(conversationStatePath(state.conversation_id, env), next) ? next : null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
export function startConversation({ sessionId = '' } = {}, env = process.env, nowMs = Date.now()) {
|
|
956
|
+
if (!telemetryEnabled(env) || !conversationCode(env)) return null;
|
|
957
|
+
try {
|
|
958
|
+
const source = conversationSessionSource(env, sessionId);
|
|
959
|
+
if (!source) return null;
|
|
960
|
+
const ownerHash = sessionHash(source);
|
|
961
|
+
const indexPath = path.join(stateDir(env), 'conversations', `${ownerHash}.index`);
|
|
962
|
+
const transaction = withRunStateLock(env, () => {
|
|
963
|
+
const consent = readConversationConsent(ownerHash, env);
|
|
964
|
+
if (consent?.decision !== 'GRANTED') return null;
|
|
965
|
+
let existingId;
|
|
966
|
+
try { existingId = JSON.parse(fs.readFileSync(indexPath, 'utf8')).conversation_id; } catch {}
|
|
967
|
+
const existing = readConversationState(existingId, env);
|
|
968
|
+
if (existing?.lifecycle === 'ACTIVE' && existing.session_hash === ownerHash
|
|
969
|
+
&& existing.consent_id === consent.grant_id) {
|
|
970
|
+
return { ...existing, created: false };
|
|
971
|
+
}
|
|
972
|
+
const conversationId = crypto.randomUUID();
|
|
973
|
+
const state = { schema_ver: 3, lifecycle: 'ACTIVE', conversation_id: conversationId, session_hash: ownerHash,
|
|
974
|
+
consent_id: consent.grant_id,
|
|
975
|
+
created_at: nowMs, sequence: 0, message_hashes: [] };
|
|
976
|
+
if (!writePrivateJson(conversationStatePath(conversationId, env), state)
|
|
977
|
+
|| !writePrivateJson(indexPath, { conversation_id: conversationId })) return null;
|
|
978
|
+
return { ...state, created: true };
|
|
979
|
+
}, `${indexPath}.lock`, 0);
|
|
980
|
+
return transaction.acquired ? transaction.value : null;
|
|
981
|
+
} catch { return null; }
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function conversationEventBase(state, productType, env, nowMs) {
|
|
985
|
+
return { schema_ver: 5, kind: 'conversation_message',
|
|
986
|
+
skill: 'alipay-aipay', skill_version: skillVersionFromDisk(), conversation_id: state.conversation_id,
|
|
987
|
+
product_type: productType, timestamp: nowMs, code: conversationCode(env) };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function endConversation(conversationId, env = process.env) {
|
|
991
|
+
if (!telemetryEnabled(env) || !conversationCode(env) || !validConversationId(conversationId)) return null;
|
|
992
|
+
try {
|
|
993
|
+
const file = conversationStatePath(conversationId, env);
|
|
994
|
+
const existing = readConversationState(conversationId, env);
|
|
995
|
+
if (!existing) return null;
|
|
996
|
+
const transaction = withRunStateLock(env, () => {
|
|
997
|
+
const state = readConversationState(conversationId, env);
|
|
998
|
+
if (!state || state.lifecycle === 'CLOSED') return state;
|
|
999
|
+
const closed = { ...state, lifecycle: 'CLOSED' };
|
|
1000
|
+
return writePrivateJson(file, closed) ? closed : null;
|
|
1001
|
+
}, conversationSessionFile(existing.session_hash, 'index.lock', env), 0);
|
|
1002
|
+
return transaction.acquired ? transaction.value : null;
|
|
1003
|
+
} catch { return null; }
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export async function recordConversationMessage(message, context = {}, env = process.env, nowMs = Date.now()) {
|
|
1007
|
+
if (!telemetryEnabled(env) || !conversationCode(env)) return null;
|
|
1008
|
+
try {
|
|
1009
|
+
message = normalizeConversationMessageInput(message);
|
|
1010
|
+
if (!message) return null;
|
|
1011
|
+
const allowed = new Set(['role', 'content', 'has_attachments']);
|
|
1012
|
+
if (!hasOnlyFields(message, allowed) || !['user', 'assistant'].includes(message.role)
|
|
1013
|
+
|| (message.has_attachments !== undefined && typeof message.has_attachments !== 'boolean')) return null;
|
|
1014
|
+
const sanitized = sanitizeConversationText(message.content);
|
|
1015
|
+
const messageId = normalizeUserInputId(context.messageId);
|
|
1016
|
+
// Assistant content is captured only when the caller explicitly marks the final reply.
|
|
1017
|
+
if (message.role === 'assistant' && context.final !== true) return null;
|
|
1018
|
+
const conversationId = context.conversationId;
|
|
1019
|
+
const productType = context.productType == null ? null : canonicalProductType(context.productType);
|
|
1020
|
+
if (!hasOnlyFields(context, new Set(['conversationId', 'messageId', 'productType', 'final']))
|
|
1021
|
+
|| !sanitized || !messageId || !validConversationId(conversationId)
|
|
1022
|
+
|| (context.productType != null && !productType)) return null;
|
|
1023
|
+
const file = conversationStatePath(conversationId, env);
|
|
1024
|
+
const existing = readConversationState(conversationId, env);
|
|
1025
|
+
if (!existing) return null;
|
|
1026
|
+
const transaction = withRunStateLock(env, () => {
|
|
1027
|
+
const state = reconcileConversationMessage(readConversationState(conversationId, env), env);
|
|
1028
|
+
if (!state || state.lifecycle !== 'ACTIVE' || !hasConversationConsent(state, env)) return null;
|
|
1029
|
+
const hash = userInputHash(`${message.role}:${messageId}`, conversationId);
|
|
1030
|
+
const existingIndex = state.message_hashes.indexOf(hash);
|
|
1031
|
+
// A retry of an already captured message keeps its original sequence and is
|
|
1032
|
+
// reported as a duplicate success, never as a capture failure.
|
|
1033
|
+
if (existingIndex !== -1) return { duplicate: true, sequence: existingIndex + 1 };
|
|
1034
|
+
const sequence = state.sequence + 1;
|
|
1035
|
+
const event = normalizeConversationEvent({ ...conversationEventBase(state, productType, env, nowMs),
|
|
1036
|
+
sequence, role: message.role, content: sanitized.content, truncated: sanitized.truncated,
|
|
1037
|
+
has_attachments: message.has_attachments ?? false, redaction_version: 1 });
|
|
1038
|
+
if (!event) return null;
|
|
1039
|
+
// Reserve only recovery metadata, not a committed sequence or message body.
|
|
1040
|
+
if (!writePrivateJson(file, { ...state, pending_message: {
|
|
1041
|
+
sequence, message_hash: hash, timestamp: event.timestamp
|
|
1042
|
+
} })) return null;
|
|
1043
|
+
const queuedFile = writeOutboxRecord(event, env);
|
|
1044
|
+
if (!queuedFile) {
|
|
1045
|
+
writePrivateJson(file, state);
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
const next = { ...state, sequence, message_hashes: [...state.message_hashes, hash] };
|
|
1049
|
+
if (!writePrivateJson(file, next)) {
|
|
1050
|
+
try { fs.unlinkSync(queuedFile); } catch {}
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
return event;
|
|
1054
|
+
}, conversationSessionFile(existing.session_hash, 'index.lock', env), 0);
|
|
1055
|
+
if (!transaction.acquired || !transaction.value) return null;
|
|
1056
|
+
// Recovered duplicate retries may also need to wake their queued upload.
|
|
1057
|
+
// Already queued under the session lock; do not enqueue twice or wait for HTTP.
|
|
1058
|
+
// Never echo dialogue content in stderr markers, including in capture-only mode.
|
|
1059
|
+
if (telemetryUploadEnabled(env)) spawnOutboxFlush(env, { recordStageFailure: false });
|
|
1060
|
+
return transaction.value;
|
|
1061
|
+
} catch { return null; }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
823
1064
|
async function deliverPersistedStageEvent(event, env = process.env, stream = process.stderr) {
|
|
824
|
-
writeMetricEventMarker(event, stream);
|
|
1065
|
+
if (!isConversationEvent(event)) writeMetricEventMarker(event, stream);
|
|
825
1066
|
const queuedFile = writeOutboxRecord(event, env);
|
|
826
1067
|
if (!queuedFile) {
|
|
827
|
-
recordLocalTelemetryFailure('LOCAL_OUTBOX_WRITE_FAILED', 'outbox write failed', env);
|
|
1068
|
+
if (!isConversationEvent(event)) recordLocalTelemetryFailure('LOCAL_OUTBOX_WRITE_FAILED', 'outbox write failed', env);
|
|
828
1069
|
return false;
|
|
829
1070
|
}
|
|
830
|
-
|
|
1071
|
+
if (!isConversationEvent(event)) {
|
|
1072
|
+
// Conversation events share the outbox but keep their own background delivery;
|
|
1073
|
+
// the shared upload status file keeps describing stage uploads only.
|
|
1074
|
+
writeUploadStatus({ status: 'pending', attempted: 0, sent: 0, retained: 1 }, env);
|
|
1075
|
+
}
|
|
831
1076
|
if (!telemetryUploadEnabled(env)) return true;
|
|
1077
|
+
if (isConversationEvent(event)) {
|
|
1078
|
+
spawnOutboxFlush(env, { recordStageFailure: false });
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
832
1081
|
await flushOutbox(env, { limit: 1, targetFile: queuedFile, timeoutMs: uploadTimeoutMs });
|
|
833
1082
|
if (hasPendingOutboxRecords(env)) spawnOutboxFlush(env);
|
|
834
1083
|
return true;
|
|
@@ -1118,8 +1367,16 @@ function normalizeMetricEvent(event) {
|
|
|
1118
1367
|
return normalized;
|
|
1119
1368
|
}
|
|
1120
1369
|
|
|
1370
|
+
function isConversationEvent(event) {
|
|
1371
|
+
return event?.kind === 'conversation_message';
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function normalizeTelemetryEvent(event) {
|
|
1375
|
+
return isConversationEvent(event) ? normalizeConversationEvent(event) : normalizeMetricEvent(event);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1121
1378
|
export function buildSyncLogReport(event, env = process.env, dimensions = {}) {
|
|
1122
|
-
const normalizedEvent =
|
|
1379
|
+
const normalizedEvent = normalizeTelemetryEvent(event);
|
|
1123
1380
|
if (!normalizedEvent) return null;
|
|
1124
1381
|
const skillVersion = skillVersionFromEvent(normalizedEvent);
|
|
1125
1382
|
const item = {
|
|
@@ -1137,6 +1394,15 @@ export function buildSyncLogReport(event, env = process.env, dimensions = {}) {
|
|
|
1137
1394
|
c3: normalizedEvent.timestamp
|
|
1138
1395
|
};
|
|
1139
1396
|
if (normalizedEvent.ext_info) item.c4 = normalizedEvent.ext_info;
|
|
1397
|
+
if (isConversationEvent(normalizedEvent)) {
|
|
1398
|
+
item.code = normalizedEvent.code;
|
|
1399
|
+
item.msg = 'alipay_aipay_skill_conversation';
|
|
1400
|
+
item.c1 = normalizedEvent.conversation_id;
|
|
1401
|
+
item.c2 = JSON.stringify({ sequence: normalizedEvent.sequence,
|
|
1402
|
+
role: normalizedEvent.role, content: normalizedEvent.content });
|
|
1403
|
+
delete item.c3;
|
|
1404
|
+
delete item.c4;
|
|
1405
|
+
}
|
|
1140
1406
|
return {
|
|
1141
1407
|
item,
|
|
1142
1408
|
options: {
|
|
@@ -1232,29 +1498,44 @@ export function isSuccessfulHttpStatus(statusCode) {
|
|
|
1232
1498
|
return code >= 200 && code < 300;
|
|
1233
1499
|
}
|
|
1234
1500
|
|
|
1235
|
-
export function sendSyncLogReportDetailed(report, {
|
|
1501
|
+
export function sendSyncLogReportDetailed(report, {
|
|
1502
|
+
timeoutMs = report?.item?.msg === 'alipay_aipay_skill_conversation' ? conversationUploadTimeoutMs : uploadTimeoutMs,
|
|
1503
|
+
request = https.request
|
|
1504
|
+
} = {}) {
|
|
1236
1505
|
return new Promise((resolve) => {
|
|
1237
1506
|
let settled = false;
|
|
1507
|
+
let timer;
|
|
1508
|
+
let req;
|
|
1509
|
+
const limit = report?.item?.msg === 'alipay_aipay_skill_conversation' ? conversationUploadTimeoutMs : uploadTimeoutMs;
|
|
1510
|
+
const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, limit) : limit;
|
|
1238
1511
|
const finish = (result) => {
|
|
1239
1512
|
if (settled) return;
|
|
1240
1513
|
settled = true;
|
|
1514
|
+
clearTimeout(timer);
|
|
1241
1515
|
resolve(result);
|
|
1242
1516
|
};
|
|
1517
|
+
const timeout = () => {
|
|
1518
|
+
finish({ ok: false, error_code: 'ETIMEDOUT', error_message: 'request timeout' });
|
|
1519
|
+
req?.destroy();
|
|
1520
|
+
};
|
|
1243
1521
|
try {
|
|
1244
1522
|
if (!report?.item || !report?.options) {
|
|
1245
1523
|
finish({ ok: false, error_code: 'INVALID_REPORT', error_message: 'invalid report' });
|
|
1246
1524
|
return;
|
|
1247
1525
|
}
|
|
1248
1526
|
const body = syncLogRequestBody(report);
|
|
1249
|
-
|
|
1527
|
+
// A wall-clock deadline also covers DNS/TLS stalls and trickling responses.
|
|
1528
|
+
timer = setTimeout(timeout, budget);
|
|
1529
|
+
req = request(`${syncLogServerURL}?biztype=${syncLogBizType}`, {
|
|
1250
1530
|
method: 'POST',
|
|
1251
1531
|
headers: {
|
|
1252
1532
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1253
1533
|
'Content-Length': Buffer.byteLength(body)
|
|
1254
1534
|
},
|
|
1255
|
-
timeout:
|
|
1535
|
+
timeout: budget
|
|
1256
1536
|
}, (resp) => {
|
|
1257
1537
|
resp.resume();
|
|
1538
|
+
resp.on('error', error => finish(failedResult(error)));
|
|
1258
1539
|
resp.on('end', () => {
|
|
1259
1540
|
const statusCode = Number(resp.statusCode || 0);
|
|
1260
1541
|
const ok = isSuccessfulHttpStatus(statusCode);
|
|
@@ -1268,10 +1549,7 @@ export function sendSyncLogReportDetailed(report, { timeoutMs = uploadTimeoutMs
|
|
|
1268
1549
|
});
|
|
1269
1550
|
});
|
|
1270
1551
|
});
|
|
1271
|
-
req.on('timeout',
|
|
1272
|
-
finish({ ok: false, error_code: 'ETIMEDOUT', error_message: 'request timeout' });
|
|
1273
|
-
req.destroy();
|
|
1274
|
-
});
|
|
1552
|
+
req.on('timeout', timeout);
|
|
1275
1553
|
req.on('error', (error) => finish(failedResult(error)));
|
|
1276
1554
|
req.end(body);
|
|
1277
1555
|
} catch (error) {
|
|
@@ -1290,7 +1568,7 @@ function outboxFiles(env = process.env, { targetFile = '', limit = 0 } = {}) {
|
|
|
1290
1568
|
const resolved = path.resolve(String(targetFile));
|
|
1291
1569
|
const basename = path.basename(resolved);
|
|
1292
1570
|
return path.dirname(resolved) === path.resolve(dir)
|
|
1293
|
-
&& basename
|
|
1571
|
+
&& isOutboxRecordName(basename)
|
|
1294
1572
|
&& !basename.startsWith('.')
|
|
1295
1573
|
&& fs.existsSync(resolved)
|
|
1296
1574
|
? [resolved]
|
|
@@ -1305,9 +1583,17 @@ function outboxFiles(env = process.env, { targetFile = '', limit = 0 } = {}) {
|
|
|
1305
1583
|
}
|
|
1306
1584
|
}
|
|
1307
1585
|
|
|
1586
|
+
function isOutboxRecordName(name) {
|
|
1587
|
+
return !name.startsWith('.') && (name.endsWith('.json') || name.endsWith('.conversation'));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function isOutboxClaimName(name) {
|
|
1591
|
+
return !name.startsWith('.') && (name.includes('.json.sending.') || name.includes('.conversation.sending.'));
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1308
1594
|
function outboxRecordEntries(dir) {
|
|
1309
1595
|
return fs.readdirSync(dir)
|
|
1310
|
-
.filter(
|
|
1596
|
+
.filter(isOutboxRecordName)
|
|
1311
1597
|
.map((name) => {
|
|
1312
1598
|
const file = path.join(dir, name);
|
|
1313
1599
|
let mtimeMs = 0;
|
|
@@ -1331,16 +1617,20 @@ function pruneOutbox(env = process.env, nowMs = Date.now()) {
|
|
|
1331
1617
|
} catch {}
|
|
1332
1618
|
}
|
|
1333
1619
|
entries = outboxRecordEntries(dir);
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1620
|
+
// Conversation volume must not evict pending business stage events.
|
|
1621
|
+
for (const conversation of [false, true]) {
|
|
1622
|
+
const group = entries.filter(entry => entry.name.endsWith('.conversation') === conversation);
|
|
1623
|
+
const overflow = group.length - outboxMaxRecords;
|
|
1624
|
+
if (overflow <= 0) continue;
|
|
1625
|
+
group
|
|
1626
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name))
|
|
1627
|
+
.slice(0, overflow)
|
|
1628
|
+
.forEach((entry) => {
|
|
1629
|
+
try {
|
|
1630
|
+
fs.unlinkSync(entry.file);
|
|
1631
|
+
} catch {}
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1344
1634
|
} catch {}
|
|
1345
1635
|
}
|
|
1346
1636
|
|
|
@@ -1348,7 +1638,7 @@ function recoverStaleOutboxClaims(env = process.env, nowMs = Date.now()) {
|
|
|
1348
1638
|
try {
|
|
1349
1639
|
const dir = outboxDir(env);
|
|
1350
1640
|
for (const name of fs.readdirSync(dir)) {
|
|
1351
|
-
if (!name
|
|
1641
|
+
if (!isOutboxClaimName(name)) continue;
|
|
1352
1642
|
const claimedPath = path.join(dir, name);
|
|
1353
1643
|
const stat = fs.statSync(claimedPath);
|
|
1354
1644
|
if (nowMs - stat.mtimeMs < outboxClaimTtlMs) continue;
|
|
@@ -1406,7 +1696,7 @@ function readOutboxRecord(file) {
|
|
|
1406
1696
|
return null;
|
|
1407
1697
|
}
|
|
1408
1698
|
if (!Number.isSafeInteger(record.created_at) || record.created_at <= 0) return null;
|
|
1409
|
-
const event =
|
|
1699
|
+
const event = normalizeTelemetryEvent(record.event);
|
|
1410
1700
|
if (!event) return null;
|
|
1411
1701
|
return {
|
|
1412
1702
|
schema_ver: record.schema_ver,
|
|
@@ -1419,7 +1709,7 @@ function readOutboxRecord(file) {
|
|
|
1419
1709
|
}
|
|
1420
1710
|
}
|
|
1421
1711
|
|
|
1422
|
-
function hasPendingOutboxRecords(env = process.env) {
|
|
1712
|
+
function hasPendingOutboxRecords(env = process.env, { includeConversations = true } = {}) {
|
|
1423
1713
|
if (!telemetryEnabled(env)) return false;
|
|
1424
1714
|
try {
|
|
1425
1715
|
const dir = outboxDir(env);
|
|
@@ -1427,7 +1717,8 @@ function hasPendingOutboxRecords(env = process.env) {
|
|
|
1427
1717
|
pruneOutbox(env);
|
|
1428
1718
|
return fs.readdirSync(dir).some((name) => {
|
|
1429
1719
|
if (name.startsWith('.')) return false;
|
|
1430
|
-
|
|
1720
|
+
if (!includeConversations && (name.endsWith('.conversation') || name.includes('.conversation.sending.'))) return false;
|
|
1721
|
+
return isOutboxRecordName(name) || isOutboxClaimName(name);
|
|
1431
1722
|
});
|
|
1432
1723
|
} catch {
|
|
1433
1724
|
return false;
|
|
@@ -1466,7 +1757,7 @@ export function permissionedFlushDecision(env = process.env) {
|
|
|
1466
1757
|
if (!telemetryUploadEnabled(env)) {
|
|
1467
1758
|
return { needed: false, outbox_count: 0, error_code: '', status: 'disabled' };
|
|
1468
1759
|
}
|
|
1469
|
-
const outboxCount =
|
|
1760
|
+
const outboxCount = outboxFilesForFlush(env, { includeConversations: false }).length;
|
|
1470
1761
|
const status = readUploadStatus(env);
|
|
1471
1762
|
const errorCode = networkUploadFailure(status);
|
|
1472
1763
|
const result = {
|
|
@@ -1495,14 +1786,39 @@ export async function flushOutbox(env = process.env, {
|
|
|
1495
1786
|
targetFile = '',
|
|
1496
1787
|
timeoutMs = uploadTimeoutMs,
|
|
1497
1788
|
sender = sendSyncLogReportDetailed,
|
|
1498
|
-
|
|
1789
|
+
onSent = null,
|
|
1790
|
+
prioritizeTerminal = false,
|
|
1791
|
+
includeConversations = true
|
|
1499
1792
|
} = {}) {
|
|
1500
1793
|
const result = { attempted: 0, sent: 0, retained: 0, invalid: 0 };
|
|
1501
1794
|
if (!telemetryUploadEnabled(env)) return result;
|
|
1502
|
-
const files = outboxFilesForFlush(env, { targetFile, limit, prioritizeTerminal });
|
|
1795
|
+
const files = outboxFilesForFlush(env, { targetFile, limit, prioritizeTerminal, includeConversations });
|
|
1796
|
+
const stages = { attempted: 0, sent: 0, retained: 0 };
|
|
1797
|
+
const stageFileCount = files.filter(file => !file.endsWith('.conversation')).length;
|
|
1798
|
+
let stageSuccessRecorded = false;
|
|
1799
|
+
function recordStageSuccess() {
|
|
1800
|
+
if (!stageSuccessRecorded && stages.sent > 0 && stages.retained === 0
|
|
1801
|
+
&& !hasPendingOutboxRecords(env, { includeConversations: false })) {
|
|
1802
|
+
stageSuccessRecorded = writeUploadStatus({ status: 'success', ...stages }, env);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1503
1805
|
for (const file of files) {
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1806
|
+
const pending = file.endsWith('.conversation') ? readOutboxRecord(file) : null;
|
|
1807
|
+
const owner = pending && readConversationState(pending.event.conversation_id, env, { checkOwner: false });
|
|
1808
|
+
// Claim only after the recorder has committed state or removed its failed
|
|
1809
|
+
// enqueue. Release this local lock before doing any network work.
|
|
1810
|
+
const claim = owner ? withRunStateLock(env, () => {
|
|
1811
|
+
const state = reconcileConversationMessage(
|
|
1812
|
+
readConversationState(owner.conversation_id, env, { checkOwner: false }), env);
|
|
1813
|
+
return state ? claimOutboxFile(file) : '';
|
|
1814
|
+
}, conversationSessionFile(owner.session_hash, 'index.lock', env)) : null;
|
|
1815
|
+
const claimedFile = owner ? (claim.acquired ? claim.value : '') : claimOutboxFile(file);
|
|
1816
|
+
if (!claimedFile) {
|
|
1817
|
+
// A local recovery/claim failure is retained work, not an empty batch.
|
|
1818
|
+
// Let the worker stop instead of spinning on an uncommittable record.
|
|
1819
|
+
if (fs.existsSync(file)) result.retained += 1;
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1506
1822
|
const record = readOutboxRecord(claimedFile);
|
|
1507
1823
|
if (!record) {
|
|
1508
1824
|
result.invalid += 1;
|
|
@@ -1511,6 +1827,25 @@ export async function flushOutbox(env = process.env, {
|
|
|
1511
1827
|
} catch {}
|
|
1512
1828
|
continue;
|
|
1513
1829
|
}
|
|
1830
|
+
if (isConversationEvent(record.event)) {
|
|
1831
|
+
// Finish stage accounting before handing off to conversation delivery.
|
|
1832
|
+
recordStageSuccess();
|
|
1833
|
+
// Legacy preview queues were captured before explicit consent existed.
|
|
1834
|
+
// Never retroactively authorize those messages with a later confirmation.
|
|
1835
|
+
const state = readConversationState(record.event.conversation_id, env, { checkOwner: false });
|
|
1836
|
+
if (!validConversationId(state?.consent_id)) {
|
|
1837
|
+
try { fs.unlinkSync(claimedFile); } catch { releaseOutboxClaim(claimedFile); }
|
|
1838
|
+
result.invalid += 1;
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
// A recorder queues before committing its sequence. An already-running
|
|
1842
|
+
// worker must leave that record pending until the state write succeeds.
|
|
1843
|
+
if (record.event.sequence > state.sequence) {
|
|
1844
|
+
releaseOutboxClaim(claimedFile);
|
|
1845
|
+
result.retained += 1;
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1514
1849
|
const report = buildSyncLogReport(
|
|
1515
1850
|
record.event,
|
|
1516
1851
|
env,
|
|
@@ -1523,50 +1858,56 @@ export async function flushOutbox(env = process.env, {
|
|
|
1523
1858
|
} catch {}
|
|
1524
1859
|
continue;
|
|
1525
1860
|
}
|
|
1861
|
+
const conversation = isConversationEvent(record.event);
|
|
1526
1862
|
result.attempted += 1;
|
|
1863
|
+
if (!conversation) stages.attempted += 1;
|
|
1527
1864
|
let sendResult = { ok: false, error_code: 'SENDER_FAILED', error_message: 'sender failed' };
|
|
1528
1865
|
try {
|
|
1529
|
-
const rawSendResult = await sender(report, {
|
|
1866
|
+
const rawSendResult = await sender(report, {
|
|
1867
|
+
timeoutMs: isConversationEvent(record.event) ? conversationUploadTimeoutMs : timeoutMs
|
|
1868
|
+
});
|
|
1530
1869
|
sendResult = typeof rawSendResult === 'boolean' ? { ok: rawSendResult } : rawSendResult;
|
|
1531
1870
|
} catch (error) {
|
|
1532
1871
|
sendResult = failedResult(error, 'SENDER_THROW', 'sender threw');
|
|
1533
1872
|
}
|
|
1534
1873
|
if (sendResult?.ok) {
|
|
1535
1874
|
result.sent += 1;
|
|
1875
|
+
if (typeof onSent === 'function') onSent();
|
|
1876
|
+
if (!conversation) stages.sent += 1;
|
|
1536
1877
|
try {
|
|
1537
1878
|
fs.unlinkSync(claimedFile);
|
|
1538
1879
|
} catch {
|
|
1539
1880
|
result.retained += 1;
|
|
1881
|
+
if (!conversation) stages.retained += 1;
|
|
1540
1882
|
releaseOutboxClaim(claimedFile);
|
|
1541
1883
|
}
|
|
1542
1884
|
continue;
|
|
1543
1885
|
}
|
|
1544
|
-
writeUploadStatus({
|
|
1886
|
+
if (!conversation) writeUploadStatus({
|
|
1545
1887
|
status: 'failed',
|
|
1546
|
-
attempted:
|
|
1547
|
-
sent:
|
|
1548
|
-
retained:
|
|
1888
|
+
attempted: stages.attempted,
|
|
1889
|
+
sent: stages.sent,
|
|
1890
|
+
retained: stageFileCount - stages.attempted + 1,
|
|
1549
1891
|
status_code: sendResult?.status_code,
|
|
1550
1892
|
error_code: sendResult?.error_code || 'UPLOAD_FAILED',
|
|
1551
1893
|
error_message: sendResult?.error_message || 'upload failed'
|
|
1552
1894
|
}, env);
|
|
1553
1895
|
releaseOutboxClaim(claimedFile);
|
|
1896
|
+
if (!conversation) stages.retained += stageFileCount - stages.attempted + 1;
|
|
1554
1897
|
result.retained += files.length - result.attempted + 1;
|
|
1555
1898
|
break;
|
|
1556
1899
|
}
|
|
1557
|
-
|
|
1558
|
-
writeUploadStatus({
|
|
1559
|
-
status: 'success',
|
|
1560
|
-
attempted: result.attempted,
|
|
1561
|
-
sent: result.sent,
|
|
1562
|
-
retained: 0
|
|
1563
|
-
}, env);
|
|
1564
|
-
}
|
|
1900
|
+
recordStageSuccess();
|
|
1565
1901
|
return result;
|
|
1566
1902
|
}
|
|
1567
1903
|
|
|
1568
|
-
function outboxFilesForFlush(env = process.env, {
|
|
1569
|
-
|
|
1904
|
+
function outboxFilesForFlush(env = process.env, {
|
|
1905
|
+
targetFile = '', limit = 0, prioritizeTerminal = false, includeConversations = true
|
|
1906
|
+
} = {}) {
|
|
1907
|
+
const files = outboxFiles(env, { targetFile, limit: 0 })
|
|
1908
|
+
.filter((file) => includeConversations || !file.endsWith('.conversation'))
|
|
1909
|
+
// Drain stage uploads before the separately bounded conversation requests.
|
|
1910
|
+
.sort((a, b) => Number(a.endsWith('.conversation')) - Number(b.endsWith('.conversation')));
|
|
1570
1911
|
if (!prioritizeTerminal || targetFile) return limit > 0 ? files.slice(0, limit) : files;
|
|
1571
1912
|
const terminalFiles = [];
|
|
1572
1913
|
const nonTerminalFiles = [];
|
|
@@ -1579,8 +1920,139 @@ function outboxFilesForFlush(env = process.env, { targetFile = '', limit = 0, pr
|
|
|
1579
1920
|
return limit > 0 ? ordered.slice(0, limit) : ordered;
|
|
1580
1921
|
}
|
|
1581
1922
|
|
|
1582
|
-
function
|
|
1923
|
+
function uploadWorkerLeasePath(env) {
|
|
1924
|
+
return path.join(outboxDir(env), '.upload-worker.json');
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function readUploadWorkerLease(env) {
|
|
1928
|
+
try {
|
|
1929
|
+
const file = uploadWorkerLeasePath(env);
|
|
1930
|
+
const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1931
|
+
if (!Number.isSafeInteger(lease.pid) || lease.pid <= 0 || !validConversationId(lease.token)) return null;
|
|
1932
|
+
// Older workers did not store a deadline. Bound their records by their
|
|
1933
|
+
// original file age instead of trusting a reused PID indefinitely.
|
|
1934
|
+
if (lease.started_at === undefined && lease.expires_at === undefined) {
|
|
1935
|
+
const startedAt = Math.floor(fs.statSync(file).mtimeMs);
|
|
1936
|
+
return { ...lease, started_at: startedAt,
|
|
1937
|
+
expires_at: startedAt + uploadWorkerLifetimeMs + 2 * runStateLockWaitMs };
|
|
1938
|
+
}
|
|
1939
|
+
return lease;
|
|
1940
|
+
} catch { return null; }
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
function uploadWorkerProcessAlive(lease) {
|
|
1944
|
+
if (!lease) return false;
|
|
1945
|
+
try { process.kill(lease.pid, 0); return true; }
|
|
1946
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function uploadWorkerAlive(lease) {
|
|
1950
|
+
if (!lease) return false;
|
|
1951
|
+
const now = Date.now();
|
|
1952
|
+
const maxAge = uploadWorkerLifetimeMs + 2 * runStateLockWaitMs;
|
|
1953
|
+
if (!Number.isSafeInteger(lease.started_at) || !Number.isSafeInteger(lease.expires_at)
|
|
1954
|
+
|| lease.started_at <= 0 || lease.started_at > now || lease.expires_at <= now
|
|
1955
|
+
|| lease.expires_at <= lease.started_at || lease.expires_at - lease.started_at > maxAge) return false;
|
|
1956
|
+
return uploadWorkerProcessAlive(lease);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function releaseWorkerClaims(env, pid = process.pid) {
|
|
1960
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return;
|
|
1961
|
+
try {
|
|
1962
|
+
for (const name of fs.readdirSync(outboxDir(env))) {
|
|
1963
|
+
if (isOutboxClaimName(name) && name.endsWith(`.sending.${pid}`)) {
|
|
1964
|
+
releaseOutboxClaim(path.join(outboxDir(env), name));
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
} catch {}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async function runOutboxWorker(env) {
|
|
1971
|
+
// Startup and exit share this short metadata lock; no network work holds it.
|
|
1972
|
+
// Checking and replacing an expired/dead owner's lease are one operation.
|
|
1973
|
+
const testLifetime = Number(env.ALIPAY_AIPAY_TEST_WORKER_LIFETIME_MS);
|
|
1974
|
+
const lifetime = env.ALIPAY_AIPAY_TEST_MODE === '1' && Number.isFinite(testLifetime) && testLifetime > 0
|
|
1975
|
+
? Math.min(testLifetime, uploadWorkerLifetimeMs) : uploadWorkerLifetimeMs;
|
|
1976
|
+
const transaction = withRunStateLock(env, () => {
|
|
1977
|
+
const previous = readUploadWorkerLease(env);
|
|
1978
|
+
if (uploadWorkerAlive(previous)) return null;
|
|
1979
|
+
// A still-live reused PID may own a different foreground upload. Leave its
|
|
1980
|
+
// claims to the existing TTL recovery instead of reclaiming active work.
|
|
1981
|
+
if (previous && !uploadWorkerProcessAlive(previous)) releaseWorkerClaims(env, previous.pid);
|
|
1982
|
+
const startedAt = Date.now();
|
|
1983
|
+
const lease = { pid: process.pid, token: crypto.randomUUID(), started_at: startedAt,
|
|
1984
|
+
expires_at: startedAt + lifetime + 2 * runStateLockWaitMs };
|
|
1985
|
+
return writePrivateJson(uploadWorkerLeasePath(env), lease) ? lease : null;
|
|
1986
|
+
}, `${uploadWorkerLeasePath(env)}.guard`);
|
|
1987
|
+
const lease = transaction.acquired && transaction.value;
|
|
1988
|
+
if (!lease) return;
|
|
1989
|
+
let released = false;
|
|
1990
|
+
let sent = 0;
|
|
1991
|
+
let drained = false;
|
|
1992
|
+
const cleanup = (keepIfPending = false) => {
|
|
1993
|
+
if (released) return false;
|
|
1994
|
+
const transaction = withRunStateLock(env, () => {
|
|
1995
|
+
if (readUploadWorkerLease(env)?.token !== lease.token) { released = true; return false; }
|
|
1996
|
+
// Foreground producers never wait for this guard. Check again after
|
|
1997
|
+
// releasing it, so messages queued in the empty-check / exit window drain.
|
|
1998
|
+
if (keepIfPending && outboxFilesForFlush(env, { limit: 1 }).length > 0) return true;
|
|
1999
|
+
releaseWorkerClaims(env);
|
|
2000
|
+
fs.unlinkSync(uploadWorkerLeasePath(env));
|
|
2001
|
+
released = true;
|
|
2002
|
+
return false;
|
|
2003
|
+
}, `${uploadWorkerLeasePath(env)}.guard`);
|
|
2004
|
+
return transaction.acquired && transaction.value;
|
|
2005
|
+
};
|
|
2006
|
+
// Production lifetime is fixed. Only the explicit test mode can shorten it.
|
|
2007
|
+
const watchdog = setTimeout(() => {
|
|
2008
|
+
cleanup();
|
|
2009
|
+
// Continue a healthy backlog in a new bounded worker. A stalled first
|
|
2010
|
+
// request never creates an endless chain of replacement processes.
|
|
2011
|
+
if (released && sent > 0 && hasPendingOutboxRecords(env)) spawnOutboxFlush({ ...env,
|
|
2012
|
+
ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE: '', ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS: '0' });
|
|
2013
|
+
process.exit(0);
|
|
2014
|
+
}, lifetime);
|
|
2015
|
+
try {
|
|
2016
|
+
const delay = parseNonNegativeInteger(env.ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS);
|
|
2017
|
+
if (delay) await new Promise(resolve => setTimeout(resolve, Math.min(delay, 30000)));
|
|
2018
|
+
const timeout = Number(env.ALIPAY_AIPAY_TELEMETRY_UPLOAD_TIMEOUT_MS || uploadTimeoutMs);
|
|
2019
|
+
let targetFile = env.ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE || '';
|
|
2020
|
+
while (!released) {
|
|
2021
|
+
const result = await flushOutbox(env, {
|
|
2022
|
+
targetFile,
|
|
2023
|
+
timeoutMs: Number.isFinite(timeout) ? timeout : uploadTimeoutMs,
|
|
2024
|
+
onSent: () => { sent += 1; }
|
|
2025
|
+
});
|
|
2026
|
+
targetFile = '';
|
|
2027
|
+
// Do not busy-retry network or local failures. Keep their records queued.
|
|
2028
|
+
if (result.retained > 0) break;
|
|
2029
|
+
if (!cleanup(true)) { drained = true; break; }
|
|
2030
|
+
}
|
|
2031
|
+
} finally {
|
|
2032
|
+
clearTimeout(watchdog);
|
|
2033
|
+
cleanup();
|
|
2034
|
+
if (drained && released && outboxFilesForFlush(env, { limit: 1 }).length > 0) {
|
|
2035
|
+
spawnOutboxFlush({ ...env,
|
|
2036
|
+
ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE: '', ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS: '0' });
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
function spawnOutboxFlush(env = process.env, {
|
|
2042
|
+
delayMs = 0,
|
|
2043
|
+
recordStageFailure = hasPendingOutboxRecords(env, { includeConversations: false })
|
|
2044
|
+
} = {}) {
|
|
1583
2045
|
if (!telemetryUploadEnabled(env)) return false;
|
|
2046
|
+
// Best effort: a busy uploader must not delay capture or business delivery.
|
|
2047
|
+
// Its post-release queue check handles an overlapping successful exit.
|
|
2048
|
+
const transaction = withRunStateLock(env, () => {
|
|
2049
|
+
if (uploadWorkerAlive(readUploadWorkerLease(env))) return true;
|
|
2050
|
+
return launchOutboxFlush(env, { delayMs, recordStageFailure });
|
|
2051
|
+
}, `${uploadWorkerLeasePath(env)}.guard`, 0);
|
|
2052
|
+
return transaction.acquired && transaction.value;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
function launchOutboxFlush(env, { delayMs, recordStageFailure }) {
|
|
1584
2056
|
try {
|
|
1585
2057
|
const childEnv = {
|
|
1586
2058
|
...env,
|
|
@@ -1593,7 +2065,7 @@ function spawnOutboxFlush(env = process.env, { delayMs = 0 } = {}) {
|
|
|
1593
2065
|
env: childEnv
|
|
1594
2066
|
});
|
|
1595
2067
|
child.on('error', (error) => {
|
|
1596
|
-
writeUploadStatus({
|
|
2068
|
+
if (recordStageFailure) writeUploadStatus({
|
|
1597
2069
|
status: 'spawn_failed',
|
|
1598
2070
|
error_code: safeDimension(error?.code || error?.name || 'UPLOAD_SPAWN_FAILED', 'UPLOAD_SPAWN_FAILED', 80),
|
|
1599
2071
|
error_message: safeDimension(error?.message || 'upload spawn failed', 'upload spawn failed', 240)
|
|
@@ -1602,7 +2074,7 @@ function spawnOutboxFlush(env = process.env, { delayMs = 0 } = {}) {
|
|
|
1602
2074
|
child.unref();
|
|
1603
2075
|
return true;
|
|
1604
2076
|
} catch {
|
|
1605
|
-
writeUploadStatus({
|
|
2077
|
+
if (recordStageFailure) writeUploadStatus({
|
|
1606
2078
|
status: 'spawn_failed',
|
|
1607
2079
|
error_code: 'UPLOAD_SPAWN_FAILED',
|
|
1608
2080
|
error_message: 'upload spawn failed'
|
|
@@ -1626,7 +2098,8 @@ export async function runPermissionedOutboxFlush(env = process.env, {
|
|
|
1626
2098
|
limit,
|
|
1627
2099
|
timeoutMs,
|
|
1628
2100
|
sender,
|
|
1629
|
-
prioritizeTerminal: true
|
|
2101
|
+
prioritizeTerminal: true,
|
|
2102
|
+
includeConversations: false
|
|
1630
2103
|
});
|
|
1631
2104
|
const backgroundRetry = spawnBackground
|
|
1632
2105
|
&& flush.attempted > 0
|
|
@@ -1650,16 +2123,92 @@ function emitTelemetryError(error) {
|
|
|
1650
2123
|
export async function runTelemetryCli(argv = process.argv) {
|
|
1651
2124
|
try {
|
|
1652
2125
|
const { command, args } = parseCliArgs(argv);
|
|
2126
|
+
if (['start-conversation', 'conversation-message', 'end-conversation', 'opt-out'].includes(command)) {
|
|
2127
|
+
// Keep input errors silent; JSON parser errors can quote message text.
|
|
2128
|
+
try {
|
|
2129
|
+
if (command !== 'opt-out' && (!telemetryEnabled(process.env) || !conversationCode(process.env))) return 0;
|
|
2130
|
+
if (command === 'opt-out') {
|
|
2131
|
+
assertCliArgs(args, new Set(['conversation-id', 'input-id']), 0);
|
|
2132
|
+
const consent = setConversationConsent({ decision: 'DENIED', conversationId: args['conversation-id'],
|
|
2133
|
+
inputId: args['input-id'] || (args['conversation-id'] && `opt-out:${args['conversation-id']}`) });
|
|
2134
|
+
if (consent?.decision === 'DENIED') {
|
|
2135
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_CONSENT=DENIED\n`);
|
|
2136
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_CLOSED=true\n`);
|
|
2137
|
+
}
|
|
2138
|
+
} else if (command === 'end-conversation') {
|
|
2139
|
+
assertCliArgs(args, new Set(['conversation-id']), 0);
|
|
2140
|
+
const closed = endConversation(args['conversation-id']);
|
|
2141
|
+
if (closed?.lifecycle === 'CLOSED') {
|
|
2142
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_CLOSED=true\n`);
|
|
2143
|
+
}
|
|
2144
|
+
} else if (command === 'start-conversation') {
|
|
2145
|
+
// --host-session-id is a host-compatible alias for --session-id; when neither is
|
|
2146
|
+
// supplied, startConversation uses the previously consented stable env session.
|
|
2147
|
+
assertCliArgs(args, new Set(['session-id', 'host-session-id', 'message-id', 'message-json', 'product-type', 'final']), 0);
|
|
2148
|
+
if (typeof args['message-json'] !== 'string') return 0;
|
|
2149
|
+
const message = normalizeConversationMessageInput(JSON.parse(args['message-json']));
|
|
2150
|
+
if (message?.role !== 'user' || !normalizeUserInputId(args['message-id'])) return 0;
|
|
2151
|
+
const conversation = startConversation({ sessionId: args['host-session-id'] || args['session-id'] });
|
|
2152
|
+
if (!conversation) return 0;
|
|
2153
|
+
const trigger = await recordConversationMessage(message, { conversationId: conversation.conversation_id,
|
|
2154
|
+
messageId: args['message-id'], productType: args['product-type'], final: args.final === 'true' });
|
|
2155
|
+
if (!trigger) {
|
|
2156
|
+
// Only an orphan conversation created by this call is closed.
|
|
2157
|
+
if (conversation.created) endConversation(conversation.conversation_id, process.env);
|
|
2158
|
+
return 0;
|
|
2159
|
+
}
|
|
2160
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_ID=${conversation.conversation_id}\n`);
|
|
2161
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_MESSAGE_RECORDED=${trigger.sequence}\n`);
|
|
2162
|
+
} else {
|
|
2163
|
+
assertCliArgs(args, new Set(['conversation-id', 'product-type', 'message-id', 'message-json', 'final']), 0);
|
|
2164
|
+
if (typeof args['message-json'] !== 'string') return 0;
|
|
2165
|
+
const recorded = await recordConversationMessage(JSON.parse(args['message-json']), {
|
|
2166
|
+
conversationId: args['conversation-id'], productType: args['product-type'], messageId: args['message-id'], final: args.final === 'true'
|
|
2167
|
+
});
|
|
2168
|
+
if (recorded) {
|
|
2169
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_MESSAGE_RECORDED=${recorded.sequence}\n`);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
} catch {}
|
|
2173
|
+
return 0;
|
|
2174
|
+
}
|
|
1653
2175
|
if (command === 'resolve-session') {
|
|
1654
2176
|
assertCliArgs(args, new Set(), 0);
|
|
1655
2177
|
process.stderr.write(`${internalPrefix}SESSION_ID=${resolveTelemetrySession(process.env)}\n`);
|
|
1656
2178
|
return 0;
|
|
1657
2179
|
}
|
|
1658
2180
|
if (command === 'start-run') {
|
|
1659
|
-
assertCliArgs(args, new Set(['root-intent', 'product-type']), 0);
|
|
2181
|
+
assertCliArgs(args, new Set(['root-intent', 'product-type', 'conversation-id', 'conversation-session-id', 'conversation-message-id', 'conversation-message-json']), 0);
|
|
1660
2182
|
if (!allowedRootIntents.has(args['root-intent'])) {
|
|
1661
2183
|
throw new Error('--root-intent 仅支持 integration_only、onboarding_only、full_process');
|
|
1662
2184
|
}
|
|
2185
|
+
if (telemetryEnabled(process.env) && conversationCode(process.env)) {
|
|
2186
|
+
try {
|
|
2187
|
+
const supplied = args['conversation-id'] && readConversationState(args['conversation-id'], process.env);
|
|
2188
|
+
const conversation = args['conversation-id']
|
|
2189
|
+
? (supplied?.lifecycle === 'ACTIVE' && hasConversationConsent(supplied, process.env) ? supplied : null)
|
|
2190
|
+
: startConversation({ sessionId: args['conversation-session-id'] || resolveTelemetrySession(process.env) }, process.env);
|
|
2191
|
+
if (conversation?.conversation_id) {
|
|
2192
|
+
let recorded = null;
|
|
2193
|
+
if (args['conversation-message-id'] && args['conversation-message-json']) {
|
|
2194
|
+
recorded = await recordConversationMessage(JSON.parse(args['conversation-message-json']), {
|
|
2195
|
+
conversationId: conversation.conversation_id,
|
|
2196
|
+
messageId: args['conversation-message-id'],
|
|
2197
|
+
productType: args['product-type']
|
|
2198
|
+
}, process.env);
|
|
2199
|
+
if (!recorded && conversation.created) {
|
|
2200
|
+
// Only an orphan conversation created by this call is closed;
|
|
2201
|
+
// a caller-supplied conversation_id is never closed here.
|
|
2202
|
+
endConversation(conversation.conversation_id, process.env);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
if (!args['conversation-message-json'] || recorded) {
|
|
2206
|
+
process.stderr.write(`${internalPrefix}CONVERSATION_ID=${conversation.conversation_id}\n`);
|
|
2207
|
+
if (recorded) process.stderr.write(`${internalPrefix}CONVERSATION_MESSAGE_RECORDED=${recorded.sequence}\n`);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
} catch {}
|
|
2211
|
+
}
|
|
1663
2212
|
await startTelemetryRun(args['root-intent'], args['product-type'], '', { env: process.env });
|
|
1664
2213
|
return 0;
|
|
1665
2214
|
}
|
|
@@ -1725,13 +2274,7 @@ export async function runTelemetryCli(argv = process.argv) {
|
|
|
1725
2274
|
if (command === 'flush-outbox-worker') {
|
|
1726
2275
|
assertCliArgs(args, new Set(), 0);
|
|
1727
2276
|
if (!telemetryUploadEnabled(process.env)) return 0;
|
|
1728
|
-
|
|
1729
|
-
const delay = parseNonNegativeInteger(process.env.ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS);
|
|
1730
|
-
if (delay && delay > 0) sleepSync(Math.min(delay, 30000));
|
|
1731
|
-
await flushOutbox(process.env, {
|
|
1732
|
-
targetFile: process.env.ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE || '',
|
|
1733
|
-
timeoutMs: Number.isFinite(timeout) ? timeout : uploadTimeoutMs
|
|
1734
|
-
});
|
|
2277
|
+
await runOutboxWorker(process.env);
|
|
1735
2278
|
return 0;
|
|
1736
2279
|
}
|
|
1737
2280
|
if (!telemetryEnabled(process.env)) return 0;
|
|
@@ -1754,6 +2297,7 @@ export async function runTelemetryCli(argv = process.argv) {
|
|
|
1754
2297
|
);
|
|
1755
2298
|
return 0;
|
|
1756
2299
|
} catch (error) {
|
|
2300
|
+
if (['start-conversation', 'conversation-message', 'end-conversation', 'opt-out'].includes(argv[2])) return 0;
|
|
1757
2301
|
emitTelemetryError(error);
|
|
1758
2302
|
return 0;
|
|
1759
2303
|
}
|