@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.
Files changed (28) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -3
  3. package/skill/SKILL.md +34 -3
  4. package/skill/VERSION +1 -1
  5. package/skill/references/normal/conversation-telemetry.md +103 -0
  6. package/skill/references/normal/json/customer-messages.json +1 -1
  7. package/skill/references/normal/json/policy-rules.json +1 -1
  8. package/skill/references/normal/scripts/conversation_consent.mjs +76 -0
  9. package/skill/references/normal/scripts/conversation_telemetry.mjs +200 -0
  10. package/skill/references/normal/scripts/open_official_url.sh +4 -2
  11. package/skill/references/normal/scripts/render_customer_message.mjs +6 -2
  12. package/skill/references/normal/scripts/runtime.mjs +1 -0
  13. package/skill/references/normal/scripts/self_update.mjs +6 -7
  14. package/skill/references/normal/scripts/telemetry.mjs +618 -74
  15. package/skill/references/normal/scripts/windows/bootstrap-node.ps1 +0 -2
  16. package/skill/references/runtime-entry.md +7 -1
  17. package/skill-pre/VERSION +1 -1
  18. package/skill-pre/references/normal/conversation-telemetry.md +9 -9
  19. package/skill-pre/references/normal/scripts/conversation_telemetry.mjs +113 -18
  20. package/skill-pre/references/normal/scripts/runtime.mjs +4 -4
  21. package/skill-pre/references/normal/scripts/self_update.mjs +6 -7
  22. package/skill-pre/references/normal/scripts/telemetry.mjs +243 -32
  23. package/skill-pre/references/normal/scripts/windows/git-discovery.mjs +6 -2
  24. package/skill-pre/references/normal/scripts/windows/git-install.mjs +2 -3
  25. package/skill-pre/references/normal/scripts/windows/preflight.mjs +13 -2
  26. package/skill-pre/references/normal/scripts/windows/prepare.mjs +2 -2
  27. package/skill-pre/references/normal/scripts/windows/routing.mjs +4 -1
  28. 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 (Date.now() - startedAt < runStateLockWaitMs) {
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) fs.unlinkSync(lockPath);
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 = safeText(event?.event_id) || crypto.randomUUID();
526
- return `${timestamp}-${eventID}.json`;
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 = normalizeMetricEvent(event);
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
- if (!writePrivateJson(finalPath, {
539
- schema_ver: 2,
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
- writeUploadStatus({ status: 'pending', attempted: 0, sent: 0, retained: 1 }, env);
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 = normalizeMetricEvent(event);
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, { timeoutMs = uploadTimeoutMs } = {}) {
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
- const req = https.request(`${syncLogServerURL}?biztype=${syncLogBizType}`, {
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: timeoutMs
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.endsWith('.json')
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((name) => name.endsWith('.json') && !name.startsWith('.'))
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
- const overflow = entries.length - outboxMaxRecords;
1335
- if (overflow <= 0) return;
1336
- entries
1337
- .sort((left, right) => left.mtimeMs - right.mtimeMs || left.name.localeCompare(right.name))
1338
- .slice(0, overflow)
1339
- .forEach((entry) => {
1340
- try {
1341
- fs.unlinkSync(entry.file);
1342
- } catch {}
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.includes('.json.sending.')) continue;
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 = normalizeMetricEvent(record.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
- return name.endsWith('.json') || name.includes('.json.sending.');
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 = outboxFiles(env).length;
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
- prioritizeTerminal = false
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 claimedFile = claimOutboxFile(file);
1505
- if (!claimedFile) continue;
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, { timeoutMs });
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: result.attempted,
1547
- sent: result.sent,
1548
- retained: files.length - result.attempted + 1,
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
- if (result.sent > 0 && result.retained === 0 && !hasPendingOutboxRecords(env)) {
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, { targetFile = '', limit = 0, prioritizeTerminal = false } = {}) {
1569
- const files = outboxFiles(env, { targetFile, limit: 0 });
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 spawnOutboxFlush(env = process.env, { delayMs = 0 } = {}) {
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
- const timeout = Number(process.env.ALIPAY_AIPAY_TELEMETRY_UPLOAD_TIMEOUT_MS || uploadTimeoutMs);
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
  }