@canonmsg/codex-plugin 0.18.3 → 0.18.4
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/dist/host.js +31 -24
- package/package.json +3 -2
- package/dist/outbox.d.ts +0 -83
- package/dist/outbox.js +0 -132
package/dist/host.js
CHANGED
|
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { dirname } from 'node:path';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
7
|
import { getCodexImagePath, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
8
|
+
import { buildTurnArtifactContextLine, ensureTurnArtifactDir, flushTurnArtifacts, } from '@canonmsg/coding-agent-host';
|
|
8
9
|
import { RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildPlanApprovalRequest, resolveQuestionAllowOther, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, ExecutionEnvironmentError, CanonClient, CanonStream, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfileLock, initRTDBAuth, buildLocalRuntimeId, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, normalizeTurnMetadata, parseRuntimeCardV1, prepareConversationEnvironment, loadHostSessionConfig, releaseConversationEnvironment, resolveCanonAgent, CanonApiError, sendMessageWithRetry, sendMessageWithRetryChunked, shouldTriggerAgentTurn, saveRuntimeSessionState, buildBoundedTurnTrail, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
9
10
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
10
11
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
@@ -16,7 +17,6 @@ import { detectCodexCliVersion } from './codex-cli-version.js';
|
|
|
16
17
|
import { buildCodexModelGuardMessage, formatCodexTurnFailure, isRecoverableCodexThreadError, } from './error-format.js';
|
|
17
18
|
import { startCodexStreamInBackground } from './host-lifecycle.js';
|
|
18
19
|
import { createCodexControlPoller } from './control-channel.js';
|
|
19
|
-
import { buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
|
|
20
20
|
import { runCli } from './cli-entry.js';
|
|
21
21
|
import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
22
22
|
import { beginCommandBlock, claimCommandBlock, createCommandBlockTracker, } from './turn-activity.js';
|
|
@@ -889,7 +889,6 @@ export async function main() {
|
|
|
889
889
|
};
|
|
890
890
|
sessions.set(conversationId, session);
|
|
891
891
|
await controlPoller.baseline([conversationId]);
|
|
892
|
-
ensureOutboxDir(sessionCwd).catch((error) => console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, error));
|
|
893
892
|
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
894
893
|
writeState(session);
|
|
895
894
|
writeTurn(session);
|
|
@@ -1289,7 +1288,6 @@ export async function main() {
|
|
|
1289
1288
|
activeSelfContextId,
|
|
1290
1289
|
provenance: hydrated.provenance,
|
|
1291
1290
|
replyContext,
|
|
1292
|
-
sessionContextLines: [buildOutboxContextLine(session.cwd)],
|
|
1293
1291
|
message: input.message,
|
|
1294
1292
|
});
|
|
1295
1293
|
if (session.running && deliveryIntent === 'interrupt') {
|
|
@@ -1302,16 +1300,13 @@ export async function main() {
|
|
|
1302
1300
|
}
|
|
1303
1301
|
enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted, imagePaths, mediaAddDirs, planCommand.planMode);
|
|
1304
1302
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
* later turn.
|
|
1309
|
-
*/
|
|
1310
|
-
async function flushSessionOutbox(session) {
|
|
1303
|
+
async function routeSessionArtifacts(session, artifactDir) {
|
|
1304
|
+
if (!artifactDir)
|
|
1305
|
+
return;
|
|
1311
1306
|
const logPrefix = `[canon-codex] [${session.conversationId.slice(0, 8)}]`;
|
|
1312
1307
|
try {
|
|
1313
|
-
const result = await
|
|
1314
|
-
|
|
1308
|
+
const result = await flushTurnArtifacts({
|
|
1309
|
+
artifactDir,
|
|
1315
1310
|
send: (file) => sendMediaFileMessage(client, session.conversationId, file.path, '', {
|
|
1316
1311
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1317
1312
|
metadata: {
|
|
@@ -1325,20 +1320,20 @@ export async function main() {
|
|
|
1325
1320
|
}),
|
|
1326
1321
|
});
|
|
1327
1322
|
for (const entry of result.sent) {
|
|
1328
|
-
console.error(`${logPrefix}
|
|
1329
|
-
+ (entry.removeFailed ? '
|
|
1323
|
+
console.error(`${logPrefix} Routed artifact ${entry.file.fileName} (${entry.messageId})`
|
|
1324
|
+
+ (entry.removeFailed ? '; removal failed, so it may resend later' : ''));
|
|
1330
1325
|
}
|
|
1331
1326
|
for (const failure of result.failed) {
|
|
1332
|
-
console.error(`${logPrefix}
|
|
1327
|
+
console.error(`${logPrefix} Artifact upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
|
|
1333
1328
|
}
|
|
1334
1329
|
for (const skipped of result.skipped) {
|
|
1335
|
-
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
|
|
1336
|
-
console.error(`${logPrefix}
|
|
1330
|
+
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap' || skipped.reason === 'unsupported') {
|
|
1331
|
+
console.error(`${logPrefix} Artifact skipped ${skipped.fileName} (${skipped.reason})`);
|
|
1337
1332
|
}
|
|
1338
1333
|
}
|
|
1339
1334
|
}
|
|
1340
1335
|
catch (error) {
|
|
1341
|
-
console.error(`${logPrefix}
|
|
1336
|
+
console.error(`${logPrefix} Artifact routing failed:`, error instanceof Error ? error.message : error);
|
|
1342
1337
|
}
|
|
1343
1338
|
}
|
|
1344
1339
|
async function runNextTurn(session) {
|
|
@@ -1366,7 +1361,19 @@ export async function main() {
|
|
|
1366
1361
|
// Status-only seed: 'thinking' renders as a working filament row on the
|
|
1367
1362
|
// clients; text here would be bubbled as speech (v4 register rule).
|
|
1368
1363
|
writeCodexStreaming(session, '', 'thinking');
|
|
1364
|
+
let artifactDir = null;
|
|
1365
|
+
let artifactsRouted = false;
|
|
1366
|
+
const routeArtifactsOnce = async () => {
|
|
1367
|
+
if (artifactsRouted)
|
|
1368
|
+
return;
|
|
1369
|
+
artifactsRouted = true;
|
|
1370
|
+
await routeSessionArtifacts(session, artifactDir);
|
|
1371
|
+
};
|
|
1369
1372
|
try {
|
|
1373
|
+
const turnId = session.currentTurnId ?? randomUUID();
|
|
1374
|
+
session.currentTurnId = turnId;
|
|
1375
|
+
artifactDir = await ensureTurnArtifactDir(session.cwd, turnId);
|
|
1376
|
+
const turnPrompt = `${nextTurn.prompt}\n\n${buildTurnArtifactContextLine(artifactDir)}`;
|
|
1370
1377
|
const modelGuard = buildCodexModelGuardMessage(session.state.model, codexCliStatus);
|
|
1371
1378
|
if (modelGuard) {
|
|
1372
1379
|
throw new ExecutionEnvironmentError(modelGuard, modelGuard);
|
|
@@ -1470,7 +1477,7 @@ export async function main() {
|
|
|
1470
1477
|
clearStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, session.environment.mode);
|
|
1471
1478
|
session.adapter.clearThreadId();
|
|
1472
1479
|
};
|
|
1473
|
-
const runTurnOnce = () => session.adapter.runTurn(
|
|
1480
|
+
const runTurnOnce = () => session.adapter.runTurn(turnPrompt, handleCodexEvent, logCodexLine, turnImagePaths, turnMediaAddDirs, {
|
|
1474
1481
|
planMode: nextTurn.planMode,
|
|
1475
1482
|
onServerRequest: (request) => handleCodexServerRequest(session, request),
|
|
1476
1483
|
});
|
|
@@ -1487,12 +1494,8 @@ export async function main() {
|
|
|
1487
1494
|
if (result.threadId && !session.resetRequested) {
|
|
1488
1495
|
saveStoredThreadId(runtimeId, agentId, session.conversationId, session.environment.baseCwd, result.threadId, session.environment.mode, session.policyFingerprint);
|
|
1489
1496
|
}
|
|
1490
|
-
// Turn-end outbox flush — media lands before the final text reply.
|
|
1491
|
-
// Interrupted turns keep their files for the next completed turn.
|
|
1492
|
-
if (!result.interrupted) {
|
|
1493
|
-
await flushSessionOutbox(session);
|
|
1494
|
-
}
|
|
1495
1497
|
if (!result.interrupted && result.finalMessage && nextTurn.planMode) {
|
|
1498
|
+
await routeArtifactsOnce();
|
|
1496
1499
|
const planApproval = buildPlanApprovalRequest(session.currentTurnId ?? randomUUID(), 'Plan ready for review.', {
|
|
1497
1500
|
responseUserId: ownerId ?? undefined,
|
|
1498
1501
|
title: 'Codex Plan',
|
|
@@ -1514,6 +1517,7 @@ export async function main() {
|
|
|
1514
1517
|
if (isRecoverableCodexThreadError(result.errorText)) {
|
|
1515
1518
|
clearStoredThread();
|
|
1516
1519
|
}
|
|
1520
|
+
await routeArtifactsOnce();
|
|
1517
1521
|
const turnTrail = buildFinalTurnTrail(session);
|
|
1518
1522
|
await sendMessageWithRetryChunked(client, session.conversationId, result.finalMessage, {
|
|
1519
1523
|
messageId: buildCodexMessageId(session, 'final'),
|
|
@@ -1531,6 +1535,7 @@ export async function main() {
|
|
|
1531
1535
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
1532
1536
|
}
|
|
1533
1537
|
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
1538
|
+
await routeArtifactsOnce();
|
|
1534
1539
|
const userVisibleError = formatCodexTurnFailure(result.errorText);
|
|
1535
1540
|
session.state.lastError = userVisibleError;
|
|
1536
1541
|
writeState(session);
|
|
@@ -1553,6 +1558,7 @@ export async function main() {
|
|
|
1553
1558
|
await handoffFinalMessage(session.conversationId);
|
|
1554
1559
|
}
|
|
1555
1560
|
else if (!result.interrupted) {
|
|
1561
|
+
await routeArtifactsOnce();
|
|
1556
1562
|
await handoffFinalMessage(session.conversationId);
|
|
1557
1563
|
}
|
|
1558
1564
|
else if (result.interrupted) {
|
|
@@ -1572,6 +1578,7 @@ export async function main() {
|
|
|
1572
1578
|
: `The Codex host failed during the turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
1573
1579
|
session.state.lastError = message;
|
|
1574
1580
|
writeState(session);
|
|
1581
|
+
await routeArtifactsOnce();
|
|
1575
1582
|
await sendMessageWithRetryChunked(client, session.conversationId, message, {
|
|
1576
1583
|
messageId: buildCodexMessageId(session, 'failure'),
|
|
1577
1584
|
...(session.activeSelfContextId
|
|
@@ -1817,7 +1824,7 @@ export async function main() {
|
|
|
1817
1824
|
{
|
|
1818
1825
|
id: 'mediaOut',
|
|
1819
1826
|
label: 'Media out',
|
|
1820
|
-
value: '
|
|
1827
|
+
value: 'Explicit turn artifact directory',
|
|
1821
1828
|
},
|
|
1822
1829
|
],
|
|
1823
1830
|
execution: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.4",
|
|
4
4
|
"description": "Canon host integration for Codex CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk",
|
|
24
|
+
"prepare:workspace-deps": "node ../../scripts/run-workspace-prep.mjs ../core ../agent-sdk ../coding-agent-host",
|
|
25
25
|
"build": "npm run prepare:workspace-deps && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
26
26
|
"dev": "npm run prepare:workspace-deps && tsc --watch",
|
|
27
27
|
"smoke": "node scripts/smoke-test.mjs",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@canonmsg/agent-sdk": "^3.2.3",
|
|
33
|
+
"@canonmsg/coding-agent-host": "^0.2.0",
|
|
33
34
|
"@canonmsg/core": "^2.6.0"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
package/dist/outbox.d.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Turn-end media outbox for Canon coding hosts.
|
|
3
|
-
*
|
|
4
|
-
* The host advertises a per-conversation outbox directory inside the session
|
|
5
|
-
* working directory (`<cwd>/.canon/outbox/`). When the runtime wants a file
|
|
6
|
-
* (screenshot, plot, artifact) delivered to the Canon conversation it writes
|
|
7
|
-
* the file there — an explicit channel, never inferred from reply prose. At
|
|
8
|
-
* turn end the host scans the outbox, uploads each regular file as a Canon
|
|
9
|
-
* media attachment, and removes files that were delivered. Failed uploads
|
|
10
|
-
* stay in place for a later turn; subdirectories, symlinks, and dotfiles are
|
|
11
|
-
* ignored.
|
|
12
|
-
*
|
|
13
|
-
* This module is intentionally identical in packages/claude-code-plugin and
|
|
14
|
-
* packages/codex-plugin — keep both copies in sync (future consolidation
|
|
15
|
-
* candidate).
|
|
16
|
-
*/
|
|
17
|
-
export declare const OUTBOX_MAX_FILES_PER_TURN = 8;
|
|
18
|
-
export declare const OUTBOX_MAX_FILE_BYTES: number;
|
|
19
|
-
export interface OutboxFile {
|
|
20
|
-
path: string;
|
|
21
|
-
fileName: string;
|
|
22
|
-
sizeBytes: number;
|
|
23
|
-
}
|
|
24
|
-
export type OutboxSkipReason = 'not-regular-file' | 'hidden' | 'too-large' | 'file-cap';
|
|
25
|
-
export interface OutboxScanResult {
|
|
26
|
-
/** Regular files eligible for upload this turn, ordered by file name. */
|
|
27
|
-
files: OutboxFile[];
|
|
28
|
-
skipped: Array<{
|
|
29
|
-
fileName: string;
|
|
30
|
-
reason: OutboxSkipReason;
|
|
31
|
-
}>;
|
|
32
|
-
}
|
|
33
|
-
export interface OutboxFlushResult {
|
|
34
|
-
sent: Array<{
|
|
35
|
-
file: OutboxFile;
|
|
36
|
-
messageId: string;
|
|
37
|
-
removeFailed?: true;
|
|
38
|
-
}>;
|
|
39
|
-
failed: Array<{
|
|
40
|
-
file: OutboxFile;
|
|
41
|
-
error: string;
|
|
42
|
-
}>;
|
|
43
|
-
skipped: OutboxScanResult['skipped'];
|
|
44
|
-
}
|
|
45
|
-
export declare function resolveOutboxDir(sessionCwd: string): string;
|
|
46
|
-
/**
|
|
47
|
-
* Create the outbox directory for a session and drop a `.gitignore` into the
|
|
48
|
-
* host-managed `.canon/` dir (only when absent) so outbox state never shows
|
|
49
|
-
* up as untracked dirt inside project checkouts or conversation worktrees.
|
|
50
|
-
*/
|
|
51
|
-
export declare function ensureOutboxDir(sessionCwd: string): Promise<string>;
|
|
52
|
-
/**
|
|
53
|
-
* The one terse paragraph injected into the runtime's Canon context so the
|
|
54
|
-
* agent knows the outbox exists. Hosts may append their own extra sentence
|
|
55
|
-
* (e.g. an immediate-send tool) but must not paraphrase the convention.
|
|
56
|
-
*/
|
|
57
|
-
export declare function buildOutboxContextLine(sessionCwd: string): string;
|
|
58
|
-
/**
|
|
59
|
-
* Discover the outbox files eligible for upload this turn. A missing outbox
|
|
60
|
-
* directory is an empty result. Entries are ordered by file name so multi-file
|
|
61
|
-
* turns deliver deterministically; everything past the per-turn cap (or over
|
|
62
|
-
* the size cap) is left in place and reported as skipped.
|
|
63
|
-
*/
|
|
64
|
-
export declare function scanOutbox(outboxDir: string, options?: {
|
|
65
|
-
maxFiles?: number;
|
|
66
|
-
maxFileBytes?: number;
|
|
67
|
-
}): Promise<OutboxScanResult>;
|
|
68
|
-
/**
|
|
69
|
-
* Upload-and-consume pass over the outbox. Each eligible file is handed to
|
|
70
|
-
* `send`; on success the file is removed (consumed), on failure it is left in
|
|
71
|
-
* place for a later turn. A failed removal after a successful send is still
|
|
72
|
-
* reported as sent (flagged `removeFailed`) so callers can warn about a
|
|
73
|
-
* potential duplicate next turn instead of re-reporting a delivery failure.
|
|
74
|
-
*/
|
|
75
|
-
export declare function flushOutbox(input: {
|
|
76
|
-
outboxDir: string;
|
|
77
|
-
send: (file: OutboxFile) => Promise<{
|
|
78
|
-
messageId: string;
|
|
79
|
-
}>;
|
|
80
|
-
maxFiles?: number;
|
|
81
|
-
maxFileBytes?: number;
|
|
82
|
-
remove?: (path: string) => Promise<void>;
|
|
83
|
-
}): Promise<OutboxFlushResult>;
|
package/dist/outbox.js
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Turn-end media outbox for Canon coding hosts.
|
|
3
|
-
*
|
|
4
|
-
* The host advertises a per-conversation outbox directory inside the session
|
|
5
|
-
* working directory (`<cwd>/.canon/outbox/`). When the runtime wants a file
|
|
6
|
-
* (screenshot, plot, artifact) delivered to the Canon conversation it writes
|
|
7
|
-
* the file there — an explicit channel, never inferred from reply prose. At
|
|
8
|
-
* turn end the host scans the outbox, uploads each regular file as a Canon
|
|
9
|
-
* media attachment, and removes files that were delivered. Failed uploads
|
|
10
|
-
* stay in place for a later turn; subdirectories, symlinks, and dotfiles are
|
|
11
|
-
* ignored.
|
|
12
|
-
*
|
|
13
|
-
* This module is intentionally identical in packages/claude-code-plugin and
|
|
14
|
-
* packages/codex-plugin — keep both copies in sync (future consolidation
|
|
15
|
-
* candidate).
|
|
16
|
-
*/
|
|
17
|
-
import { mkdir, readdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
export const OUTBOX_MAX_FILES_PER_TURN = 8;
|
|
20
|
-
export const OUTBOX_MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
21
|
-
export function resolveOutboxDir(sessionCwd) {
|
|
22
|
-
return join(sessionCwd, '.canon', 'outbox');
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Create the outbox directory for a session and drop a `.gitignore` into the
|
|
26
|
-
* host-managed `.canon/` dir (only when absent) so outbox state never shows
|
|
27
|
-
* up as untracked dirt inside project checkouts or conversation worktrees.
|
|
28
|
-
*/
|
|
29
|
-
export async function ensureOutboxDir(sessionCwd) {
|
|
30
|
-
const dir = resolveOutboxDir(sessionCwd);
|
|
31
|
-
await mkdir(dir, { recursive: true });
|
|
32
|
-
try {
|
|
33
|
-
// The `*` pattern ignores everything under .canon, including this file.
|
|
34
|
-
await writeFile(join(sessionCwd, '.canon', '.gitignore'), '*\n', { flag: 'wx' });
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// Already present (or unwritable) — never block session startup on it.
|
|
38
|
-
}
|
|
39
|
-
return dir;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* The one terse paragraph injected into the runtime's Canon context so the
|
|
43
|
-
* agent knows the outbox exists. Hosts may append their own extra sentence
|
|
44
|
-
* (e.g. an immediate-send tool) but must not paraphrase the convention.
|
|
45
|
-
*/
|
|
46
|
-
export function buildOutboxContextLine(sessionCwd) {
|
|
47
|
-
const maxMb = Math.floor(OUTBOX_MAX_FILE_BYTES / (1024 * 1024));
|
|
48
|
-
return `Media outbox: to deliver a file (screenshot, plot, artifact) to this Canon conversation, write it into ${resolveOutboxDir(sessionCwd)} — when your turn ends the host uploads each regular file there as a chat attachment and then deletes it. Limits: ${OUTBOX_MAX_FILES_PER_TURN} files per turn and ${maxMb}MB per file; subdirectories, symlinks, and dotfiles are ignored.`;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Discover the outbox files eligible for upload this turn. A missing outbox
|
|
52
|
-
* directory is an empty result. Entries are ordered by file name so multi-file
|
|
53
|
-
* turns deliver deterministically; everything past the per-turn cap (or over
|
|
54
|
-
* the size cap) is left in place and reported as skipped.
|
|
55
|
-
*/
|
|
56
|
-
export async function scanOutbox(outboxDir, options) {
|
|
57
|
-
const maxFiles = options?.maxFiles ?? OUTBOX_MAX_FILES_PER_TURN;
|
|
58
|
-
const maxFileBytes = options?.maxFileBytes ?? OUTBOX_MAX_FILE_BYTES;
|
|
59
|
-
let entries;
|
|
60
|
-
try {
|
|
61
|
-
entries = await readdir(outboxDir, { withFileTypes: true });
|
|
62
|
-
}
|
|
63
|
-
catch (error) {
|
|
64
|
-
if (error.code === 'ENOENT') {
|
|
65
|
-
return { files: [], skipped: [] };
|
|
66
|
-
}
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
const files = [];
|
|
70
|
-
const skipped = [];
|
|
71
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
72
|
-
// `readdir` does not follow symlinks, so a symlinked file reports
|
|
73
|
-
// isSymbolicLink(), not isFile() — links and directories both land here.
|
|
74
|
-
if (!entry.isFile()) {
|
|
75
|
-
skipped.push({ fileName: entry.name, reason: 'not-regular-file' });
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (entry.name.startsWith('.')) {
|
|
79
|
-
skipped.push({ fileName: entry.name, reason: 'hidden' });
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
const path = join(outboxDir, entry.name);
|
|
83
|
-
const info = await stat(path);
|
|
84
|
-
if (info.size > maxFileBytes) {
|
|
85
|
-
skipped.push({ fileName: entry.name, reason: 'too-large' });
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (files.length >= maxFiles) {
|
|
89
|
-
skipped.push({ fileName: entry.name, reason: 'file-cap' });
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
files.push({ path, fileName: entry.name, sizeBytes: info.size });
|
|
93
|
-
}
|
|
94
|
-
return { files, skipped };
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Upload-and-consume pass over the outbox. Each eligible file is handed to
|
|
98
|
-
* `send`; on success the file is removed (consumed), on failure it is left in
|
|
99
|
-
* place for a later turn. A failed removal after a successful send is still
|
|
100
|
-
* reported as sent (flagged `removeFailed`) so callers can warn about a
|
|
101
|
-
* potential duplicate next turn instead of re-reporting a delivery failure.
|
|
102
|
-
*/
|
|
103
|
-
export async function flushOutbox(input) {
|
|
104
|
-
const { files, skipped } = await scanOutbox(input.outboxDir, {
|
|
105
|
-
...(input.maxFiles != null ? { maxFiles: input.maxFiles } : {}),
|
|
106
|
-
...(input.maxFileBytes != null ? { maxFileBytes: input.maxFileBytes } : {}),
|
|
107
|
-
});
|
|
108
|
-
const remove = input.remove ?? ((path) => unlink(path));
|
|
109
|
-
const sent = [];
|
|
110
|
-
const failed = [];
|
|
111
|
-
for (const file of files) {
|
|
112
|
-
let messageId;
|
|
113
|
-
try {
|
|
114
|
-
({ messageId } = await input.send(file));
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
failed.push({
|
|
118
|
-
file,
|
|
119
|
-
error: error instanceof Error ? error.message : String(error),
|
|
120
|
-
});
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
await remove(file.path);
|
|
125
|
-
sent.push({ file, messageId });
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
sent.push({ file, messageId, removeFailed: true });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return { sent, failed, skipped };
|
|
132
|
-
}
|