@myagentroam/node 0.11.35 → 0.11.37
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/application/execution-lifecycle.js +54 -0
- package/dist/application/node-lifecycle.js +104 -0
- package/dist/application/node-operation-admission.js +51 -0
- package/dist/application/node-operation-contracts.js +1 -0
- package/dist/application/runner-composition.js +22 -0
- package/dist/application/session-projection-composition.js +26 -0
- package/dist/application/workspace-application.js +61 -0
- package/dist/application/workspace-composition.js +21 -0
- package/dist/capabilities.js +2 -2
- package/dist/codex-app-server.js +1 -471
- package/dist/connection/authorization.js +26 -0
- package/dist/{service → connection}/direct-transfer-service.js +1 -1
- package/dist/connection/endpoints.js +32 -0
- package/dist/connector.js +144 -252
- package/dist/database.js +34 -14
- package/dist/git-askpass.js +1 -1
- package/dist/git-execution-helper.js +1 -1
- package/dist/hosting/hosted-conversation-file-identity.js +27 -0
- package/dist/hosting/hosted-conversation-file-operations.js +44 -0
- package/dist/hosting/hosted-conversation-node-service.js +974 -0
- package/dist/hosting/hosted-conversation-runs.js +599 -0
- package/dist/hosting/hosted-conversation-templates.js +280 -0
- package/dist/hosting/hosted-conversation-uploads.js +344 -0
- package/dist/hosting/hosted-conversation-watch.js +194 -0
- package/dist/hosting/runtime-contracts.js +1 -0
- package/dist/marctl-guidance.js +1 -1
- package/dist/runner/abstract-runner.js +4 -13
- package/dist/{claude-agent-sdk.js → runner/claude/claude-agent-sdk.js} +2 -2
- package/dist/runner/claude/conversation-parser.js +2 -1
- package/dist/runner/claude/managed-run-controller.js +6 -7
- package/dist/runner/claude/message-parsers.js +2 -1
- package/dist/runner/claude/native-transcript.js +2 -1
- package/dist/runner/claude/tool-normalizer.js +1 -1
- package/dist/runner/claude-code-runner.js +2 -2
- package/dist/runner/codex/codex-app-server.js +471 -0
- package/dist/runner/codex/conversation-parser.js +4 -2
- package/dist/runner/codex/managed-run-controller.js +7 -9
- package/dist/runner/codex/native-transcript.js +1 -1
- package/dist/runner/codex/tool-normalizer.js +1 -1
- package/dist/runner/codex-runner.js +9 -27
- package/dist/runner/contracts.js +1 -0
- package/dist/runner/mar-agent/conversation-normalizer.js +2 -2
- package/dist/runner/mar-agent/history-projection.js +1 -1
- package/dist/runner/mar-agent/hosted-conversation-runtime.js +6 -10
- package/dist/runner/mar-agent/managed-run-controller.js +85 -61
- package/dist/runner/mar-agent-runner.js +1 -1
- package/dist/runner/normalization.js +42 -0
- package/dist/runner/opencode/conversation-parser.js +1 -1
- package/dist/runner/opencode/managed-run-controller.js +6 -7
- package/dist/{opencode-server.js → runner/opencode/opencode-server.js} +2 -2
- package/dist/runner/opencode/tool-normalizer.js +1 -1
- package/dist/runner/opencode-runner.js +4 -5
- package/dist/runner/runner-registry.js +4 -1
- package/dist/runtime-state.js +48 -372
- package/dist/service/computer-use-enablement-service.js +1 -1
- package/dist/service/continuous-run-coordinator.js +52 -7
- package/dist/service/conversation-history-service.js +4 -4
- package/dist/service/conversation-hosting-capability-service.js +1 -1
- package/dist/service/conversation-hosting-enablement-service.js +1 -1
- package/dist/service/conversation-hosting-lifecycle-service.js +3 -3
- package/dist/service/conversation-hosting-maintenance-service.js +1 -1
- package/dist/service/conversation-observation.js +1 -1
- package/dist/service/conversation-segment-service.js +1 -1
- package/dist/service/external-session-resume-service.js +47 -27
- package/dist/service/fake-managed-run-service.js +3 -7
- package/dist/service/git-operation-service.js +1 -1
- package/dist/service/goal-continuation-coordinator.js +14 -7
- package/dist/service/mar-agent-model-transport-test-service.js +1 -1
- package/dist/service/native-session-watch-service.js +1 -1
- package/dist/service/node-control-channel.js +1 -5
- package/dist/service/node-request-service.js +1 -1
- package/dist/service/node-terminal-channel.js +2 -1
- package/dist/service/node-workspace-coordinator.js +3 -2
- package/dist/service/notification-mcp-bridge.js +6 -2
- package/dist/service/recent-run-projection-service.js +1 -1
- package/dist/service/restart-session-restorer.js +1 -1
- package/dist/service/run-event-service.js +49 -42
- package/dist/service/run-workbench-service.js +3 -3
- package/dist/service/runner-interaction-service.js +6 -5
- package/dist/service/runner-service.js +1 -1
- package/dist/service/session-recent-activity.js +2 -1
- package/dist/service/skill-directory-service.js +1 -1
- package/dist/service/skill-install-service.js +2 -2
- package/dist/service/terminal-service.js +1 -1
- package/dist/service/workbench-event-projection.js +3 -2
- package/dist/service/workbench-event-service.js +1 -1
- package/dist/service/workspace-service.js +3 -2
- package/dist/session/attachments.js +110 -0
- package/dist/session/contracts.js +1 -0
- package/dist/{util → session}/conversation-identity.js +1 -1
- package/dist/session/input-validation.js +15 -0
- package/dist/{service → session}/native-session-projection-service.js +40 -11
- package/dist/session/pagination.js +77 -0
- package/dist/{util → session}/runner-native-session-parsers.js +2 -1
- package/dist/session/runtime/approvals.js +90 -0
- package/dist/session/runtime/conversation-projection.js +310 -0
- package/dist/session/runtime/type-guards.js +19 -0
- package/dist/{service → session}/session-catalog-service.js +5 -4
- package/dist/{service → session}/session-command-service.js +3 -3
- package/dist/{service → session}/session-identity-service.js +1 -1
- package/dist/{service → session}/session-lifecycle-service.js +18 -7
- package/dist/{service → session}/session-message-service.js +6 -5
- package/dist/{service → session}/session-presentation-service.js +1 -1
- package/dist/{service → session}/session-query-service.js +2 -2
- package/dist/{service → session}/session-schedule-service.js +2 -2
- package/dist/util/records.js +3 -0
- package/dist/workspace/contracts.js +1 -0
- package/dist/workspace/file-index.js +534 -0
- package/dist/workspace/files.js +466 -0
- package/dist/workspace/git.js +991 -0
- package/dist/workspace/path-access.js +184 -0
- package/dist/workspace/utf8.js +12 -0
- package/dist/{service → workspace}/workspace-change-service.js +45 -21
- package/dist/{service → workspace}/workspace-code-navigation-service.js +6 -6
- package/dist/{service → workspace}/workspace-file-manager-service.js +1 -1
- package/dist/{service → workspace}/workspace-file-service.js +16 -16
- package/dist/{service → workspace}/workspace-git-exclude-service.js +1 -1
- package/dist/{service → workspace}/workspace-queue-workbench-service.js +3 -2
- package/dist/{service → workspace}/workspace-session-service.js +45 -40
- package/dist/{service → workspace}/workspace-upload-service.js +28 -28
- package/dist/{service → workspace}/workspace-watch-service.js +29 -28
- package/dist/{service → workspace}/workspace-workbench-service.js +4 -3
- package/package.json +4 -4
- package/dist/service/hosted-conversation-node-service.js +0 -2252
- package/dist/service/run-domain-state.js +0 -4
- package/dist/service/session-domain-state.js +0 -9
- package/dist/service/workspace-domain-state.js +0 -41
- package/dist/util/node-operation-parsers.js +0 -320
- package/dist/workspace.js +0 -2168
- /package/dist/{connector → application}/node-connector-options.js +0 -0
- /package/dist/{util → connection}/node-operation-data.js +0 -0
- /package/dist/{connector → connection}/node-operation-router.js +0 -0
- /package/dist/{service → hosting}/hosted-conversation-sandbox-profile.js +0 -0
- /package/dist/{service → hosting}/hosted-conversation-store.js +0 -0
- /package/dist/{util → mcp}/secret-environment.js +0 -0
- /package/dist/{claude-channel.js → runner/claude/claude-channel.js} +0 -0
- /package/dist/{util → runner}/personal-instructions.js +0 -0
- /package/dist/{util → runner}/runner-error.js +0 -0
- /package/dist/{service/conversation-work-process.js → session/runtime/work-process.js} +0 -0
- /package/dist/{service → session}/session-context-service.js +0 -0
- /package/dist/{util → workspace}/git-credential-client.js +0 -0
- /package/dist/{service → workspace}/workspace-content-search-service.js +0 -0
- /package/dist/{service → workspace}/workspace-watch-snapshot-service.js +0 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { marAgentRuntimeModelConfigurationSchema } from '@myagentroam/protocol';
|
|
4
|
+
import { assertMcpServerNames } from '../mcp/naming.js';
|
|
5
|
+
import { parseTemplateVersionManifest, templateContentHash, templateMcpConfigHash, templateManifestHash } from '../service/conversation-template-manifest.js';
|
|
6
|
+
const HOST_POLICY_VERSION = 1;
|
|
7
|
+
export class HostedConversationTemplates {
|
|
8
|
+
database;
|
|
9
|
+
store;
|
|
10
|
+
nodeId;
|
|
11
|
+
processExecutorFactory;
|
|
12
|
+
constructor(database, store, nodeId, processExecutorFactory) {
|
|
13
|
+
this.database = database;
|
|
14
|
+
this.store = store;
|
|
15
|
+
this.nodeId = nodeId;
|
|
16
|
+
this.processExecutorFactory = processExecutorFactory;
|
|
17
|
+
}
|
|
18
|
+
pathMounts(paths, version, generatedImagesDirectory, generatedImagesLogicalPath, temporaryDirectory) {
|
|
19
|
+
const mounts = [
|
|
20
|
+
{
|
|
21
|
+
logicalPath: '/workspace',
|
|
22
|
+
physicalPath: version.templateDirectory,
|
|
23
|
+
writable: false
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
logicalPath: '/workspace/inbox',
|
|
27
|
+
physicalPath: paths.uploadsDirectory,
|
|
28
|
+
writable: true
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
logicalPath: '/workspace/output',
|
|
32
|
+
physicalPath: paths.outputDirectory,
|
|
33
|
+
writable: true
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
if (temporaryDirectory !== undefined)
|
|
37
|
+
mounts.push({
|
|
38
|
+
logicalPath: '/tmp',
|
|
39
|
+
physicalPath: temporaryDirectory,
|
|
40
|
+
writable: true
|
|
41
|
+
});
|
|
42
|
+
if (generatedImagesDirectory !== undefined && generatedImagesLogicalPath !== undefined)
|
|
43
|
+
mounts.push({
|
|
44
|
+
logicalPath: generatedImagesLogicalPath,
|
|
45
|
+
physicalPath: generatedImagesDirectory,
|
|
46
|
+
writable: false
|
|
47
|
+
});
|
|
48
|
+
if (version.sourceWorkspacePath !== undefined)
|
|
49
|
+
mounts.push({
|
|
50
|
+
logicalPath: version.sourceWorkspacePath,
|
|
51
|
+
physicalPath: version.templateDirectory,
|
|
52
|
+
writable: false
|
|
53
|
+
}, {
|
|
54
|
+
logicalPath: join(version.sourceWorkspacePath, 'inbox'),
|
|
55
|
+
physicalPath: paths.uploadsDirectory,
|
|
56
|
+
writable: true
|
|
57
|
+
}, {
|
|
58
|
+
logicalPath: join(version.sourceWorkspacePath, 'output'),
|
|
59
|
+
physicalPath: paths.outputDirectory,
|
|
60
|
+
writable: true
|
|
61
|
+
});
|
|
62
|
+
return mounts;
|
|
63
|
+
}
|
|
64
|
+
processExecutor(paths, version, temporaryDirectory = paths.temporaryRootDirectory, generatedImagesDirectory, generatedImagesLogicalPath) {
|
|
65
|
+
return this.processExecutorFactory({
|
|
66
|
+
workspaceDirectory: version.templateDirectory,
|
|
67
|
+
sourceWorkspacePath: version.sourceWorkspacePath,
|
|
68
|
+
inboxDirectory: paths.uploadsDirectory,
|
|
69
|
+
outputDirectory: paths.outputDirectory,
|
|
70
|
+
...(generatedImagesDirectory === undefined || generatedImagesLogicalPath === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: { generatedImagesDirectory, generatedImagesLogicalPath }),
|
|
73
|
+
temporaryDirectory,
|
|
74
|
+
networkMode: version.draft.networkMode
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
latestAvailableTemplateVersionId(templateId) {
|
|
78
|
+
const row = this.database.raw
|
|
79
|
+
.prepare(`SELECT id FROM conversation_template_versions
|
|
80
|
+
WHERE template_id=? AND status='AVAILABLE'
|
|
81
|
+
ORDER BY version_number DESC,published_at DESC,id DESC
|
|
82
|
+
LIMIT 1`)
|
|
83
|
+
.get(templateId);
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (typeof row.id !== 'string')
|
|
87
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
88
|
+
return row.id;
|
|
89
|
+
}
|
|
90
|
+
async loadTemplateVersion(versionId, templateId) {
|
|
91
|
+
const row = this.database.raw
|
|
92
|
+
.prepare(`SELECT id,template_id,status,storage_key,manifest,content_hash,manifest_hash,mcp_config_hash,
|
|
93
|
+
file_count,total_bytes,host_policy_version
|
|
94
|
+
FROM conversation_template_versions WHERE id=? AND template_id=?`)
|
|
95
|
+
.get(versionId, templateId);
|
|
96
|
+
if (row === undefined ||
|
|
97
|
+
(row.status !== 'AVAILABLE' && row.status !== 'RETAINED') ||
|
|
98
|
+
row.host_policy_version !== HOST_POLICY_VERSION ||
|
|
99
|
+
typeof row.storage_key !== 'string' ||
|
|
100
|
+
typeof row.manifest !== 'string' ||
|
|
101
|
+
typeof row.content_hash !== 'string' ||
|
|
102
|
+
typeof row.manifest_hash !== 'string' ||
|
|
103
|
+
typeof row.mcp_config_hash !== 'string')
|
|
104
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
105
|
+
const versionDirectory = this.store.templateVersionDirectory(row.storage_key);
|
|
106
|
+
const manifestPath = join(versionDirectory, 'manifest.json');
|
|
107
|
+
const contentDirectory = join(versionDirectory, 'content');
|
|
108
|
+
const manifestInfo = await lstat(manifestPath).catch(() => undefined);
|
|
109
|
+
const contentInfo = await lstat(contentDirectory).catch(() => undefined);
|
|
110
|
+
if (manifestInfo === undefined ||
|
|
111
|
+
!manifestInfo.isFile() ||
|
|
112
|
+
manifestInfo.isSymbolicLink() ||
|
|
113
|
+
contentInfo === undefined ||
|
|
114
|
+
!contentInfo.isDirectory() ||
|
|
115
|
+
contentInfo.isSymbolicLink() ||
|
|
116
|
+
(contentInfo.mode & 0o777) !== 0o555)
|
|
117
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
118
|
+
let persisted;
|
|
119
|
+
try {
|
|
120
|
+
const manifestText = await readFile(manifestPath, 'utf8');
|
|
121
|
+
const manifestJson = manifestText.endsWith('\n') ? manifestText.slice(0, -1) : manifestText;
|
|
122
|
+
if (manifestJson !== row.manifest || templateManifestHash(manifestText) !== row.manifest_hash)
|
|
123
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
124
|
+
persisted = JSON.parse(manifestJson);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
128
|
+
}
|
|
129
|
+
if (!record(persisted))
|
|
130
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
131
|
+
const snapshot = parseTemplateVersionManifest(persisted);
|
|
132
|
+
const { allowedModels, defaultModelId, ...draft } = snapshot.draft;
|
|
133
|
+
const mcpServers = snapshot.mcpServers;
|
|
134
|
+
const contentHash = templateContentHash(snapshot.directories, snapshot.entries);
|
|
135
|
+
const totalBytes = snapshot.entries.reduce((total, entry) => total + entry.size, 0);
|
|
136
|
+
if (snapshot.mcpConfigHash !== row.mcp_config_hash ||
|
|
137
|
+
templateMcpConfigHash(mcpServers) !== row.mcp_config_hash ||
|
|
138
|
+
snapshot.contentHash !== row.content_hash ||
|
|
139
|
+
contentHash !== row.content_hash ||
|
|
140
|
+
row.file_count !== snapshot.entries.length ||
|
|
141
|
+
row.total_bytes !== totalBytes)
|
|
142
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
143
|
+
if (persisted.versionId !== versionId ||
|
|
144
|
+
persisted.templateId !== templateId ||
|
|
145
|
+
persisted.hostPolicyVersion !== HOST_POLICY_VERSION ||
|
|
146
|
+
draft.templateId !== templateId ||
|
|
147
|
+
draft.nodeId !== this.nodeId())
|
|
148
|
+
throw new Error('CONVERSATION_TEMPLATE_VERSION_CORRUPTED');
|
|
149
|
+
return {
|
|
150
|
+
id: versionId,
|
|
151
|
+
templateId,
|
|
152
|
+
storageKey: row.storage_key,
|
|
153
|
+
templateDirectory: contentDirectory,
|
|
154
|
+
sourceWorkspacePath: snapshot.sourceWorkspacePath,
|
|
155
|
+
draft,
|
|
156
|
+
modelPolicy: {
|
|
157
|
+
allowedIds: allowedModels.map((model) => model.modelId),
|
|
158
|
+
defaultId: defaultModelId
|
|
159
|
+
},
|
|
160
|
+
mcpServers
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export function runtimeModels(value) {
|
|
165
|
+
return marAgentRuntimeModelConfigurationSchema
|
|
166
|
+
.array()
|
|
167
|
+
.parse(value);
|
|
168
|
+
}
|
|
169
|
+
export function allowedModels(version, models) {
|
|
170
|
+
const allowed = new Set(version.modelPolicy.allowedIds);
|
|
171
|
+
return models.filter((model) => model.enabled && allowed.has(model.modelId));
|
|
172
|
+
}
|
|
173
|
+
export function selectModel(version, models, requestedModelId) {
|
|
174
|
+
const available = allowedModels(version, models).filter((model) => model.conversationModel !== false);
|
|
175
|
+
const requested = available.find((candidate) => candidate.id === requestedModelId);
|
|
176
|
+
if (requested !== undefined)
|
|
177
|
+
return requested;
|
|
178
|
+
if (models.some((candidate) => candidate.id === requestedModelId && candidate.conversationModel === false))
|
|
179
|
+
throw new Error('HOSTED_CONVERSATION_MODEL_NOT_AVAILABLE');
|
|
180
|
+
const defaultModel = available.find((candidate) => candidate.modelId === version.modelPolicy.defaultId);
|
|
181
|
+
if (defaultModel !== undefined)
|
|
182
|
+
return defaultModel;
|
|
183
|
+
const model = version.modelPolicy.allowedIds
|
|
184
|
+
.map((allowedId) => available.find((candidate) => candidate.modelId === allowedId))
|
|
185
|
+
.find((candidate) => candidate !== undefined);
|
|
186
|
+
if (model === undefined)
|
|
187
|
+
throw new Error('HOSTED_CONVERSATION_MODEL_NOT_AVAILABLE');
|
|
188
|
+
return model;
|
|
189
|
+
}
|
|
190
|
+
export function networkModels(models, networkMode) {
|
|
191
|
+
if (networkMode !== 'NONE')
|
|
192
|
+
return models;
|
|
193
|
+
return models.map((model) => model.hostedWebSearch ? { ...model, hostedWebSearch: false } : model);
|
|
194
|
+
}
|
|
195
|
+
export function resolveMcpServers(version, environment) {
|
|
196
|
+
const resolveReferences = (references) => {
|
|
197
|
+
const resolved = {};
|
|
198
|
+
for (const [name, reference] of Object.entries(references ?? {})) {
|
|
199
|
+
const value = environment[reference.secretName];
|
|
200
|
+
if (value === undefined)
|
|
201
|
+
throw new Error('CONVERSATION_TEMPLATE_MCP_INVALID');
|
|
202
|
+
resolved[name] = value;
|
|
203
|
+
}
|
|
204
|
+
return resolved;
|
|
205
|
+
};
|
|
206
|
+
assertMcpServerNames(version.mcpServers.map((server) => ({ name: server.serverName })));
|
|
207
|
+
return version.mcpServers.flatMap((server) => {
|
|
208
|
+
if (server.runtime.transport === 'HTTP') {
|
|
209
|
+
if (version.draft.networkMode !== 'UNRESTRICTED')
|
|
210
|
+
return [];
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
name: server.serverName,
|
|
214
|
+
lifecycle: 'EXECUTION',
|
|
215
|
+
transport: 'HTTP',
|
|
216
|
+
url: server.runtime.url,
|
|
217
|
+
headers: {
|
|
218
|
+
...resolveReferences(server.runtime.headers),
|
|
219
|
+
...resolveReferences(server.configuration.headers)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
return [
|
|
225
|
+
{
|
|
226
|
+
name: server.serverName,
|
|
227
|
+
lifecycle: 'EXECUTION',
|
|
228
|
+
transport: 'STDIO',
|
|
229
|
+
command: server.runtime.command,
|
|
230
|
+
args: [...server.runtime.args, ...(server.configuration.arguments ?? [])],
|
|
231
|
+
environment: {
|
|
232
|
+
...resolveReferences(server.runtime.environment),
|
|
233
|
+
...resolveReferences(server.configuration.environment)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
];
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
export function systemInstruction(version, generatedImagesDisplayDirectory) {
|
|
240
|
+
const draft = version.draft;
|
|
241
|
+
const network = draft.networkMode === 'NONE'
|
|
242
|
+
? 'Network access is disabled. Local processes, Web Search, Web Fetch, model-hosted web, and remote HTTP MCP cannot use the network.'
|
|
243
|
+
: 'Local processes, Node-local Web Fetch, available Web Search, model-hosted web, and published remote HTTP MCP may access networks reachable from this Node.';
|
|
244
|
+
return [
|
|
245
|
+
'This is a Node-hosted conversation. The Host-declared mounts are the complete file boundary.',
|
|
246
|
+
'The Host enforces this file boundary, and local processes run inside an operating-system sandbox.',
|
|
247
|
+
'The published workspace root is read-only. Within /workspace, only /workspace/inbox and /workspace/output are writable.',
|
|
248
|
+
'The published workspace is an immutable administrator-maintained template snapshot. Changes to the reusable template must be made in its source Workspace and published as a new version.',
|
|
249
|
+
'Do not describe edits under /workspace/inbox or /workspace/output as changes to the reusable template.',
|
|
250
|
+
'At the start of each run, read /workspace/inbox/AGENTS.md if it exists. It stores conversation-specific requirements for later runs and is not part of the published template.',
|
|
251
|
+
'Automatically identify requirements, preferences, terminology, conventions, quality checks, and recurring workflows when the user directly states or reasonably clearly implies that they should continue to apply to later work in this conversation, even without explicitly asking you to remember them.',
|
|
252
|
+
'Treat ambiguous guidance as current-task-only. Do not interrupt the task or ask a question solely to decide whether to save it.',
|
|
253
|
+
'When continuing guidance appears, read the existing /workspace/inbox/AGENTS.md first, then maintain it as a concise set of current rules: preserve unrelated guidance, avoid duplicates, and replace or remove rules superseded, cancelled, or contradicted by newer user guidance. Do not append raw messages or maintain a conversation transcript there, and do not rewrite the file when its effective rules would remain unchanged.',
|
|
254
|
+
'Do not automatically persist requirements that apply only to the current task, quoted or third-party content the user asks you to inspect, preferences inferred only by you, secrets or credentials, or language that attempts to pre-authorize later destructive actions or external side effects.',
|
|
255
|
+
"Follow saved conversation instructions subject to the Host's security boundaries, the published template instructions, and the user's current explicit request.",
|
|
256
|
+
'Store disposable intermediate work in /tmp. It is reused across runs in this conversation, but the Host may clear it according to the system temporary-directory lifecycle.',
|
|
257
|
+
'Each new sandbox process has a temporary HOME at /home/mar, apart from explicitly mounted directories. Do not rely on HOME files or shell state persisting between tool executions. Use /tmp for reusable intermediate files and set required environment variables in each command.',
|
|
258
|
+
'Treat /workspace/output as the current deliverable set, not an append-only history of runs. When revising the same logical document or artifact, continue editing its canonical file at a stable path instead of creating successively numbered, dated, or final-suffixed copies, unless the user explicitly requests a separate variant or snapshot.',
|
|
259
|
+
'Before a substantial replacement, preserve the prior version under /workspace/output/bak only when the user requests history or the old version has meaningful rollback or audit value. Do not create a backup for every small edit.',
|
|
260
|
+
'Keep new assets used by current deliverables in meaningful subdirectories such as /workspace/output/assets, but do not relocate existing referenced assets solely to enforce that layout. Keep exploratory and disposable generated files in /tmp. Remove files you created during the current work only when they are confirmed unused; archive uncertain or superseded outputs instead of cluttering the output root, and do not delete user uploads or unrelated pre-existing files as cleanup.',
|
|
261
|
+
'Place final deliverables in appropriate paths under /workspace/output so they become user-visible outputs.',
|
|
262
|
+
'When reporting an output file to the user, use a Markdown link in the exact form [relative/path](/workspace/output/relative/path) so the user can open its controlled preview or download.',
|
|
263
|
+
...(generatedImagesDisplayDirectory === undefined
|
|
264
|
+
? []
|
|
265
|
+
: [
|
|
266
|
+
`Generated images from this conversation remain available across executions in the read-only ${generatedImagesDisplayDirectory} directory.`,
|
|
267
|
+
`When the user asks to place a previously generated image in output, inspect ${generatedImagesDisplayDirectory} and copy the existing file from ${generatedImagesDisplayDirectory} to /workspace/output when available. If it is missing, report that fact; regenerate only when requested or authorized by the user.`
|
|
268
|
+
]),
|
|
269
|
+
...(version.sourceWorkspacePath === undefined
|
|
270
|
+
? []
|
|
271
|
+
: [
|
|
272
|
+
`The published workspace is also mounted read-only at ${version.sourceWorkspacePath} as a compatibility alias; it is the same immutable snapshot, not the live source Workspace. ${join(version.sourceWorkspacePath, 'inbox')} and ${join(version.sourceWorkspacePath, 'output')} are aliases of the same writable overlays.`
|
|
273
|
+
]),
|
|
274
|
+
'Read user uploads from /workspace/inbox.',
|
|
275
|
+
network
|
|
276
|
+
].join('\n');
|
|
277
|
+
}
|
|
278
|
+
function record(value) {
|
|
279
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
280
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { assertRegularFile } from './hosted-conversation-file-identity.js';
|
|
2
|
+
import { lstat, open, rename, rm, utimes } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { hostedConversationUploadChunkInputSchema, hostedConversationUploadControlInputSchema, hostedConversationUploadCreateInputSchema, hostedConversationUploadPreflightInputSchema, hostedConversationUploadPreflightResultSchema, hostedConversationUploadSchema } from '@myagentroam/protocol';
|
|
5
|
+
/** Owns Hosted upload admission, name reservations and temporary files. */
|
|
6
|
+
export class HostedConversationUploads {
|
|
7
|
+
options;
|
|
8
|
+
now;
|
|
9
|
+
id;
|
|
10
|
+
uploads = new Map();
|
|
11
|
+
uploadsByRequest = new Map();
|
|
12
|
+
pendingUploadCreates = new Map();
|
|
13
|
+
uploadNameReservations = new Map();
|
|
14
|
+
constructor(options, now, id) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
this.now = now;
|
|
17
|
+
this.id = id;
|
|
18
|
+
}
|
|
19
|
+
busy(conversationId) {
|
|
20
|
+
return [...this.uploads.values()].some((upload) => upload.conversationId === conversationId && upload.status === 'UPLOADING');
|
|
21
|
+
}
|
|
22
|
+
async close() {
|
|
23
|
+
await Promise.allSettled([...this.uploads.values()].map(async (upload) => {
|
|
24
|
+
if (upload.status === 'UPLOADING') {
|
|
25
|
+
upload.status = 'CANCELLED';
|
|
26
|
+
await rm(upload.temporaryPath, { force: true });
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
this.uploads.clear();
|
|
30
|
+
this.uploadsByRequest.clear();
|
|
31
|
+
this.uploadNameReservations.clear();
|
|
32
|
+
}
|
|
33
|
+
async preflightUpload(raw) {
|
|
34
|
+
const payload = this.options.request(raw);
|
|
35
|
+
const input = hostedConversationUploadPreflightInputSchema.parse(payload.data);
|
|
36
|
+
const conversation = this.options.requireWritableConversation(payload.userId, input.conversationId);
|
|
37
|
+
const version = await this.options.loadTemplateVersion(conversation.activeVersionId, conversation.templateId);
|
|
38
|
+
const files = await Promise.all(input.files.map(async (file) => ({
|
|
39
|
+
name: file.name,
|
|
40
|
+
status: await this.uploadPreflightStatus(conversation, version.draft, file.name, file.size)
|
|
41
|
+
})));
|
|
42
|
+
return hostedConversationUploadPreflightResultSchema.parse({ files });
|
|
43
|
+
}
|
|
44
|
+
async createUpload(raw) {
|
|
45
|
+
const payload = this.options.request(raw);
|
|
46
|
+
const input = hostedConversationUploadCreateInputSchema.parse(payload.data);
|
|
47
|
+
const conversation = this.options.requireWritableConversation(payload.userId, input.conversationId);
|
|
48
|
+
const requestKey = `${payload.userId}\0${conversation.id}\0${input.clientRequestId}`;
|
|
49
|
+
const existingId = this.uploadsByRequest.get(requestKey);
|
|
50
|
+
if (existingId !== undefined) {
|
|
51
|
+
const existing = this.uploads.get(existingId);
|
|
52
|
+
if (existing !== undefined)
|
|
53
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(existing)) };
|
|
54
|
+
}
|
|
55
|
+
const pending = this.pendingUploadCreates.get(requestKey);
|
|
56
|
+
if (pending !== undefined)
|
|
57
|
+
return pending;
|
|
58
|
+
const operation = this.createUploadForRequest(payload, input, conversation, requestKey);
|
|
59
|
+
this.pendingUploadCreates.set(requestKey, operation);
|
|
60
|
+
void operation
|
|
61
|
+
.finally(() => {
|
|
62
|
+
if (this.pendingUploadCreates.get(requestKey) === operation)
|
|
63
|
+
this.pendingUploadCreates.delete(requestKey);
|
|
64
|
+
})
|
|
65
|
+
.catch(() => undefined);
|
|
66
|
+
return operation;
|
|
67
|
+
}
|
|
68
|
+
async createUploadForRequest(payload, input, conversation, requestKey) {
|
|
69
|
+
const version = await this.options.loadTemplateVersion(conversation.activeVersionId, conversation.templateId);
|
|
70
|
+
const paths = this.options.store.pathsForConversation(payload.userId, conversation.id);
|
|
71
|
+
const status = await this.uploadPreflightStatus(conversation, version.draft, input.name, input.size);
|
|
72
|
+
if (status === 'QUOTA_EXCEEDED' || status === 'TOO_LARGE')
|
|
73
|
+
throw new Error('HOSTED_CONVERSATION_QUOTA_EXCEEDED');
|
|
74
|
+
if (status !== 'READY')
|
|
75
|
+
throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
|
|
76
|
+
const uploadId = this.id();
|
|
77
|
+
const temporaryPath = join(paths.uploadsDirectory, `.${uploadId}.upload`);
|
|
78
|
+
const name = await this.allocateUploadName(paths, payload.userId, conversation.id, input.name);
|
|
79
|
+
const targetPath = join(paths.uploadsDirectory, name);
|
|
80
|
+
try {
|
|
81
|
+
const handle = await open(temporaryPath, 'wx', 0o600);
|
|
82
|
+
await handle.close();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.releaseUploadName(conversation.id, name);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
const upload = {
|
|
89
|
+
uploadId,
|
|
90
|
+
conversationId: conversation.id,
|
|
91
|
+
userId: payload.userId,
|
|
92
|
+
clientRequestId: input.clientRequestId,
|
|
93
|
+
name,
|
|
94
|
+
mimeType: input.mimeType,
|
|
95
|
+
size: input.size,
|
|
96
|
+
temporaryPath,
|
|
97
|
+
targetPath,
|
|
98
|
+
receivedBytes: 0,
|
|
99
|
+
status: 'UPLOADING'
|
|
100
|
+
};
|
|
101
|
+
this.uploads.set(uploadId, upload);
|
|
102
|
+
this.uploadsByRequest.set(requestKey, uploadId);
|
|
103
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
|
|
104
|
+
}
|
|
105
|
+
async writeUploadChunk(raw) {
|
|
106
|
+
const payload = this.options.request(raw);
|
|
107
|
+
const input = hostedConversationUploadChunkInputSchema.parse(payload.data);
|
|
108
|
+
const upload = this.requireUpload(payload.userId, input.conversationId, input.uploadId);
|
|
109
|
+
if (upload.status !== 'UPLOADING')
|
|
110
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_STATE_INVALID');
|
|
111
|
+
if (input.offset !== upload.receivedBytes)
|
|
112
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_OFFSET_INVALID');
|
|
113
|
+
const chunk = Buffer.from(input.dataBase64, 'base64');
|
|
114
|
+
if (upload.receivedBytes + chunk.byteLength > upload.size)
|
|
115
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_SIZE_INVALID');
|
|
116
|
+
await assertUploadTemporaryFile(upload.temporaryPath, upload.receivedBytes);
|
|
117
|
+
const handle = await open(upload.temporaryPath, 'r+');
|
|
118
|
+
try {
|
|
119
|
+
await handle.write(chunk, 0, chunk.byteLength, upload.receivedBytes);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await handle.close();
|
|
123
|
+
}
|
|
124
|
+
upload.receivedBytes += chunk.byteLength;
|
|
125
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
|
|
126
|
+
}
|
|
127
|
+
uploadStatus(raw) {
|
|
128
|
+
const payload = this.options.request(raw);
|
|
129
|
+
const input = hostedConversationUploadControlInputSchema.parse(payload.data);
|
|
130
|
+
const upload = this.requireUpload(payload.userId, input.conversationId, input.uploadId);
|
|
131
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
|
|
132
|
+
}
|
|
133
|
+
async completeUpload(raw) {
|
|
134
|
+
const payload = this.options.request(raw);
|
|
135
|
+
const input = hostedConversationUploadControlInputSchema.parse(payload.data);
|
|
136
|
+
const upload = this.requireUpload(payload.userId, input.conversationId, input.uploadId);
|
|
137
|
+
if (upload.status === 'COMPLETED')
|
|
138
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
|
|
139
|
+
if (upload.status !== 'UPLOADING' || upload.receivedBytes !== upload.size)
|
|
140
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_INCOMPLETE');
|
|
141
|
+
const conversation = this.options.requireWritableConversation(payload.userId, upload.conversationId);
|
|
142
|
+
const version = await this.options.loadTemplateVersion(conversation.activeVersionId, conversation.templateId);
|
|
143
|
+
if (upload.size > version.draft.quotas.maxUploadBytes ||
|
|
144
|
+
conversation.quota.usedBytes + upload.size > conversation.quota.maxBytes)
|
|
145
|
+
throw new Error('HOSTED_CONVERSATION_QUOTA_EXCEEDED');
|
|
146
|
+
await assertUploadTemporaryFile(upload.temporaryPath, upload.size);
|
|
147
|
+
const target = await lstat(upload.targetPath).catch((error) => {
|
|
148
|
+
if (error.code === 'ENOENT')
|
|
149
|
+
return undefined;
|
|
150
|
+
throw error;
|
|
151
|
+
});
|
|
152
|
+
if (target !== undefined)
|
|
153
|
+
throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
|
|
154
|
+
const handle = await open(upload.temporaryPath, 'r+');
|
|
155
|
+
try {
|
|
156
|
+
await handle.sync();
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await handle.close();
|
|
160
|
+
}
|
|
161
|
+
await rename(upload.temporaryPath, upload.targetPath);
|
|
162
|
+
const createdAt = this.now();
|
|
163
|
+
try {
|
|
164
|
+
await utimes(upload.targetPath, new Date(createdAt), new Date(createdAt));
|
|
165
|
+
const file = {
|
|
166
|
+
id: this.options.store.fileProjectionId(upload.conversationId, 'UPLOAD', upload.name),
|
|
167
|
+
conversationId: upload.conversationId,
|
|
168
|
+
category: 'UPLOAD',
|
|
169
|
+
relativePath: upload.name,
|
|
170
|
+
displayName: upload.name,
|
|
171
|
+
mimeType: upload.mimeType,
|
|
172
|
+
size: upload.size,
|
|
173
|
+
source: 'USER',
|
|
174
|
+
createdAt,
|
|
175
|
+
updatedAt: createdAt
|
|
176
|
+
};
|
|
177
|
+
upload.file = await this.options.store.registerFile(payload.userId, file);
|
|
178
|
+
upload.status = 'COMPLETED';
|
|
179
|
+
this.releaseUploadName(upload.conversationId, upload.name);
|
|
180
|
+
this.options.publish(upload.conversationId, {
|
|
181
|
+
conversationId: upload.conversationId,
|
|
182
|
+
kind: 'files',
|
|
183
|
+
files: this.options.store.listFiles(payload.userId, upload.conversationId),
|
|
184
|
+
error: null
|
|
185
|
+
});
|
|
186
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
await rename(upload.targetPath, upload.temporaryPath).catch(() => undefined);
|
|
190
|
+
this.releaseUploadName(upload.conversationId, upload.name);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async cancelUpload(raw) {
|
|
195
|
+
const payload = this.options.request(raw);
|
|
196
|
+
const input = hostedConversationUploadControlInputSchema.parse(payload.data);
|
|
197
|
+
const upload = this.requireUpload(payload.userId, input.conversationId, input.uploadId);
|
|
198
|
+
if (upload.status === 'COMPLETED')
|
|
199
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_STATE_INVALID');
|
|
200
|
+
upload.status = 'CANCELLED';
|
|
201
|
+
await rm(upload.temporaryPath, { force: true });
|
|
202
|
+
this.releaseUploadName(upload.conversationId, upload.name);
|
|
203
|
+
return { upload: hostedConversationUploadSchema.parse(uploadProjection(upload)) };
|
|
204
|
+
}
|
|
205
|
+
requireUpload(userId, conversationId, uploadId) {
|
|
206
|
+
this.options.store.requireConversation(userId, conversationId);
|
|
207
|
+
const upload = this.uploads.get(uploadId);
|
|
208
|
+
if (upload === undefined ||
|
|
209
|
+
upload.userId !== userId ||
|
|
210
|
+
upload.conversationId !== conversationId)
|
|
211
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_NOT_FOUND');
|
|
212
|
+
return upload;
|
|
213
|
+
}
|
|
214
|
+
async requireAttachment(userId, conversationId, fileId) {
|
|
215
|
+
const file = this.options.store.requireFile(userId, conversationId, fileId);
|
|
216
|
+
if (file.category !== 'UPLOAD')
|
|
217
|
+
throw new Error('HOSTED_CONVERSATION_ATTACHMENT_INVALID');
|
|
218
|
+
await assertRegularFile(this.options.store.filePath(userId, file), file.size);
|
|
219
|
+
return file;
|
|
220
|
+
}
|
|
221
|
+
async cancelConversationUploads(userId, conversationId) {
|
|
222
|
+
for (const upload of this.uploads.values()) {
|
|
223
|
+
if (upload.userId !== userId || upload.conversationId !== conversationId)
|
|
224
|
+
continue;
|
|
225
|
+
if (upload.status === 'UPLOADING') {
|
|
226
|
+
upload.status = 'CANCELLED';
|
|
227
|
+
await rm(upload.temporaryPath, { force: true }).catch(() => undefined);
|
|
228
|
+
this.releaseUploadName(upload.conversationId, upload.name);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async uploadPreflightStatus(conversation, draft, name, size) {
|
|
233
|
+
if (Buffer.byteLength(name, 'utf8') > 255)
|
|
234
|
+
return 'INVALID_NAME';
|
|
235
|
+
if (size > draft.quotas.maxUploadBytes)
|
|
236
|
+
return 'TOO_LARGE';
|
|
237
|
+
if (conversation.quota.usedBytes + size > conversation.quota.maxBytes)
|
|
238
|
+
return 'QUOTA_EXCEEDED';
|
|
239
|
+
return 'READY';
|
|
240
|
+
}
|
|
241
|
+
async allocateUploadName(paths, userId, conversationId, requestedName) {
|
|
242
|
+
const existingNames = new Set(this.options.store
|
|
243
|
+
.listFiles(userId, conversationId)
|
|
244
|
+
.filter((file) => file.category === 'UPLOAD')
|
|
245
|
+
.map((file) => canonicalUploadName(file.relativePath)));
|
|
246
|
+
const parts = uploadNameParts(requestedName);
|
|
247
|
+
let index = parts.index;
|
|
248
|
+
for (;;) {
|
|
249
|
+
const candidate = uploadNameCandidate(parts, index);
|
|
250
|
+
const canonical = canonicalUploadName(candidate);
|
|
251
|
+
if (existingNames.has(canonical) || this.isUploadNameReserved(conversationId, canonical)) {
|
|
252
|
+
index = Math.max(1, index + 1);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
this.reserveUploadName(conversationId, canonical);
|
|
256
|
+
let target;
|
|
257
|
+
try {
|
|
258
|
+
target = await lstat(join(paths.uploadsDirectory, candidate));
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (error.code === 'ENOENT')
|
|
262
|
+
return candidate;
|
|
263
|
+
this.releaseUploadName(conversationId, candidate);
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
if (target === undefined)
|
|
267
|
+
return candidate;
|
|
268
|
+
this.releaseUploadName(conversationId, candidate);
|
|
269
|
+
if (!target.isFile())
|
|
270
|
+
throw new Error('HOSTED_CONVERSATION_FILE_INVALID');
|
|
271
|
+
existingNames.add(canonical);
|
|
272
|
+
index = Math.max(1, index + 1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
isUploadNameReserved(conversationId, canonicalName) {
|
|
276
|
+
return this.uploadNameReservations.get(conversationId)?.has(canonicalName) ?? false;
|
|
277
|
+
}
|
|
278
|
+
reserveUploadName(conversationId, canonicalName) {
|
|
279
|
+
const reserved = this.uploadNameReservations.get(conversationId) ?? new Set();
|
|
280
|
+
reserved.add(canonicalName);
|
|
281
|
+
this.uploadNameReservations.set(conversationId, reserved);
|
|
282
|
+
}
|
|
283
|
+
releaseUploadName(conversationId, name) {
|
|
284
|
+
const reserved = this.uploadNameReservations.get(conversationId);
|
|
285
|
+
if (reserved === undefined)
|
|
286
|
+
return;
|
|
287
|
+
reserved.delete(canonicalUploadName(name));
|
|
288
|
+
if (reserved.size === 0)
|
|
289
|
+
this.uploadNameReservations.delete(conversationId);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function canonicalUploadName(name) {
|
|
293
|
+
return name.normalize('NFKC').toLocaleLowerCase();
|
|
294
|
+
}
|
|
295
|
+
function uploadNameParts(name) {
|
|
296
|
+
const dot = name.lastIndexOf('.');
|
|
297
|
+
const extension = dot > 0 ? name.slice(dot) : '';
|
|
298
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
299
|
+
const suffix = /^(.*) \((\d+)\)$/u.exec(stem);
|
|
300
|
+
if (suffix === null || suffix[1] === undefined || suffix[2] === undefined)
|
|
301
|
+
return { stem, extension, index: 0 };
|
|
302
|
+
const index = Number.parseInt(suffix[2], 10);
|
|
303
|
+
return {
|
|
304
|
+
stem: suffix[1],
|
|
305
|
+
extension,
|
|
306
|
+
index: Number.isSafeInteger(index) && index > 0 ? index : 0
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function uploadNameCandidate(parts, index) {
|
|
310
|
+
if (index === 0)
|
|
311
|
+
return `${parts.stem}${parts.extension}`;
|
|
312
|
+
const suffix = ` (${index})`;
|
|
313
|
+
const maximumStemLength = 512 - parts.extension.length - suffix.length;
|
|
314
|
+
return `${truncateUploadStem(parts.stem, maximumStemLength)}${suffix}${parts.extension}`;
|
|
315
|
+
}
|
|
316
|
+
function truncateUploadStem(stem, maximumLength) {
|
|
317
|
+
if (stem.length <= maximumLength)
|
|
318
|
+
return stem;
|
|
319
|
+
const truncated = stem.slice(0, Math.max(0, maximumLength));
|
|
320
|
+
const last = truncated.charCodeAt(truncated.length - 1);
|
|
321
|
+
return last >= 0xd800 && last <= 0xdbff ? truncated.slice(0, -1) : truncated;
|
|
322
|
+
}
|
|
323
|
+
function uploadProjection(upload) {
|
|
324
|
+
return {
|
|
325
|
+
uploadId: upload.uploadId,
|
|
326
|
+
conversationId: upload.conversationId,
|
|
327
|
+
name: upload.name,
|
|
328
|
+
mimeType: upload.mimeType,
|
|
329
|
+
receivedBytes: upload.receivedBytes,
|
|
330
|
+
size: upload.size,
|
|
331
|
+
status: upload.status,
|
|
332
|
+
...(upload.file === undefined ? {} : { file: upload.file })
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
async function assertUploadTemporaryFile(path, expectedSize) {
|
|
336
|
+
const metadata = await lstat(path).catch(() => {
|
|
337
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_STATE_INVALID');
|
|
338
|
+
});
|
|
339
|
+
if (!metadata.isFile() ||
|
|
340
|
+
metadata.isSymbolicLink() ||
|
|
341
|
+
metadata.nlink !== 1 ||
|
|
342
|
+
metadata.size !== expectedSize)
|
|
343
|
+
throw new Error('HOSTED_CONVERSATION_UPLOAD_STATE_INVALID');
|
|
344
|
+
}
|