@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
|
@@ -24,6 +24,8 @@ const syncLogCode = '1005';
|
|
|
24
24
|
const syncLogMsg = 'alipay_aipay_skill_event';
|
|
25
25
|
const syncLogYuyanID = '180020010001290755';
|
|
26
26
|
const uploadTimeoutMs = 3000;
|
|
27
|
+
const conversationUploadTimeoutMs = 10000;
|
|
28
|
+
const uploadWorkerLifetimeMs = 60000;
|
|
27
29
|
const permissionedForegroundFlushLimit = 5;
|
|
28
30
|
const localWriteMaxAttempts = 2;
|
|
29
31
|
const runStateLockWaitMs = 5000;
|
|
@@ -405,11 +407,12 @@ function writePrivateJson(file, value) {
|
|
|
405
407
|
return false;
|
|
406
408
|
}
|
|
407
409
|
|
|
408
|
-
function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env)) {
|
|
410
|
+
function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env), waitMs = runStateLockWaitMs) {
|
|
409
411
|
const startedAt = Date.now();
|
|
412
|
+
let recoveredStaleLock = false;
|
|
410
413
|
try {
|
|
411
414
|
ensurePrivateDir(path.dirname(lockPath));
|
|
412
|
-
while (
|
|
415
|
+
while (true) {
|
|
413
416
|
try {
|
|
414
417
|
const descriptor = fs.openSync(lockPath, 'wx', 0o600);
|
|
415
418
|
fs.closeSync(descriptor);
|
|
@@ -418,8 +421,13 @@ function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env)
|
|
|
418
421
|
if (error?.code !== 'EEXIST') return '';
|
|
419
422
|
try {
|
|
420
423
|
const lockStat = fs.statSync(lockPath);
|
|
421
|
-
if (Date.now() - lockStat.mtimeMs >= runStateLockStaleMs)
|
|
424
|
+
if (!recoveredStaleLock && Date.now() - lockStat.mtimeMs >= runStateLockStaleMs) {
|
|
425
|
+
fs.unlinkSync(lockPath);
|
|
426
|
+
recoveredStaleLock = true;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
422
429
|
} catch {}
|
|
430
|
+
if (Date.now() - startedAt >= waitMs) return '';
|
|
423
431
|
sleepSync(10);
|
|
424
432
|
}
|
|
425
433
|
}
|
|
@@ -427,8 +435,8 @@ function acquireRunStateLock(env = process.env, lockPath = runStateLockPath(env)
|
|
|
427
435
|
return '';
|
|
428
436
|
}
|
|
429
437
|
|
|
430
|
-
function withRunStateLock(env = process.env, operation, requestedLockPath = runStateLockPath(env)) {
|
|
431
|
-
const lockPath = acquireRunStateLock(env, requestedLockPath);
|
|
438
|
+
function withRunStateLock(env = process.env, operation, requestedLockPath = runStateLockPath(env), waitMs = runStateLockWaitMs) {
|
|
439
|
+
const lockPath = acquireRunStateLock(env, requestedLockPath, waitMs);
|
|
432
440
|
if (!lockPath) return { acquired: false, value: null };
|
|
433
441
|
try {
|
|
434
442
|
return { acquired: true, value: operation() };
|
|
@@ -893,7 +901,7 @@ export function setConversationConsent({ sessionId = '', decision, inputId, conv
|
|
|
893
901
|
}
|
|
894
902
|
}
|
|
895
903
|
return next;
|
|
896
|
-
}, conversationSessionFile(ownerHash, 'index.lock', env));
|
|
904
|
+
}, conversationSessionFile(ownerHash, 'index.lock', env), 0);
|
|
897
905
|
return transaction.acquired ? transaction.value : null;
|
|
898
906
|
} catch { return null; }
|
|
899
907
|
}
|
|
@@ -920,6 +928,39 @@ function readConversationState(conversationId, env, { checkOwner = true } = {})
|
|
|
920
928
|
} catch { return null; }
|
|
921
929
|
}
|
|
922
930
|
|
|
931
|
+
// Called only while holding the conversation's session lock. A private pending
|
|
932
|
+
// marker survives a crash between enqueue and the final sequence/dedup commit.
|
|
933
|
+
function reconcileConversationMessage(state, env) {
|
|
934
|
+
if (!state || !Object.prototype.hasOwnProperty.call(state, 'pending_message')) return state;
|
|
935
|
+
const pending = state.pending_message;
|
|
936
|
+
if (!pending || pending.sequence !== state.sequence + 1
|
|
937
|
+
|| !Number.isSafeInteger(pending.sequence)
|
|
938
|
+
|| !/^[a-f0-9]{32}$/.test(pending.message_hash || '')
|
|
939
|
+
|| state.message_hashes.includes(pending.message_hash)
|
|
940
|
+
|| !Number.isSafeInteger(pending.timestamp) || pending.timestamp <= 0) return null;
|
|
941
|
+
const basename = outboxFilename({ kind: 'conversation_message',
|
|
942
|
+
conversation_id: state.conversation_id, sequence: pending.sequence, timestamp: pending.timestamp });
|
|
943
|
+
let queued;
|
|
944
|
+
try {
|
|
945
|
+
const names = fs.readdirSync(outboxDir(env));
|
|
946
|
+
const name = names.find(name => name === basename || name.startsWith(`${basename}.sending.`));
|
|
947
|
+
if (name) {
|
|
948
|
+
queued = readOutboxRecord(path.join(outboxDir(env), name));
|
|
949
|
+
if (!queued || queued.event.conversation_id !== state.conversation_id
|
|
950
|
+
|| queued.event.sequence !== pending.sequence || queued.event.timestamp !== pending.timestamp) return null;
|
|
951
|
+
}
|
|
952
|
+
} catch (error) {
|
|
953
|
+
// Missing queue means the process stopped before enqueue, or cleanup succeeded.
|
|
954
|
+
if (error?.code !== 'ENOENT') return null;
|
|
955
|
+
}
|
|
956
|
+
const { pending_message: _pending, ...next } = state;
|
|
957
|
+
if (queued) {
|
|
958
|
+
next.sequence = pending.sequence;
|
|
959
|
+
next.message_hashes = [...state.message_hashes, pending.message_hash];
|
|
960
|
+
}
|
|
961
|
+
return writePrivateJson(conversationStatePath(state.conversation_id, env), next) ? next : null;
|
|
962
|
+
}
|
|
963
|
+
|
|
923
964
|
export function startConversation({ sessionId = '' } = {}, env = process.env, nowMs = Date.now()) {
|
|
924
965
|
if (!telemetryEnabled(env) || !conversationCode(env)) return null;
|
|
925
966
|
try {
|
|
@@ -944,7 +985,7 @@ export function startConversation({ sessionId = '' } = {}, env = process.env, no
|
|
|
944
985
|
if (!writePrivateJson(conversationStatePath(conversationId, env), state)
|
|
945
986
|
|| !writePrivateJson(indexPath, { conversation_id: conversationId })) return null;
|
|
946
987
|
return { ...state, created: true };
|
|
947
|
-
}, `${indexPath}.lock
|
|
988
|
+
}, `${indexPath}.lock`, 0);
|
|
948
989
|
return transaction.acquired ? transaction.value : null;
|
|
949
990
|
} catch { return null; }
|
|
950
991
|
}
|
|
@@ -966,7 +1007,7 @@ export function endConversation(conversationId, env = process.env) {
|
|
|
966
1007
|
if (!state || state.lifecycle === 'CLOSED') return state;
|
|
967
1008
|
const closed = { ...state, lifecycle: 'CLOSED' };
|
|
968
1009
|
return writePrivateJson(file, closed) ? closed : null;
|
|
969
|
-
}, conversationSessionFile(existing.session_hash, 'index.lock', env));
|
|
1010
|
+
}, conversationSessionFile(existing.session_hash, 'index.lock', env), 0);
|
|
970
1011
|
return transaction.acquired ? transaction.value : null;
|
|
971
1012
|
} catch { return null; }
|
|
972
1013
|
}
|
|
@@ -992,7 +1033,7 @@ export async function recordConversationMessage(message, context = {}, env = pro
|
|
|
992
1033
|
const existing = readConversationState(conversationId, env);
|
|
993
1034
|
if (!existing) return null;
|
|
994
1035
|
const transaction = withRunStateLock(env, () => {
|
|
995
|
-
const state = readConversationState(conversationId, env);
|
|
1036
|
+
const state = reconcileConversationMessage(readConversationState(conversationId, env), env);
|
|
996
1037
|
if (!state || state.lifecycle !== 'ACTIVE' || !hasConversationConsent(state, env)) return null;
|
|
997
1038
|
const hash = userInputHash(`${message.role}:${messageId}`, conversationId);
|
|
998
1039
|
const existingIndex = state.message_hashes.indexOf(hash);
|
|
@@ -1003,13 +1044,28 @@ export async function recordConversationMessage(message, context = {}, env = pro
|
|
|
1003
1044
|
const event = normalizeConversationEvent({ ...conversationEventBase(state, productType, env, nowMs),
|
|
1004
1045
|
sequence, role: message.role, content: sanitized.content, truncated: sanitized.truncated,
|
|
1005
1046
|
has_attachments: message.has_attachments ?? false, redaction_version: 1 });
|
|
1047
|
+
if (!event) return null;
|
|
1048
|
+
// Reserve only recovery metadata, not a committed sequence or message body.
|
|
1049
|
+
if (!writePrivateJson(file, { ...state, pending_message: {
|
|
1050
|
+
sequence, message_hash: hash, timestamp: event.timestamp
|
|
1051
|
+
} })) return null;
|
|
1052
|
+
const queuedFile = writeOutboxRecord(event, env);
|
|
1053
|
+
if (!queuedFile) {
|
|
1054
|
+
writePrivateJson(file, state);
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1006
1057
|
const next = { ...state, sequence, message_hashes: [...state.message_hashes, hash] };
|
|
1007
|
-
|
|
1008
|
-
|
|
1058
|
+
if (!writePrivateJson(file, next)) {
|
|
1059
|
+
try { fs.unlinkSync(queuedFile); } catch {}
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
return event;
|
|
1063
|
+
}, conversationSessionFile(existing.session_hash, 'index.lock', env), 0);
|
|
1009
1064
|
if (!transaction.acquired || !transaction.value) return null;
|
|
1010
|
-
|
|
1065
|
+
// Recovered duplicate retries may also need to wake their queued upload.
|
|
1066
|
+
// Already queued under the session lock; do not enqueue twice or wait for HTTP.
|
|
1011
1067
|
// Never echo dialogue content in stderr markers, including in capture-only mode.
|
|
1012
|
-
|
|
1068
|
+
if (telemetryUploadEnabled(env)) spawnOutboxFlush(env, { recordStageFailure: false });
|
|
1013
1069
|
return transaction.value;
|
|
1014
1070
|
} catch { return null; }
|
|
1015
1071
|
}
|
|
@@ -1452,30 +1508,43 @@ export function isSuccessfulHttpStatus(statusCode) {
|
|
|
1452
1508
|
}
|
|
1453
1509
|
|
|
1454
1510
|
export function sendSyncLogReportDetailed(report, {
|
|
1455
|
-
timeoutMs = report?.item?.msg === 'alipay_aipay_skill_conversation' ?
|
|
1511
|
+
timeoutMs = report?.item?.msg === 'alipay_aipay_skill_conversation' ? conversationUploadTimeoutMs : uploadTimeoutMs,
|
|
1512
|
+
request = https.request
|
|
1456
1513
|
} = {}) {
|
|
1457
1514
|
return new Promise((resolve) => {
|
|
1458
1515
|
let settled = false;
|
|
1516
|
+
let timer;
|
|
1517
|
+
let req;
|
|
1518
|
+
const limit = report?.item?.msg === 'alipay_aipay_skill_conversation' ? conversationUploadTimeoutMs : uploadTimeoutMs;
|
|
1519
|
+
const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.min(timeoutMs, limit) : limit;
|
|
1459
1520
|
const finish = (result) => {
|
|
1460
1521
|
if (settled) return;
|
|
1461
1522
|
settled = true;
|
|
1523
|
+
clearTimeout(timer);
|
|
1462
1524
|
resolve(result);
|
|
1463
1525
|
};
|
|
1526
|
+
const timeout = () => {
|
|
1527
|
+
finish({ ok: false, error_code: 'ETIMEDOUT', error_message: 'request timeout' });
|
|
1528
|
+
req?.destroy();
|
|
1529
|
+
};
|
|
1464
1530
|
try {
|
|
1465
1531
|
if (!report?.item || !report?.options) {
|
|
1466
1532
|
finish({ ok: false, error_code: 'INVALID_REPORT', error_message: 'invalid report' });
|
|
1467
1533
|
return;
|
|
1468
1534
|
}
|
|
1469
1535
|
const body = syncLogRequestBody(report);
|
|
1470
|
-
|
|
1536
|
+
// A wall-clock deadline also covers DNS/TLS stalls and trickling responses.
|
|
1537
|
+
timer = setTimeout(timeout, budget);
|
|
1538
|
+
req = request(`${syncLogServerURL}?biztype=${syncLogBizType}`, {
|
|
1471
1539
|
method: 'POST',
|
|
1472
1540
|
headers: {
|
|
1473
1541
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1474
1542
|
'Content-Length': Buffer.byteLength(body)
|
|
1475
1543
|
},
|
|
1476
|
-
timeout:
|
|
1544
|
+
timeout: budget
|
|
1477
1545
|
}, (resp) => {
|
|
1478
1546
|
resp.resume();
|
|
1547
|
+
resp.on('error', error => finish(failedResult(error)));
|
|
1479
1548
|
resp.on('end', () => {
|
|
1480
1549
|
const statusCode = Number(resp.statusCode || 0);
|
|
1481
1550
|
const ok = isSuccessfulHttpStatus(statusCode);
|
|
@@ -1489,10 +1558,7 @@ export function sendSyncLogReportDetailed(report, {
|
|
|
1489
1558
|
});
|
|
1490
1559
|
});
|
|
1491
1560
|
});
|
|
1492
|
-
req.on('timeout',
|
|
1493
|
-
finish({ ok: false, error_code: 'ETIMEDOUT', error_message: 'request timeout' });
|
|
1494
|
-
req.destroy();
|
|
1495
|
-
});
|
|
1561
|
+
req.on('timeout', timeout);
|
|
1496
1562
|
req.on('error', (error) => finish(failedResult(error)));
|
|
1497
1563
|
req.end(body);
|
|
1498
1564
|
} catch (error) {
|
|
@@ -1729,6 +1795,7 @@ export async function flushOutbox(env = process.env, {
|
|
|
1729
1795
|
targetFile = '',
|
|
1730
1796
|
timeoutMs = uploadTimeoutMs,
|
|
1731
1797
|
sender = sendSyncLogReportDetailed,
|
|
1798
|
+
onSent = null,
|
|
1732
1799
|
prioritizeTerminal = false,
|
|
1733
1800
|
includeConversations = true
|
|
1734
1801
|
} = {}) {
|
|
@@ -1745,8 +1812,22 @@ export async function flushOutbox(env = process.env, {
|
|
|
1745
1812
|
}
|
|
1746
1813
|
}
|
|
1747
1814
|
for (const file of files) {
|
|
1748
|
-
const
|
|
1749
|
-
|
|
1815
|
+
const pending = file.endsWith('.conversation') ? readOutboxRecord(file) : null;
|
|
1816
|
+
const owner = pending && readConversationState(pending.event.conversation_id, env, { checkOwner: false });
|
|
1817
|
+
// Claim only after the recorder has committed state or removed its failed
|
|
1818
|
+
// enqueue. Release this local lock before doing any network work.
|
|
1819
|
+
const claim = owner ? withRunStateLock(env, () => {
|
|
1820
|
+
const state = reconcileConversationMessage(
|
|
1821
|
+
readConversationState(owner.conversation_id, env, { checkOwner: false }), env);
|
|
1822
|
+
return state ? claimOutboxFile(file) : '';
|
|
1823
|
+
}, conversationSessionFile(owner.session_hash, 'index.lock', env)) : null;
|
|
1824
|
+
const claimedFile = owner ? (claim.acquired ? claim.value : '') : claimOutboxFile(file);
|
|
1825
|
+
if (!claimedFile) {
|
|
1826
|
+
// A local recovery/claim failure is retained work, not an empty batch.
|
|
1827
|
+
// Let the worker stop instead of spinning on an uncommittable record.
|
|
1828
|
+
if (fs.existsSync(file)) result.retained += 1;
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1750
1831
|
const record = readOutboxRecord(claimedFile);
|
|
1751
1832
|
if (!record) {
|
|
1752
1833
|
result.invalid += 1;
|
|
@@ -1756,7 +1837,7 @@ export async function flushOutbox(env = process.env, {
|
|
|
1756
1837
|
continue;
|
|
1757
1838
|
}
|
|
1758
1839
|
if (isConversationEvent(record.event)) {
|
|
1759
|
-
// Finish stage accounting before
|
|
1840
|
+
// Finish stage accounting before handing off to conversation delivery.
|
|
1760
1841
|
recordStageSuccess();
|
|
1761
1842
|
// Legacy preview queues were captured before explicit consent existed.
|
|
1762
1843
|
// Never retroactively authorize those messages with a later confirmation.
|
|
@@ -1766,6 +1847,13 @@ export async function flushOutbox(env = process.env, {
|
|
|
1766
1847
|
result.invalid += 1;
|
|
1767
1848
|
continue;
|
|
1768
1849
|
}
|
|
1850
|
+
// A recorder queues before committing its sequence. An already-running
|
|
1851
|
+
// worker must leave that record pending until the state write succeeds.
|
|
1852
|
+
if (record.event.sequence > state.sequence) {
|
|
1853
|
+
releaseOutboxClaim(claimedFile);
|
|
1854
|
+
result.retained += 1;
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1769
1857
|
}
|
|
1770
1858
|
const report = buildSyncLogReport(
|
|
1771
1859
|
record.event,
|
|
@@ -1785,7 +1873,7 @@ export async function flushOutbox(env = process.env, {
|
|
|
1785
1873
|
let sendResult = { ok: false, error_code: 'SENDER_FAILED', error_message: 'sender failed' };
|
|
1786
1874
|
try {
|
|
1787
1875
|
const rawSendResult = await sender(report, {
|
|
1788
|
-
timeoutMs: isConversationEvent(record.event) ?
|
|
1876
|
+
timeoutMs: isConversationEvent(record.event) ? conversationUploadTimeoutMs : timeoutMs
|
|
1789
1877
|
});
|
|
1790
1878
|
sendResult = typeof rawSendResult === 'boolean' ? { ok: rawSendResult } : rawSendResult;
|
|
1791
1879
|
} catch (error) {
|
|
@@ -1793,6 +1881,7 @@ export async function flushOutbox(env = process.env, {
|
|
|
1793
1881
|
}
|
|
1794
1882
|
if (sendResult?.ok) {
|
|
1795
1883
|
result.sent += 1;
|
|
1884
|
+
if (typeof onSent === 'function') onSent();
|
|
1796
1885
|
if (!conversation) stages.sent += 1;
|
|
1797
1886
|
try {
|
|
1798
1887
|
fs.unlinkSync(claimedFile);
|
|
@@ -1826,7 +1915,7 @@ function outboxFilesForFlush(env = process.env, {
|
|
|
1826
1915
|
} = {}) {
|
|
1827
1916
|
const files = outboxFiles(env, { targetFile, limit: 0 })
|
|
1828
1917
|
.filter((file) => includeConversations || !file.endsWith('.conversation'))
|
|
1829
|
-
// Drain
|
|
1918
|
+
// Drain stage uploads before the separately bounded conversation requests.
|
|
1830
1919
|
.sort((a, b) => Number(a.endsWith('.conversation')) - Number(b.endsWith('.conversation')));
|
|
1831
1920
|
if (!prioritizeTerminal || targetFile) return limit > 0 ? files.slice(0, limit) : files;
|
|
1832
1921
|
const terminalFiles = [];
|
|
@@ -1840,11 +1929,139 @@ function outboxFilesForFlush(env = process.env, {
|
|
|
1840
1929
|
return limit > 0 ? ordered.slice(0, limit) : ordered;
|
|
1841
1930
|
}
|
|
1842
1931
|
|
|
1932
|
+
function uploadWorkerLeasePath(env) {
|
|
1933
|
+
return path.join(outboxDir(env), '.upload-worker.json');
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function readUploadWorkerLease(env) {
|
|
1937
|
+
try {
|
|
1938
|
+
const file = uploadWorkerLeasePath(env);
|
|
1939
|
+
const lease = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1940
|
+
if (!Number.isSafeInteger(lease.pid) || lease.pid <= 0 || !validConversationId(lease.token)) return null;
|
|
1941
|
+
// Older workers did not store a deadline. Bound their records by their
|
|
1942
|
+
// original file age instead of trusting a reused PID indefinitely.
|
|
1943
|
+
if (lease.started_at === undefined && lease.expires_at === undefined) {
|
|
1944
|
+
const startedAt = Math.floor(fs.statSync(file).mtimeMs);
|
|
1945
|
+
return { ...lease, started_at: startedAt,
|
|
1946
|
+
expires_at: startedAt + uploadWorkerLifetimeMs + 2 * runStateLockWaitMs };
|
|
1947
|
+
}
|
|
1948
|
+
return lease;
|
|
1949
|
+
} catch { return null; }
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function uploadWorkerProcessAlive(lease) {
|
|
1953
|
+
if (!lease) return false;
|
|
1954
|
+
try { process.kill(lease.pid, 0); return true; }
|
|
1955
|
+
catch (error) { return error.code !== 'ESRCH'; }
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function uploadWorkerAlive(lease) {
|
|
1959
|
+
if (!lease) return false;
|
|
1960
|
+
const now = Date.now();
|
|
1961
|
+
const maxAge = uploadWorkerLifetimeMs + 2 * runStateLockWaitMs;
|
|
1962
|
+
if (!Number.isSafeInteger(lease.started_at) || !Number.isSafeInteger(lease.expires_at)
|
|
1963
|
+
|| lease.started_at <= 0 || lease.started_at > now || lease.expires_at <= now
|
|
1964
|
+
|| lease.expires_at <= lease.started_at || lease.expires_at - lease.started_at > maxAge) return false;
|
|
1965
|
+
return uploadWorkerProcessAlive(lease);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function releaseWorkerClaims(env, pid = process.pid) {
|
|
1969
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return;
|
|
1970
|
+
try {
|
|
1971
|
+
for (const name of fs.readdirSync(outboxDir(env))) {
|
|
1972
|
+
if (isOutboxClaimName(name) && name.endsWith(`.sending.${pid}`)) {
|
|
1973
|
+
releaseOutboxClaim(path.join(outboxDir(env), name));
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
} catch {}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
async function runOutboxWorker(env) {
|
|
1980
|
+
// Startup and exit share this short metadata lock; no network work holds it.
|
|
1981
|
+
// Checking and replacing an expired/dead owner's lease are one operation.
|
|
1982
|
+
const testLifetime = Number(env.ALIPAY_AIPAY_TEST_WORKER_LIFETIME_MS);
|
|
1983
|
+
const lifetime = env.ALIPAY_AIPAY_TEST_MODE === '1' && Number.isFinite(testLifetime) && testLifetime > 0
|
|
1984
|
+
? Math.min(testLifetime, uploadWorkerLifetimeMs) : uploadWorkerLifetimeMs;
|
|
1985
|
+
const transaction = withRunStateLock(env, () => {
|
|
1986
|
+
const previous = readUploadWorkerLease(env);
|
|
1987
|
+
if (uploadWorkerAlive(previous)) return null;
|
|
1988
|
+
// A still-live reused PID may own a different foreground upload. Leave its
|
|
1989
|
+
// claims to the existing TTL recovery instead of reclaiming active work.
|
|
1990
|
+
if (previous && !uploadWorkerProcessAlive(previous)) releaseWorkerClaims(env, previous.pid);
|
|
1991
|
+
const startedAt = Date.now();
|
|
1992
|
+
const lease = { pid: process.pid, token: crypto.randomUUID(), started_at: startedAt,
|
|
1993
|
+
expires_at: startedAt + lifetime + 2 * runStateLockWaitMs };
|
|
1994
|
+
return writePrivateJson(uploadWorkerLeasePath(env), lease) ? lease : null;
|
|
1995
|
+
}, `${uploadWorkerLeasePath(env)}.guard`);
|
|
1996
|
+
const lease = transaction.acquired && transaction.value;
|
|
1997
|
+
if (!lease) return;
|
|
1998
|
+
let released = false;
|
|
1999
|
+
let sent = 0;
|
|
2000
|
+
let drained = false;
|
|
2001
|
+
const cleanup = (keepIfPending = false) => {
|
|
2002
|
+
if (released) return false;
|
|
2003
|
+
const transaction = withRunStateLock(env, () => {
|
|
2004
|
+
if (readUploadWorkerLease(env)?.token !== lease.token) { released = true; return false; }
|
|
2005
|
+
// Foreground producers never wait for this guard. Check again after
|
|
2006
|
+
// releasing it, so messages queued in the empty-check / exit window drain.
|
|
2007
|
+
if (keepIfPending && outboxFilesForFlush(env, { limit: 1 }).length > 0) return true;
|
|
2008
|
+
releaseWorkerClaims(env);
|
|
2009
|
+
fs.unlinkSync(uploadWorkerLeasePath(env));
|
|
2010
|
+
released = true;
|
|
2011
|
+
return false;
|
|
2012
|
+
}, `${uploadWorkerLeasePath(env)}.guard`);
|
|
2013
|
+
return transaction.acquired && transaction.value;
|
|
2014
|
+
};
|
|
2015
|
+
// Production lifetime is fixed. Only the explicit test mode can shorten it.
|
|
2016
|
+
const watchdog = setTimeout(() => {
|
|
2017
|
+
cleanup();
|
|
2018
|
+
// Continue a healthy backlog in a new bounded worker. A stalled first
|
|
2019
|
+
// request never creates an endless chain of replacement processes.
|
|
2020
|
+
if (released && sent > 0 && hasPendingOutboxRecords(env)) spawnOutboxFlush({ ...env,
|
|
2021
|
+
ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE: '', ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS: '0' });
|
|
2022
|
+
process.exit(0);
|
|
2023
|
+
}, lifetime);
|
|
2024
|
+
try {
|
|
2025
|
+
const delay = parseNonNegativeInteger(env.ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS);
|
|
2026
|
+
if (delay) await new Promise(resolve => setTimeout(resolve, Math.min(delay, 30000)));
|
|
2027
|
+
const timeout = Number(env.ALIPAY_AIPAY_TELEMETRY_UPLOAD_TIMEOUT_MS || uploadTimeoutMs);
|
|
2028
|
+
let targetFile = env.ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE || '';
|
|
2029
|
+
while (!released) {
|
|
2030
|
+
const result = await flushOutbox(env, {
|
|
2031
|
+
targetFile,
|
|
2032
|
+
timeoutMs: Number.isFinite(timeout) ? timeout : uploadTimeoutMs,
|
|
2033
|
+
onSent: () => { sent += 1; }
|
|
2034
|
+
});
|
|
2035
|
+
targetFile = '';
|
|
2036
|
+
// Do not busy-retry network or local failures. Keep their records queued.
|
|
2037
|
+
if (result.retained > 0) break;
|
|
2038
|
+
if (!cleanup(true)) { drained = true; break; }
|
|
2039
|
+
}
|
|
2040
|
+
} finally {
|
|
2041
|
+
clearTimeout(watchdog);
|
|
2042
|
+
cleanup();
|
|
2043
|
+
if (drained && released && outboxFilesForFlush(env, { limit: 1 }).length > 0) {
|
|
2044
|
+
spawnOutboxFlush({ ...env,
|
|
2045
|
+
ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE: '', ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS: '0' });
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
1843
2050
|
function spawnOutboxFlush(env = process.env, {
|
|
1844
2051
|
delayMs = 0,
|
|
1845
2052
|
recordStageFailure = hasPendingOutboxRecords(env, { includeConversations: false })
|
|
1846
2053
|
} = {}) {
|
|
1847
2054
|
if (!telemetryUploadEnabled(env)) return false;
|
|
2055
|
+
// Best effort: a busy uploader must not delay capture or business delivery.
|
|
2056
|
+
// Its post-release queue check handles an overlapping successful exit.
|
|
2057
|
+
const transaction = withRunStateLock(env, () => {
|
|
2058
|
+
if (uploadWorkerAlive(readUploadWorkerLease(env))) return true;
|
|
2059
|
+
return launchOutboxFlush(env, { delayMs, recordStageFailure });
|
|
2060
|
+
}, `${uploadWorkerLeasePath(env)}.guard`, 0);
|
|
2061
|
+
return transaction.acquired && transaction.value;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function launchOutboxFlush(env, { delayMs, recordStageFailure }) {
|
|
1848
2065
|
try {
|
|
1849
2066
|
const childEnv = {
|
|
1850
2067
|
...env,
|
|
@@ -2066,13 +2283,7 @@ export async function runTelemetryCli(argv = process.argv) {
|
|
|
2066
2283
|
if (command === 'flush-outbox-worker') {
|
|
2067
2284
|
assertCliArgs(args, new Set(), 0);
|
|
2068
2285
|
if (!telemetryUploadEnabled(process.env)) return 0;
|
|
2069
|
-
|
|
2070
|
-
const delay = parseNonNegativeInteger(process.env.ALIPAY_AIPAY_TELEMETRY_FLUSH_DELAY_MS);
|
|
2071
|
-
if (delay && delay > 0) sleepSync(Math.min(delay, 30000));
|
|
2072
|
-
await flushOutbox(process.env, {
|
|
2073
|
-
targetFile: process.env.ALIPAY_AIPAY_TELEMETRY_FLUSH_TARGET_FILE || '',
|
|
2074
|
-
timeoutMs: Number.isFinite(timeout) ? timeout : uploadTimeoutMs
|
|
2075
|
-
});
|
|
2286
|
+
await runOutboxWorker(process.env);
|
|
2076
2287
|
return 0;
|
|
2077
2288
|
}
|
|
2078
2289
|
if (!telemetryEnabled(process.env)) return 0;
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {run, fail} from './host.mjs';
|
|
4
|
-
|
|
4
|
+
function managedGitVersion() {
|
|
5
|
+
try { return JSON.parse(fs.readFileSync(new URL('./policy.json',import.meta.url),'utf8')).git.version; }
|
|
6
|
+
catch { return ''; }
|
|
7
|
+
}
|
|
5
8
|
|
|
6
9
|
export function gitCandidates(env = process.env) {
|
|
7
10
|
if (env.ALIPAY_GIT_BASH) return [env.ALIPAY_GIT_BASH];
|
|
11
|
+
const managedVersion = env.LOCALAPPDATA ? managedGitVersion() : '';
|
|
8
12
|
const candidates = [
|
|
9
13
|
path.win32.join(env.ProgramFiles || 'C:/Program Files', 'Git/bin/bash.exe'),
|
|
10
14
|
...(env.LOCALAPPDATA ? [path.win32.join(env.LOCALAPPDATA, 'Programs/Git/bin/bash.exe'),
|
|
11
|
-
path.win32.join(env.LOCALAPPDATA, `Programs/Git-Aipay-${
|
|
15
|
+
...(managedVersion ? [path.win32.join(env.LOCALAPPDATA, `Programs/Git-Aipay-${managedVersion}/bin/bash.exe`)] : [])] : [])
|
|
12
16
|
];
|
|
13
17
|
const search = Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] || '';
|
|
14
18
|
for (const directory of search.split(';').filter(p => path.win32.isAbsolute(p))) {
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
-
import {
|
|
4
|
+
import {loadPolicy,run,fail} from './preflight.mjs';
|
|
5
5
|
import {findGitBash} from './git-discovery.mjs';
|
|
6
6
|
import {powershell,psEnv} from './host.mjs';
|
|
7
7
|
|
|
8
8
|
export function installGitOfficial({env=process.env,execute=run,exists=fs.existsSync,
|
|
9
9
|
read=fs.readFileSync,remove=fs.rmSync,discover=findGitBash,root,
|
|
10
|
-
signatureCommand=powershell,beforeAttempt=()=>{}}={}) {
|
|
11
|
-
const asset=policy.git;
|
|
10
|
+
signatureCommand=powershell,beforeAttempt=()=>{},asset=loadPolicy('Git Bash').git}={}) {
|
|
12
11
|
if(!root || !env.LOCALAPPDATA || !path.win32.isAbsolute(env.LOCALAPPDATA) ||
|
|
13
12
|
!/^https:\/\/github\.com\/git-for-windows\/git\/releases\/download\/v[0-9.]+\.windows\.[0-9]+\/Git-[0-9.]+-64-bit\.exe$/u.test(asset.url) ||
|
|
14
13
|
!/^[a-f0-9]{64}$/u.test(asset.sha256)) fail('GIT_ASSET_INVALID','Git Bash');
|
|
@@ -5,7 +5,18 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import {fileURLToPath} from 'node:url';
|
|
6
6
|
|
|
7
7
|
export const directory = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
export
|
|
8
|
+
export function loadPolicy(dependency, read = fs.readFileSync) {
|
|
9
|
+
try {
|
|
10
|
+
const policy = JSON.parse(read(path.join(directory,'policy.json'),'utf8'));
|
|
11
|
+
const asset = dependency === 'Git Bash' ? policy?.git : policy?.jq;
|
|
12
|
+
if (!asset || typeof asset !== 'object' || typeof asset.url !== 'string' ||
|
|
13
|
+
typeof asset.sha256 !== 'string' || (dependency === 'Git Bash' && typeof asset.version !== 'string')) {
|
|
14
|
+
throw new Error('incomplete policy');
|
|
15
|
+
}
|
|
16
|
+
return policy;
|
|
17
|
+
}
|
|
18
|
+
catch { fail('POLICY_UNAVAILABLE',dependency); }
|
|
19
|
+
}
|
|
9
20
|
const slash = p => p.replaceAll('\\','/');
|
|
10
21
|
let invocationContext;
|
|
11
22
|
import {fail, run, checkWritable} from './host.mjs';
|
|
@@ -69,7 +80,7 @@ export function locateJq() {
|
|
|
69
80
|
}
|
|
70
81
|
export function locate() {
|
|
71
82
|
if(process.platform!=='win32') fail('WINDOWS_ADAPTER_REQUIRES_WINDOWS');
|
|
72
|
-
if(Number(process.versions.node.split('.')[0])<
|
|
83
|
+
if(Number(process.versions.node.split('.')[0])<18) fail('DEPENDENCY_VERSION','Node');
|
|
73
84
|
requireWindowsX64Node();
|
|
74
85
|
const foundGit=findGitBash();
|
|
75
86
|
if(!foundGit) fail('DEPENDENCY_MISSING','Git Bash');
|
|
@@ -7,7 +7,7 @@ import {findGitBash} from './git-discovery.mjs';
|
|
|
7
7
|
import {findWinget} from './winget.mjs';
|
|
8
8
|
import {installGitOfficial} from './git-install.mjs';
|
|
9
9
|
import {powershell} from './host.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import {loadPolicy,run,stateRoot,readState,saveState,withLock,ensureReady,locateJq,fail,report} from './preflight.mjs';
|
|
11
11
|
|
|
12
12
|
export function claimPreparation(state,stage,{source='official',repair=false,authorized=false}={}) {
|
|
13
13
|
if(!authorized) fail('PREPARATION_AUTHORIZATION_REQUIRED');
|
|
@@ -65,7 +65,7 @@ export function prepareDependencies(state,{authorized=false,repair=false,save=()
|
|
|
65
65
|
gitReady=()=>findGitBash(),jqReady=()=>locateJq(),
|
|
66
66
|
cliReady=()=>{const cli=path.join(os.homedir(),'.local/bin/alipay-cli.exe');return fs.existsSync(cli)&&run(cli,['version']).status===0;},
|
|
67
67
|
installWinget=installGitWithWinget,installOfficial=installGitOfficial,
|
|
68
|
-
prepareJq=(beforeAttempt)=>{requireAssetDownloader();beforeAttempt();installAsset('jq',
|
|
68
|
+
prepareJq=(beforeAttempt)=>{const asset=loadPolicy('jq').jq;requireAssetDownloader();beforeAttempt();installAsset('jq',asset);},
|
|
69
69
|
prepareCli=(beforeAttempt)=>{powershell();beforeAttempt();if(refreshWindowsCli()!=='READY')fail('CLI_INSTALL_FAILED','alipay-cli');}
|
|
70
70
|
}={}) {
|
|
71
71
|
if(!authorized) fail('PREPARATION_AUTHORIZATION_REQUIRED');
|
|
@@ -6,12 +6,15 @@ const prefix = 'ALIPAY_AIPAY_INTERNAL:';
|
|
|
6
6
|
|
|
7
7
|
// Optional tool failures do not prevent independent project code development.
|
|
8
8
|
function codeOnlyFailure(error) {
|
|
9
|
+
if (error.reason === 'SHELL_TRANSPORT_FAILED' && error.dependency !== 'Node') return true;
|
|
10
|
+
if (error.reason === 'NOT_GIT_BASH' && error.dependency === 'ALIPAY_GIT_BASH') return true;
|
|
11
|
+
if (error.dependency === 'Node') return false;
|
|
9
12
|
const tools = ['Git Bash', 'git', 'jq', 'alipay-cli', 'cli', 'curl', 'PowerShell'];
|
|
10
13
|
if (!tools.includes(error.dependency)) return false;
|
|
11
14
|
return ['DEPENDENCY_MISSING', 'DEPENDENCY_VERSION', 'EXECUTION_FAILED',
|
|
12
15
|
'WINGET_UNAVAILABLE', 'GIT_INSTALL_FAILED', 'GIT_VERIFY_FAILED', 'CLI_INSTALL_FAILED',
|
|
13
16
|
'DOWNLOAD_FAILED', 'INTEGRITY_FAILED', 'SIGNATURE_FAILED', 'RETRY_BUDGET_EXHAUSTED',
|
|
14
|
-
'USER_SCOPE_REQUIRED'].includes(error.reason);
|
|
17
|
+
'USER_SCOPE_REQUIRED', 'POLICY_UNAVAILABLE'].includes(error.reason);
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
// This is a routing result, never proof of a successful payment or onboarding.
|
|
@@ -34,11 +34,11 @@ stdout 为空、原始安装器输出不对客,只映射根协议的唯一刷
|
|
|
34
34
|
|
|
35
35
|
CLI 刷新策略与非 Windows 保持同一会话预算,不锁定 0.1.7 或其哈希;Windows 交由正式 PowerShell 安装器选择版本。jq 优先复用能通过版本和实际 filter 检查的既有原生工具;缺失或不可用时安装固定 1.7.1,官方 HTTPS 资产和 SHA-256 位于 `scripts/windows/policy.json`。Node 优先复用当前可运行的 Windows x64 Node >=18;缺失时由根启动器的 PowerShell 引导准备官方 LTS 用户级 ZIP,SHA-256 验证后返回绝对可执行路径,首次一次、明确修复最多一次。Git Bash 从常见目录和本机 PATH 的 Git 安装布局定位,也可由 `ALIPAY_GIT_BASH` 指定绝对路径;必须验证 Git for Windows 版本、cygpath 和 Bash 的 MINGW/MSYS 平台,不使用 System32/WSL Bash,显式指定错误时停止而不偷偷换工具。
|
|
36
36
|
|
|
37
|
-
初次缺 Git Bash、jq 或本机 CLI 无法启动且当前任务授权准备时,按 Git Bash → jq → 必要的 CLI 修复 → 完整复检执行。CLI 已可用时不重复安装。Git 优先复用已有可运行的 Git for Windows;缺失时优先调用用户级 winget,无法运行或未通过复检时使用 `scripts/windows/policy.json` 固定的 Git for Windows 官方 x64 安装包(当前 2.56.0.2)。官方包下载后先校验固定 SHA-256,再由系统 PowerShell 验证 Authenticode 有效,之后安装到 `%LOCALAPPDATA%/Programs/Git-Aipay-<version>`,并实测 git、bash、cygpath
|
|
37
|
+
初次缺 Git Bash、jq 或本机 CLI 无法启动且当前任务授权准备时,按 Git Bash → jq → 必要的 CLI 修复 → 完整复检执行。CLI 已可用时不重复安装。Git 优先复用已有可运行的 Git for Windows;缺失时优先调用用户级 winget,无法运行或未通过复检时使用 `scripts/windows/policy.json` 固定的 Git for Windows 官方 x64 安装包(当前 2.56.0.2)。官方包下载后先校验固定 SHA-256,再由系统 PowerShell 验证 Authenticode 有效,之后安装到 `%LOCALAPPDATA%/Programs/Git-Aipay-<version>`,并实测 git、bash、cygpath。下载或校验失败不运行安装包。两种来源均不可用时按根意图返回 CODE_ONLY 或 BLOCKED,展示原因及官方手工安装路径,不静默跳站点。
|
|
38
38
|
|
|
39
|
-
jq 使用固定 1.7.1 官方资产和 SHA-256,已有健康版本直接复用;CLI 仍使用正式 PowerShell 安装器选择当前版本。每个依赖和 Git 的两种安装来源分别记录实际开始的安装尝试,确认来源前的只读探测不消耗预算;Git 的 winget
|
|
39
|
+
jq 使用固定 1.7.1 官方资产和 SHA-256,已有健康版本直接复用;CLI 仍使用正式 PowerShell 安装器选择当前版本。每个依赖和 Git 的两种安装来源分别记录实际开始的安装尝试,确认来源前的只读探测不消耗预算;Git 的 winget 失败可在同次准备中尝试官方固定包。初次每个来源最多一次,用户明确修复后每个来源最多再一次;失败阶段可续跑,已健康的阶段不重装。状态记录不含业务材料;复检始终只读且不消耗安装预算。可选工具的网络、签名或完整性失败按第 21 行分流;实际存储故障仍为 BLOCKED。不轮换未经验证的镜像、使用 WSL、提权或修改执行策略。
|
|
40
40
|
|
|
41
|
-
环境诊断和次数保存在 `%LOCALAPPDATA%/alipay-aipay-pre/windows/environment.json`;旧版全局次数无法归属具体安装阶段,迁移时保留阻断原因并从新版本的有限分阶段预算重新计数。锁短时占用或状态损坏时,完整工具检查通过仍可 READY
|
|
41
|
+
环境诊断和次数保存在 `%LOCALAPPDATA%/alipay-aipay-pre/windows/environment.json`;旧版全局次数无法归属具体安装阶段,迁移时保留阻断原因并从新版本的有限分阶段预算重新计数。锁短时占用或状态损坏时,完整工具检查通过仍可 READY;自动安装保持阻断,不删除锁或状态绕过预算。CODE_ONLY 的恢复使用第 23 行 `windows-entry`;以下仅适用于 BLOCKED。用户修好依赖后,只读复检可再次执行,无论此前安装预算是否用尽:
|
|
42
42
|
|
|
43
43
|
```text
|
|
44
44
|
node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" env retry --repair-confirmed true --root-intent "<ROOT_INTENT>" --agent-platform "<AGENT_PLATFORM>" --session-id "<SESSION_ID>" --windows-route BLOCKED
|
|
@@ -52,7 +52,7 @@ node "<SKILL_DIR>/references/normal/scripts/runtime.mjs" env prepare --authorize
|
|
|
52
52
|
|
|
53
53
|
如果之前只是缺少授权或没有找到安装来源,尚未实际尝试该阶段,则使用同一命令但省略 `--repair-confirmed true`,继续首次准备;不能把新授权当成已消耗预算后的额外重试许可。
|
|
54
54
|
|
|
55
|
-
仅退出 0、唯一 `CLI_ENV:READY` 和唯一根意图匹配的 `WINDOWS_ROUTE=READY`
|
|
55
|
+
仅退出 0、唯一 `CLI_ENV:READY` 和唯一根意图匹配的 `WINDOWS_ROUTE=READY` 可恢复沙箱及开通能力。BLOCKED 恢复命令的其他结果保持 BLOCKED,说明具体失败原因;不重建会话、重复计数或重放业务操作。CODE_ONLY 按第 23 行重新取得入口分流。用户明确选择站点或平台执行映射不支持时,可走原 LEGACY 分支;可恢复的依赖失败不能自动转站点。
|
|
56
56
|
|
|
57
57
|
## 路径、权限与进程
|
|
58
58
|
|