@wowyuarm/dsh-agent-team 0.1.0 → 0.1.2
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 +14 -2
- package/README.zh.md +10 -2
- package/package.json +32 -24
- package/packages/agent-team/lib/attachments.js +157 -0
- package/packages/agent-team/lib/index.js +280 -22
- package/packages/agent-team/lib/ledger.js +98 -22
- package/packages/agent-team/lib/recovery.js +87 -0
- package/packages/agent-team/lib/spec.js +27 -5
- package/packages/agent-team/lib/typert.host.js +313 -26
- package/packages/agent-team/lib/typert.remote-client.d.ts +9 -1
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +234 -17
- package/packages/agent-team/lib/types/attachments.d.ts +69 -0
- package/packages/agent-team/lib/types/attachments.d.ts.map +1 -0
- package/packages/agent-team/lib/types/index.d.ts +68 -6
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +17 -1
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/recovery.d.ts +42 -0
- package/packages/agent-team/lib/types/recovery.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types.d.ts +94 -3
- package/packages/agent-team/lib/types/types.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +2 -2
- package/packages/client-agent-team/README.md +4 -3
- package/packages/client-agent-team/README.zh.md +4 -3
- package/packages/client-agent-team/lib/client.js +1791 -322
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +5 -2
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +108 -59
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +7 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +80 -19
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +38 -19
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +4 -1
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamComposer.js +61 -4
- package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamConversation.js +3 -3
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +12 -2
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +152 -14
- package/packages/client-agent-team/lib/types/client/TeamRunDivider.d.ts +10 -0
- package/packages/client-agent-team/lib/types/client/TeamRunDivider.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamRunDivider.js +12 -0
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts +7 -0
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +93 -16
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +6 -5
- package/packages/client-agent-team/lib/types/client/attachment-preview.d.ts +22 -0
- package/packages/client-agent-team/lib/types/client/attachment-preview.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/attachment-preview.js +35 -0
- package/packages/client-agent-team/lib/types/client/drafts.d.ts +41 -0
- package/packages/client-agent-team/lib/types/client/drafts.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/drafts.js +144 -0
- package/packages/client-agent-team/lib/types/client/index.d.ts +2 -0
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +50 -10
- package/packages/client-agent-team/lib/types/client/locales.d.ts +34 -0
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +34 -0
- package/packages/client-agent-team/lib/types/client/multi-menu-field.d.ts +33 -0
- package/packages/client-agent-team/lib/types/client/multi-menu-field.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/multi-menu-field.js +30 -0
- package/packages/client-agent-team/lib/types/client/navigation.d.ts +16 -1
- package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/navigation.js +45 -8
- package/packages/client-agent-team/lib/types/client/sidebar-drag.d.ts +35 -0
- package/packages/client-agent-team/lib/types/client/sidebar-drag.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/sidebar-drag.js +70 -0
- package/packages/client-agent-team/lib/types/client/sidebar-order.d.ts +27 -0
- package/packages/client-agent-team/lib/types/client/sidebar-order.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/sidebar-order.js +170 -0
- package/packages/client-agent-team/lib/types/client/slots.d.ts +18 -2
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/task-refs.d.ts +20 -0
- package/packages/client-agent-team/lib/types/client/task-refs.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/task-refs.js +58 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +15 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-formatters.js +30 -2
- package/packages/client-agent-team/lib/types/client/team-separators.d.ts +8 -0
- package/packages/client-agent-team/lib/types/client/team-separators.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-separators.js +14 -0
- package/packages/tool-agent-team/lib/index.js +10 -6
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | [简体中文](README.zh.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@wowyuarm/dsh-agent-team)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/wowyuarm/dsh-agent-team/releases)
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
**dsh-agent-team** helps a human manage tasks in an orderly way and use agents as true collaborators: agents are persistent identities for their sessions; Workspaces organize agents and sessions per project; Channels carry responsibilities; Task Threads chain session agents into one line of progress.
|
|
10
|
+
|
|
11
|
+
An opt-in plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): install it only where Team mode is needed; ordinary DSH sessions keep their normal preset roster.
|
|
8
12
|
|
|
9
13
|
## Preview
|
|
10
14
|
|
|
@@ -53,6 +57,14 @@ Team mode
|
|
|
53
57
|
|
|
54
58
|
Create an Agent only in a trusted Workspace. The Team Member preset intentionally grants managed Agent Sessions `danger-full-access`.
|
|
55
59
|
|
|
60
|
+
## Uninstall
|
|
61
|
+
|
|
62
|
+
Remove the bundle from the profile; this also removes its composed layers:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
dsh plugin --profile web remove @wowyuarm/dsh-agent-team
|
|
66
|
+
```
|
|
67
|
+
|
|
56
68
|
## What it adds
|
|
57
69
|
|
|
58
70
|
- A durable single-host Team with Channels, Messages, Tasks, Threads, Claims, and Agent membership.
|
package/README.zh.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**dsh-agent-team** 帮助 human 有序管理任务、把 agents 用成真正的协作者:Agent 是 session 的持久身份;Workspace 按项目组织 agents 与 sessions;Channel 承载职责分派;Task Thread 把多个 session agent 串成一条推进线。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
一个为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 提供的按需启用插件:只在需要 Team mode 的 profile 安装,普通 DSH Session 保持原有 preset roster。
|
|
8
8
|
|
|
9
9
|
## 预览
|
|
10
10
|
|
|
@@ -53,6 +53,14 @@ Team mode
|
|
|
53
53
|
|
|
54
54
|
只在可信 Workspace 中创建 Agent。Team Member preset 会给被管理的 Agent Session 授予 `danger-full-access`。
|
|
55
55
|
|
|
56
|
+
## 卸载
|
|
57
|
+
|
|
58
|
+
从 profile 移除 bundle,同时会移除它组合进来的层:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
dsh plugin --profile web remove @wowyuarm/dsh-agent-team
|
|
62
|
+
```
|
|
63
|
+
|
|
56
64
|
## 提供的能力
|
|
57
65
|
|
|
58
66
|
- 持久化的单 Host Team,包含 Channel、Message、Task、Thread、Claim 和 Agent membership。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wowyuarm/dsh-agent-team",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Help humans organize tasks and let agents collaborate - a Team plugin for DeepSeek Harness",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/wowyuarm/dsh-agent-team.git"
|
|
@@ -111,20 +111,22 @@
|
|
|
111
111
|
"./package.json": "./package.json"
|
|
112
112
|
},
|
|
113
113
|
"peerDependencies": {
|
|
114
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
114
115
|
"@deepseek-ai/dsh-agent": ">=0.1.1-rc.2 <0.2.0",
|
|
115
116
|
"@deepseek-ai/dsh-agent-default-model": ">=0.1.1-rc.2 <0.2.0",
|
|
116
|
-
"@deepseek-ai/dsh-agent-tool-presentation": ">=0.1.1-rc.2 <0.2.0",
|
|
117
117
|
"@deepseek-ai/dsh-agent-presets": ">=0.1.1-rc.2 <0.2.0",
|
|
118
|
-
"@deepseek-ai/dsh-tool-
|
|
119
|
-
"@deepseek-ai/dsh-
|
|
120
|
-
"@deepseek-ai/dsh-tool-fs": ">=0.1.1-rc.2 <0.2.0",
|
|
121
|
-
"@deepseek-ai/dsh-tool-fs-search": ">=0.1.1-rc.2 <0.2.0",
|
|
122
|
-
"@deepseek-ai/dsh-tool-jobs": ">=0.1.1-rc.2 <0.2.0",
|
|
123
|
-
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.1-rc.2 <0.2.0",
|
|
124
|
-
"@deepseek-ai/dsh-tool-skill": ">=0.1.1-rc.2 <0.2.0",
|
|
125
|
-
"@deepseek-ai/dsh-tool-todo": ">=0.1.1-rc.2 <0.2.0",
|
|
126
|
-
"@deepseek-ai/dsh-tool-web": ">=0.1.1-rc.2 <0.2.0",
|
|
118
|
+
"@deepseek-ai/dsh-agent-tool-presentation": ">=0.1.1-rc.2 <0.2.0",
|
|
119
|
+
"@deepseek-ai/dsh-api-remotes": ">=0.1.1-rc.2 <0.2.0",
|
|
127
120
|
"@deepseek-ai/dsh-brand": ">=0.1.1-rc.2 <0.2.0",
|
|
121
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.1-rc.2 <0.2.0",
|
|
122
|
+
"@deepseek-ai/dsh-client-runtime": ">=0.1.1-rc.2 <0.2.0",
|
|
123
|
+
"@deepseek-ai/dsh-client-ui-conversation": ">=0.1.1-rc.2 <0.2.0",
|
|
124
|
+
"@deepseek-ai/dsh-client-ui-layout": ">=0.1.1-rc.2 <0.2.0",
|
|
125
|
+
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.1-rc.2 <0.2.0",
|
|
126
|
+
"@deepseek-ai/dsh-client-ui-settings-general": ">=0.1.1-rc.2 <0.2.0",
|
|
127
|
+
"@deepseek-ai/dsh-client-ui-sidebar": ">=0.1.1-rc.2 <0.2.0",
|
|
128
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.1-rc.2 <0.2.0",
|
|
129
|
+
"@deepseek-ai/dsh-client-ui-workspace": ">=0.1.1-rc.2 <0.2.0",
|
|
128
130
|
"@deepseek-ai/dsh-home-paths": ">=0.1.1-rc.2 <0.2.0",
|
|
129
131
|
"@deepseek-ai/dsh-invariants": ">=0.1.1-rc.2 <0.2.0",
|
|
130
132
|
"@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.2.0",
|
|
@@ -132,21 +134,27 @@
|
|
|
132
134
|
"@deepseek-ai/dsh-scope": ">=0.1.1-rc.2 <0.2.0",
|
|
133
135
|
"@deepseek-ai/dsh-session": ">=0.1.1-rc.2 <0.2.0",
|
|
134
136
|
"@deepseek-ai/dsh-session-persistence": ">=0.1.1-rc.2 <0.2.0",
|
|
137
|
+
"@deepseek-ai/dsh-skill-filesystem": ">=0.1.1-rc.2 <0.2.0",
|
|
135
138
|
"@deepseek-ai/dsh-storage-domain": ">=0.1.1-rc.2 <0.2.0",
|
|
139
|
+
"@deepseek-ai/dsh-tool-bash": ">=0.1.1-rc.2 <0.2.0",
|
|
140
|
+
"@deepseek-ai/dsh-tool-fs": ">=0.1.1-rc.2 <0.2.0",
|
|
141
|
+
"@deepseek-ai/dsh-tool-fs-search": ">=0.1.1-rc.2 <0.2.0",
|
|
142
|
+
"@deepseek-ai/dsh-tool-jobs": ">=0.1.1-rc.2 <0.2.0",
|
|
143
|
+
"@deepseek-ai/dsh-tool-pwsh": ">=0.1.1-rc.2 <0.2.0",
|
|
144
|
+
"@deepseek-ai/dsh-tool-skill": ">=0.1.1-rc.2 <0.2.0",
|
|
145
|
+
"@deepseek-ai/dsh-tool-todo": ">=0.1.1-rc.2 <0.2.0",
|
|
146
|
+
"@deepseek-ai/dsh-tool-web": ">=0.1.1-rc.2 <0.2.0",
|
|
136
147
|
"@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.2.0",
|
|
137
148
|
"@deepseek-ai/dsh-typert-protocol": ">=0.1.1-rc.2 <0.2.0",
|
|
138
149
|
"@deepseek-ai/dsh-workspace": ">=0.1.1-rc.2 <0.2.0",
|
|
139
|
-
"@deepseek-ai/cordis": "^4.0.1",
|
|
140
|
-
"@deepseek-ai/dsh-api-remotes": ">=0.1.1-rc.2 <0.2.0",
|
|
141
|
-
"@deepseek-ai/dsh-client-locale": ">=0.1.1-rc.2 <0.2.0",
|
|
142
|
-
"@deepseek-ai/dsh-client-runtime": ">=0.1.1-rc.2 <0.2.0",
|
|
143
|
-
"@deepseek-ai/dsh-client-ui-conversation": ">=0.1.1-rc.2 <0.2.0",
|
|
144
|
-
"@deepseek-ai/dsh-client-ui-workspace": ">=0.1.1-rc.2 <0.2.0",
|
|
145
|
-
"@deepseek-ai/dsh-client-ui-layout": ">=0.1.1-rc.2 <0.2.0",
|
|
146
|
-
"@deepseek-ai/dsh-client-ui-primitives": ">=0.1.1-rc.2 <0.2.0",
|
|
147
|
-
"@deepseek-ai/dsh-client-ui-settings-general": ">=0.1.1-rc.2 <0.2.0",
|
|
148
|
-
"@deepseek-ai/dsh-client-ui-sidebar": ">=0.1.1-rc.2 <0.2.0",
|
|
149
|
-
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.1-rc.2 <0.2.0",
|
|
150
150
|
"react": "^18.2.0"
|
|
151
|
-
}
|
|
151
|
+
},
|
|
152
|
+
"keywords": [
|
|
153
|
+
"deepseek-harness",
|
|
154
|
+
"dsh",
|
|
155
|
+
"dsh-plugin",
|
|
156
|
+
"agent-team",
|
|
157
|
+
"multi-agent",
|
|
158
|
+
"ai-agents"
|
|
159
|
+
]
|
|
152
160
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
|
|
5
|
+
/**
|
|
6
|
+
* Composer attachments are a cache, not an archive: bytes live only so Member
|
|
7
|
+
* agents can read them within the consumption window, while the ledger keeps
|
|
8
|
+
* the metadata forever. Everything here derives from the on-disk layout
|
|
9
|
+
* `$DSH_HOME/agent-team/attachments/v1/<attachmentId>/` holding the payload
|
|
10
|
+
* file (original sanitized name) plus a `meta.json` sidecar.
|
|
11
|
+
*/
|
|
12
|
+
export const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
13
|
+
/** Referenced uploads survive this long after upload for member consumption. */
|
|
14
|
+
export const ATTACHMENT_REFERENCED_TTL_MS = 72 * 60 * 60 * 1000;
|
|
15
|
+
/** Unreferenced uploads (uploaded but never sent) are cleaned much sooner. */
|
|
16
|
+
export const ATTACHMENT_ORPHAN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
export function attachmentsRoot() {
|
|
18
|
+
return dshHomePath('agent-team', 'attachments', 'v1');
|
|
19
|
+
}
|
|
20
|
+
export function newAttachmentId() {
|
|
21
|
+
return randomUUID();
|
|
22
|
+
}
|
|
23
|
+
/** Strip path separators, control characters, and leading dots from one client-supplied name. */
|
|
24
|
+
export function sanitizeFileName(raw) {
|
|
25
|
+
const cleaned = raw.replaceAll(/[\\/\u0000-\u001f\u007f]/g, '').replaceAll(/^\.+/g, '').trim();
|
|
26
|
+
return cleaned === '' ? 'attachment' : cleaned.slice(0, 180);
|
|
27
|
+
}
|
|
28
|
+
/** Extension-derived media types for agent-supplied files; unknown types stay generic. */
|
|
29
|
+
const PATH_MEDIA_TYPES = {
|
|
30
|
+
'.png': 'image/png',
|
|
31
|
+
'.jpg': 'image/jpeg',
|
|
32
|
+
'.jpeg': 'image/jpeg',
|
|
33
|
+
'.gif': 'image/gif',
|
|
34
|
+
'.webp': 'image/webp',
|
|
35
|
+
'.bmp': 'image/bmp',
|
|
36
|
+
'.svg': 'image/svg+xml',
|
|
37
|
+
'.pdf': 'application/pdf',
|
|
38
|
+
'.txt': 'text/plain',
|
|
39
|
+
'.md': 'text/markdown',
|
|
40
|
+
'.json': 'application/json',
|
|
41
|
+
};
|
|
42
|
+
/** Best-effort media type from one path's extension, so images render as thumbnails. */
|
|
43
|
+
export function mediaTypeForPath(raw) {
|
|
44
|
+
return PATH_MEDIA_TYPES[extname(raw).toLowerCase()] ?? 'application/octet-stream';
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate one agent-supplied attachment path before any cache write happens,
|
|
48
|
+
* so a rejection anywhere leaves the upload cache untouched.
|
|
49
|
+
*/
|
|
50
|
+
export async function validatePathAttachment(raw) {
|
|
51
|
+
if (!isAbsolute(raw))
|
|
52
|
+
throw new Error(`attachment path '${raw}' must be absolute`);
|
|
53
|
+
const info = await stat(raw).catch(() => undefined);
|
|
54
|
+
if (info === undefined)
|
|
55
|
+
throw new Error(`attachment path '${raw}' does not exist`);
|
|
56
|
+
if (!info.isFile())
|
|
57
|
+
throw new Error(`attachment path '${raw}' is not a regular file`);
|
|
58
|
+
if (info.size === 0)
|
|
59
|
+
throw new Error(`attachment '${basename(raw)}' must not be empty`);
|
|
60
|
+
if (info.size > ATTACHMENT_MAX_BYTES)
|
|
61
|
+
throw new Error(`attachment '${basename(raw)}' exceeds the ${ATTACHMENT_MAX_BYTES} byte limit`);
|
|
62
|
+
}
|
|
63
|
+
/** Copy one validated file into the cache as a fresh immutable entry. */
|
|
64
|
+
export async function copyPathAttachment(root, raw) {
|
|
65
|
+
const bytes = await readFile(raw);
|
|
66
|
+
const stored = await writeAttachment(root, newAttachmentId(), basename(raw), mediaTypeForPath(raw), bytes);
|
|
67
|
+
return { attachmentId: stored.attachmentId, name: stored.name, byteSize: stored.byteSize, mediaType: stored.mediaType };
|
|
68
|
+
}
|
|
69
|
+
function attachmentDir(root, attachmentId) {
|
|
70
|
+
return join(root, attachmentId);
|
|
71
|
+
}
|
|
72
|
+
/** Write one upload as an immutable payload plus its metadata sidecar. */
|
|
73
|
+
export async function writeAttachment(root, attachmentId, rawName, mediaType, bytes) {
|
|
74
|
+
const name = sanitizeFileName(rawName);
|
|
75
|
+
const dir = attachmentDir(root, attachmentId);
|
|
76
|
+
await mkdir(dir, { recursive: true });
|
|
77
|
+
await writeFile(join(dir, name), bytes);
|
|
78
|
+
const meta = { name, mediaType, uploadedAt: new Date().toISOString() };
|
|
79
|
+
await writeFile(join(dir, 'meta.json'), JSON.stringify(meta), 'utf8');
|
|
80
|
+
return { attachmentId, path: join(dir, name), name, byteSize: bytes.byteLength, mediaType };
|
|
81
|
+
}
|
|
82
|
+
/** Read one attachment back; `undefined` when the cache entry is gone (GC'd). */
|
|
83
|
+
export async function readAttachment(root, attachmentId) {
|
|
84
|
+
const dir = attachmentDir(root, attachmentId);
|
|
85
|
+
let metaRaw;
|
|
86
|
+
try {
|
|
87
|
+
metaRaw = await readFile(join(dir, 'meta.json'));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const meta = JSON.parse(metaRaw.toString('utf8'));
|
|
93
|
+
const entries = await readdir(dir);
|
|
94
|
+
const payload = entries.filter(entry => entry !== 'meta.json')[0];
|
|
95
|
+
if (payload === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const bytes = await readFile(join(dir, payload));
|
|
98
|
+
return { name: meta.name, mediaType: meta.mediaType, byteSize: bytes.byteLength, uploadedAt: meta.uploadedAt, bytes };
|
|
99
|
+
}
|
|
100
|
+
/** Whether an upload with this id still has bytes on disk. */
|
|
101
|
+
export async function attachmentExists(root, attachmentId) {
|
|
102
|
+
try {
|
|
103
|
+
await stat(attachmentDir(root, attachmentId));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** List every cache entry with its upload instant for the GC sweep. */
|
|
111
|
+
export async function scanAttachmentCache(root) {
|
|
112
|
+
let ids;
|
|
113
|
+
try {
|
|
114
|
+
ids = await readdir(root);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const id of ids) {
|
|
121
|
+
try {
|
|
122
|
+
const metaRaw = await readFile(join(root, id, 'meta.json'));
|
|
123
|
+
const meta = JSON.parse(metaRaw.toString('utf8'));
|
|
124
|
+
entries.push({ attachmentId: id, uploadedAt: Date.parse(meta.uploadedAt) });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A half-written or foreign directory is not ours to judge; skip it.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
|
132
|
+
/** Remove one cache entry's bytes; missing entries already satisfy the sweep. */
|
|
133
|
+
export async function removeAttachment(root, attachmentId) {
|
|
134
|
+
await rm(attachmentDir(root, attachmentId), { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* One GC pass: drop uploads older than the orphan TTL, and referenced ones
|
|
138
|
+
* older than the consumption-window TTL. Returns the ids removed so the
|
|
139
|
+
* service can log them.
|
|
140
|
+
*/
|
|
141
|
+
export async function sweepAttachmentCache(root, referenced, now) {
|
|
142
|
+
const removed = [];
|
|
143
|
+
for (const entry of await scanAttachmentCache(root)) {
|
|
144
|
+
const age = now - entry.uploadedAt;
|
|
145
|
+
const ttl = referenced.has(entry.attachmentId) ? ATTACHMENT_REFERENCED_TTL_MS : ATTACHMENT_ORPHAN_TTL_MS;
|
|
146
|
+
if (age > ttl) {
|
|
147
|
+
await removeAttachment(root, entry.attachmentId);
|
|
148
|
+
removed.push(entry.attachmentId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return removed;
|
|
152
|
+
}
|
|
153
|
+
/** Accept only well-formed `type/subtype` media types; anything else falls back to the generic binary type. */
|
|
154
|
+
export function sanitizeMediaType(raw) {
|
|
155
|
+
const candidate = (raw ?? '').trim().toLowerCase();
|
|
156
|
+
return /^[a-z0-9][-.\w]*\/[-.\w]+$/.test(candidate) ? candidate : 'application/octet-stream';
|
|
157
|
+
}
|