@co0ontty/wand 4.77.0 → 4.78.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 +21 -46
- package/dist/agent-dispatch.js +3 -2
- package/dist/ai-team-live.d.ts +25 -0
- package/dist/ai-team-live.js +135 -0
- package/dist/ai-team-prompts.d.ts +10 -6
- package/dist/ai-team-prompts.js +74 -41
- package/dist/ai-team-runner.d.ts +85 -3
- package/dist/ai-team-runner.js +336 -33
- package/dist/ai-team-types.d.ts +57 -2
- package/dist/ai-team-types.js +4 -0
- package/dist/build-info.json +3 -3
- package/dist/commit-task-archive.js +14 -18
- package/dist/config.js +14 -6
- package/dist/git-quick-commit.js +1 -1
- package/dist/middleware/rate-limit.d.ts +18 -4
- package/dist/middleware/rate-limit.js +55 -23
- package/dist/missions.js +0 -3
- package/dist/models.d.ts +6 -1
- package/dist/models.js +35 -0
- package/dist/path-repair.d.ts +6 -5
- package/dist/path-repair.js +3 -3
- package/dist/render-protocol.d.ts +1 -1
- package/dist/render-protocol.js +1 -1
- package/dist/server-ai-team-routes.d.ts +1 -0
- package/dist/server-ai-team-routes.js +28 -14
- package/dist/server-local-preview-routes.js +1 -1
- package/dist/server-session-routes.js +0 -3
- package/dist/server-settings-routes.d.ts +1 -1
- package/dist/server-settings-routes.js +3 -2
- package/dist/server-task-routes.js +16 -32
- package/dist/server-workspace-routes.js +31 -24
- package/dist/server.js +60 -21
- package/dist/storage.d.ts +54 -21
- package/dist/storage.js +485 -122
- package/dist/types.d.ts +7 -0
- package/dist/upload-routes.d.ts +2 -2
- package/dist/upload-routes.js +10 -3
- package/dist/wand-task-sync.d.ts +2 -34
- package/dist/wand-task-sync.js +0 -206
- package/dist/web-ui/asset-version.d.ts +5 -0
- package/dist/web-ui/asset-version.js +5 -0
- package/dist/web-ui/content/ai-teams.js +17 -8
- package/dist/web-ui/content/scripts.js +67 -66
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +0 -21
- package/dist/web-ui/embedded-assets.js +8 -7
- package/dist/web-ui/index.js +9 -17
- package/dist/web-ui/scripts.d.ts +3 -1
- package/dist/web-ui/scripts.js +52 -25
- package/dist/web-ui/styles.d.ts +2 -0
- package/dist/web-ui/styles.js +12 -0
- package/package.json +13 -12
- package/scripts/install-render-binary.js +2 -2
- package/scripts/stage-render-binaries.js +1 -1
package/dist/ai-team-runner.js
CHANGED
|
@@ -6,13 +6,16 @@ import path from "node:path";
|
|
|
6
6
|
import { dispatchAgentForTask, resolveTaskDispatchTarget, sendToAgentSession, stopAgentSession, } from "./agent-dispatch.js";
|
|
7
7
|
import { AI_TEAM_REPORT_DIR, aiTeamChatHistoryPath, aiTeamHandoffPath, aiTeamReportPath, buildLeaderFollowupPrompt, buildLeaderFormatRetryPrompt, buildAiTeamObjective, buildLeaderKickoffPrompt, buildMemberPrompt, leaderOf, parseLeaderDecision, renderChatHistoryFile, renderHandoffFile, } from "./ai-team-prompts.js";
|
|
8
8
|
import { AI_TEAM_STARTUP_WINDOW_MS, classifyCandidateFailure, classifyStartupFailure, isDegradableWorkFailure, isModelUnknownBeforeDispatch, } from "./ai-team-availability.js";
|
|
9
|
-
import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, agentKey, memberAgents, } from "./ai-team-types.js";
|
|
9
|
+
import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, AI_TEAM_TERMINAL_RUN_STATUSES, agentKey, memberAgents, } from "./ai-team-types.js";
|
|
10
|
+
import { renderLiveStepText } from "./ai-team-live.js";
|
|
10
11
|
import { getErrorMessage } from "./error-utils.js";
|
|
11
12
|
import { activityState } from "./missions.js";
|
|
12
13
|
const REPORT_MAX_BYTES = 64 * 1024;
|
|
13
14
|
/** 报告文件最后一次写入后要稳定这么久才读取,避免读到写了一半的文件。 */
|
|
14
15
|
const REPORT_SETTLE_MS = 1500;
|
|
15
16
|
const OUTPUT_DEBOUNCE_MS = 1000;
|
|
17
|
+
/** live 文本推送的独立去抖(§4.9),与 evaluate 的 OUTPUT_DEBOUNCE_MS 互不干扰。 */
|
|
18
|
+
const LIVE_NOTIFY_DEBOUNCE_MS = 500;
|
|
16
19
|
const SWEEP_INTERVAL_MS = 5000;
|
|
17
20
|
const MAX_FORMAT_RETRIES = 2;
|
|
18
21
|
/** 同一个候选累计这么多次 startup-timeout 就按五元组拉黑(§3.4 黑名单 agents 层)。 */
|
|
@@ -22,6 +25,9 @@ export const AI_TEAM_CHAT_PREFIX = "ai-team-chat:";
|
|
|
22
25
|
/** 等批准时,群里回复这些词就是批准,其余内容都当修改意见。 */
|
|
23
26
|
const APPROVE_REPLY = /^(批准|同意|可以|开始|开干|好的?|ok|okay|yes|lgtm|approve)[。.!!]*$/i;
|
|
24
27
|
const STOP_REPLY = /^(停止|停下|stop)[。.!!]*$/i;
|
|
28
|
+
/** 步数用完后,纯「继续」保留尚未执行的计划;带新要求的回复交回负责人安排。 */
|
|
29
|
+
const CONTINUE_REPLY = /^(继续|让团队继续|继续执行|接着做|接着干|continue)[。.!!]*$/i;
|
|
30
|
+
const STEP_LIMIT_DETAIL = "已达到步数上限";
|
|
25
31
|
/** 用户可见的冲突错误(路由转 409)。 */
|
|
26
32
|
export class AiTeamConflictError extends Error {
|
|
27
33
|
}
|
|
@@ -71,22 +77,42 @@ export function createAiTeamRunner(deps) {
|
|
|
71
77
|
ops: createAiTeamSessionOps(deps),
|
|
72
78
|
chat: createAiTeamChatOps(deps),
|
|
73
79
|
notify: deps.notify,
|
|
80
|
+
notifyLive: deps.notifyLive,
|
|
74
81
|
models: deps.models,
|
|
82
|
+
defaultModelOf: deps.defaultModelOf,
|
|
75
83
|
});
|
|
76
84
|
deps.structured?.registerRelay(AI_TEAM_CHAT_PREFIX, (sessionId, text) => runner.chatInput(sessionId, text));
|
|
77
85
|
return runner;
|
|
78
86
|
}
|
|
79
87
|
/**
|
|
80
88
|
* 群聊里的发言人。上传的头像是 data URL,每条消息都带一份太重,改用按 id 取的像素猫。
|
|
81
|
-
* `
|
|
89
|
+
* `agent` 传该步**实际使用候选**(§3.6/B15,降级换候选后跟着变),model / thinkingEffort 与
|
|
90
|
+
* provider 同源;Leader 或没有步骤的场景回退首选候选。只填真值,展示文案归客户端。
|
|
82
91
|
*/
|
|
83
|
-
function
|
|
92
|
+
export function displayAiTeam(snapshot, current) {
|
|
93
|
+
if (!current)
|
|
94
|
+
return snapshot;
|
|
95
|
+
const byId = new Map(current.members.map((member) => [member.id, member]));
|
|
96
|
+
return {
|
|
97
|
+
...snapshot,
|
|
98
|
+
name: current.name,
|
|
99
|
+
updatedAt: current.updatedAt,
|
|
100
|
+
members: snapshot.members.map((member) => {
|
|
101
|
+
const live = byId.get(member.id);
|
|
102
|
+
return live ? { ...member, name: live.name, avatar: live.avatar } : member;
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function chatAuthor(member, sessionId, agent) {
|
|
107
|
+
const used = agent ?? memberAgents(member)[0] ?? member.agent;
|
|
84
108
|
return {
|
|
85
109
|
id: member.id,
|
|
86
110
|
name: member.name,
|
|
87
111
|
avatar: member.avatar && !member.avatar.startsWith("data:") ? member.avatar : undefined,
|
|
88
112
|
leader: member.isLeader || undefined,
|
|
89
|
-
provider:
|
|
113
|
+
provider: used?.provider ?? member.agent.provider,
|
|
114
|
+
model: used?.model?.trim() || undefined,
|
|
115
|
+
thinkingEffort: used?.thinkingEffort || undefined,
|
|
90
116
|
sessionId: sessionId ?? undefined,
|
|
91
117
|
};
|
|
92
118
|
}
|
|
@@ -150,6 +176,73 @@ function turnText(turn) {
|
|
|
150
176
|
.filter(Boolean)
|
|
151
177
|
.join("\n\n");
|
|
152
178
|
}
|
|
179
|
+
// ── 群聊开场与协作流转的文案(设计 v2.1 S1–S5)──
|
|
180
|
+
// 全部由服务端纯函数拼好:客户端只做 @ 高亮,不拼句子、不猜字段;也**不引任何 LLM 文本**。
|
|
181
|
+
/** 每行邀请名单上限(S2/S3):`邀请 @a、@b、@c、@d 加入群聊`。 */
|
|
182
|
+
export const CHAT_INVITE_PER_LINE = 4;
|
|
183
|
+
/** 开工发言与派工行共用的步骤说法:`第 2 步「实现 Web 端」`;标题空白时只剩步数。 */
|
|
184
|
+
function chatStepLabel(seq, title) {
|
|
185
|
+
const label = title.trim();
|
|
186
|
+
return label ? `第 ${seq} 步「${label}」` : `第 ${seq} 步`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 首次建群的入群序列(S1–S3):负责人先入群,再按 `team.members` 原顺序邀请其余成员。
|
|
190
|
+
* 每行最多 [CHAT_INVITE_PER_LINE] 人,第二行起用 `继续邀请 …`;除负责人外没人时给
|
|
191
|
+
* `还没有邀请其他成员入群`(仍两条,不伪造成员)。
|
|
192
|
+
*/
|
|
193
|
+
export function chatIntroLines(team) {
|
|
194
|
+
const teamName = team.name.trim();
|
|
195
|
+
const lines = [teamName ? `创建了团队群聊「${teamName}」` : "创建了团队群聊"];
|
|
196
|
+
const invitees = team.members.filter((member) => !member.isLeader)
|
|
197
|
+
.map((member) => member.name.trim())
|
|
198
|
+
.filter(Boolean);
|
|
199
|
+
if (invitees.length === 0) {
|
|
200
|
+
lines.push("还没有邀请其他成员入群");
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
for (let index = 0; index < invitees.length; index += CHAT_INVITE_PER_LINE) {
|
|
204
|
+
const names = invitees.slice(index, index + CHAT_INVITE_PER_LINE).map((name) => `@${name}`).join("、");
|
|
205
|
+
lines.push(`${index === 0 ? "邀请" : "继续邀请"} ${names} 加入群聊`);
|
|
206
|
+
}
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 开工发言正文(S4):
|
|
211
|
+
* - 第 1 行 `我正在开始工作:第 N 步「标题」`;
|
|
212
|
+
* - 有依赖时第 2 行 `依据 @上游成员 第 M 步「上游标题」的产物 <reportPath>`,超过两条
|
|
213
|
+
* 只列前两条并在尾上 `等 N 步`(N = 依赖总数);
|
|
214
|
+
* - 依赖解析不到(旧数据/步骤被删)→ `依据上游步骤的产物继续`。
|
|
215
|
+
*/
|
|
216
|
+
export function stepStartText(step, upstream = [], dependencyCount = upstream.length) {
|
|
217
|
+
const lines = [`我正在开始工作:${chatStepLabel(step.seq, step.title)}`];
|
|
218
|
+
if (dependencyCount > 0)
|
|
219
|
+
lines.push(stepBasisLine(upstream, dependencyCount));
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
function stepBasisLine(upstream, dependencyCount) {
|
|
223
|
+
const shown = upstream.slice(0, 2);
|
|
224
|
+
if (shown.length === 0)
|
|
225
|
+
return "依据上游步骤的产物继续";
|
|
226
|
+
const parts = shown.map((item) => {
|
|
227
|
+
const reportPath = item.reportPath.trim();
|
|
228
|
+
return `@${item.memberName} ${chatStepLabel(item.seq, item.title)}的产物${reportPath ? ` ${reportPath}` : ""}`;
|
|
229
|
+
});
|
|
230
|
+
return `依据 ${parts.join("、")}${dependencyCount > shown.length ? ` 等 ${dependencyCount} 步` : ""}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 派工行的依据括注(S5):`(依据:第 1 步「设计规格」的产物)`,多个依赖用 `、` 连接。
|
|
234
|
+
* 位置 `at` 是**本批 steps 内**的 0-based 下标(展示成第 at+1 步);没有依赖就是空串(首步无依据)。
|
|
235
|
+
* 它**取代**旧的 `(等第 N 项完成后)`:同一个依赖关系不写两遍。
|
|
236
|
+
*/
|
|
237
|
+
export function assignmentBasisNote(after, titles) {
|
|
238
|
+
if (after.length === 0)
|
|
239
|
+
return "";
|
|
240
|
+
const parts = after.map((at) => {
|
|
241
|
+
const label = (titles[at] ?? "").trim();
|
|
242
|
+
return `第 ${at + 1} 步${label ? `「${label}」` : ""}的产物`;
|
|
243
|
+
});
|
|
244
|
+
return `(依据:${parts.join("、")})`;
|
|
245
|
+
}
|
|
153
246
|
/** 找到提示词(含 reportPath)那一轮之后的 assistant 文本;没有回复返回 null。 */
|
|
154
247
|
function assistantReplyAfterPrompt(snapshot, reportPath) {
|
|
155
248
|
const messages = snapshot.messages ?? [];
|
|
@@ -168,6 +261,28 @@ function assistantReplyAfterPrompt(snapshot, reportPath) {
|
|
|
168
261
|
return null;
|
|
169
262
|
return replies.map(turnText).filter(Boolean).join("\n\n");
|
|
170
263
|
}
|
|
264
|
+
/** 只看本步提示词之后的真实输出;structured 的失败提示是系统生成的,不是模型回答。 */
|
|
265
|
+
function hasStepAssistantReply(snapshot, reportPath, errorMessage) {
|
|
266
|
+
const messages = snapshot.messages ?? [];
|
|
267
|
+
let promptIndex = -1;
|
|
268
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
269
|
+
const turn = messages[index];
|
|
270
|
+
if (turn.role === "user" && turnText(turn).includes(reportPath)) {
|
|
271
|
+
promptIndex = index;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// 无法确认本步起点时,不把历史会话误判成「启动失败」后再次派工。
|
|
276
|
+
if (promptIndex < 0)
|
|
277
|
+
return true;
|
|
278
|
+
const failureText = errorMessage ? `结构化会话执行失败:${errorMessage}` : null;
|
|
279
|
+
return messages.slice(promptIndex + 1).some((turn) => {
|
|
280
|
+
if (turn.role !== "assistant")
|
|
281
|
+
return false;
|
|
282
|
+
return !failureText || turn.content.length !== 1
|
|
283
|
+
|| turn.content[0]?.type !== "text" || turn.content[0].text !== failureText;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
171
286
|
function readReport(file) {
|
|
172
287
|
const buffer = readFileSync(file);
|
|
173
288
|
if (buffer.length <= REPORT_MAX_BYTES)
|
|
@@ -203,10 +318,16 @@ export class AiTeamRunner {
|
|
|
203
318
|
chat;
|
|
204
319
|
resolveCwd;
|
|
205
320
|
notifyListener;
|
|
321
|
+
notifyLiveListener;
|
|
206
322
|
modelsOf;
|
|
323
|
+
defaultModelOf;
|
|
207
324
|
now;
|
|
208
325
|
chains = new Map();
|
|
209
326
|
outputTimers = new Map();
|
|
327
|
+
/** live 推送的去抖计时器,按 runId 计(§4.9)。 */
|
|
328
|
+
liveTimers = new Map();
|
|
329
|
+
/** 上次推出去的 live 列表指纹(§4.9):stepId + text + omittedChars + state 全同就不重复推。 */
|
|
330
|
+
lastLiveKey = new Map();
|
|
210
331
|
recheckTimers = new Map();
|
|
211
332
|
/** 已经确定「不降级、按既有路径记 failed」的步骤,防止 finishStep 再判一次形成回环。 */
|
|
212
333
|
noDegrade = new Set();
|
|
@@ -219,9 +340,13 @@ export class AiTeamRunner {
|
|
|
219
340
|
this.chat = options.chat;
|
|
220
341
|
this.resolveCwd = options.resolveCwd;
|
|
221
342
|
this.notifyListener = options.notify;
|
|
343
|
+
this.notifyLiveListener = options.notifyLive;
|
|
222
344
|
this.modelsOf = options.models;
|
|
345
|
+
this.defaultModelOf = options.defaultModelOf;
|
|
223
346
|
this.now = options.now ?? Date.now;
|
|
224
347
|
}
|
|
348
|
+
/** 提示词里的模型名解析器:`default` 哨兵交给服务端配置的默认模型。 */
|
|
349
|
+
defaultModelName = (provider) => this.defaultModelOf?.(provider)?.trim() ?? "";
|
|
225
350
|
/** 本 runner 的遮蔽参照目录(§4.5)。 */
|
|
226
351
|
get redactionDirs() {
|
|
227
352
|
if (!this.redactionDirsValue) {
|
|
@@ -288,7 +413,7 @@ export class AiTeamRunner {
|
|
|
288
413
|
excludeReportDir(cwd);
|
|
289
414
|
await this.enqueue(run.id, async () => {
|
|
290
415
|
const step = this.createLeaderStep(run, "制定计划");
|
|
291
|
-
await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistory)));
|
|
416
|
+
await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistory, this.defaultModelName)));
|
|
292
417
|
});
|
|
293
418
|
return this.detail(run.id);
|
|
294
419
|
}
|
|
@@ -327,6 +452,16 @@ export class AiTeamRunner {
|
|
|
327
452
|
if (options.echo !== false)
|
|
328
453
|
this.postUser(run, note);
|
|
329
454
|
run.formatRetries = 0;
|
|
455
|
+
if (run.statusDetail === STEP_LIMIT_DETAIL) {
|
|
456
|
+
// 群聊的「继续」也要真正增加这次运行的预算,且不能作废尚未派出的计划。
|
|
457
|
+
this.extendStepLimit(run, 10);
|
|
458
|
+
this.setStatus(run, "running", "");
|
|
459
|
+
if (CONTINUE_REPLY.test(note))
|
|
460
|
+
await this.advance(run);
|
|
461
|
+
else
|
|
462
|
+
await this.startLeaderRound(run, note);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
330
465
|
this.refreshTeam(run);
|
|
331
466
|
this.setStatus(run, "running", "");
|
|
332
467
|
await this.startLeaderRound(run, note);
|
|
@@ -335,14 +470,22 @@ export class AiTeamRunner {
|
|
|
335
470
|
continueRun(runId, extraSteps) {
|
|
336
471
|
return this.act(runId, async (run) => {
|
|
337
472
|
this.requireStatus(run, "waiting_user", "团队当前没有在等你。");
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
this.
|
|
473
|
+
if (run.statusDetail !== STEP_LIMIT_DETAIL) {
|
|
474
|
+
throw new AiTeamConflictError("团队正在等你回复,当前不是步数上限暂停。");
|
|
475
|
+
}
|
|
476
|
+
this.extendStepLimit(run, extraSteps);
|
|
342
477
|
this.setStatus(run, "running", "");
|
|
343
478
|
await this.advance(run);
|
|
344
479
|
});
|
|
345
480
|
}
|
|
481
|
+
/** 已有运行的上限独立持久化;修改团队定义后,下次续跑至少采用新的团队上限。 */
|
|
482
|
+
extendStepLimit(run, extraSteps) {
|
|
483
|
+
const extra = Math.min(50, Math.max(5, Math.round(extraSteps) || 10));
|
|
484
|
+
this.refreshTeam(run);
|
|
485
|
+
run.stepLimit = Math.max(run.stepLimit, run.stepsUsed) + extra;
|
|
486
|
+
run.stepLimit = Math.max(run.stepLimit, run.team.maxSteps);
|
|
487
|
+
this.postNotice(run, `步数上限加到 ${run.stepLimit},继续执行`);
|
|
488
|
+
}
|
|
346
489
|
completeStep(runId, stepId, report) {
|
|
347
490
|
return this.act(runId, async (run) => {
|
|
348
491
|
const step = this.storage.listAiTeamSteps(run.id).find((item) => item.id === stepId);
|
|
@@ -372,6 +515,8 @@ export class AiTeamRunner {
|
|
|
372
515
|
}
|
|
373
516
|
this.postNotice(run, "团队已停止。在群里发消息可以让团队接着处理。");
|
|
374
517
|
this.setStatus(run, "stopped", "已由用户停止");
|
|
518
|
+
// 步骤全变 skipped:推一次空的 live 列表让卡片收尾(§4.9)。
|
|
519
|
+
this.pushLive(run.id);
|
|
375
520
|
});
|
|
376
521
|
}
|
|
377
522
|
/**
|
|
@@ -429,16 +574,106 @@ export class AiTeamRunner {
|
|
|
429
574
|
}
|
|
430
575
|
// 群聊回合只给最近这一段(§4.4);没有群聊会话的旧运行给空数组,前端不用判 null。
|
|
431
576
|
const messages = run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [];
|
|
577
|
+
const currentTeam = this.storage.getAiTeam(run.teamId);
|
|
432
578
|
return {
|
|
433
579
|
run,
|
|
434
580
|
steps,
|
|
435
581
|
memberStates,
|
|
582
|
+
// 别把展示字段写回 run.team:运行快照里的执行候选/职责由 runner 独立管理。
|
|
583
|
+
displayTeam: displayAiTeam(run.team, currentTeam),
|
|
436
584
|
chatTurns: messages.slice(-AI_TEAM_DETAIL_CHAT_TURNS),
|
|
437
585
|
};
|
|
438
586
|
}
|
|
439
587
|
listForTask(taskId) {
|
|
440
588
|
return this.storage.listAiTeamRuns({ taskId });
|
|
441
589
|
}
|
|
590
|
+
/**
|
|
591
|
+
* 此刻正在干活的步骤的实时文本(§4.9)。只覆盖 running 且有会话的步骤,按 seq 升序;
|
|
592
|
+
* 快照拿不到的步骤跳过而不抛。run 不存在时与 detail() 同口径抛错。
|
|
593
|
+
*/
|
|
594
|
+
live(runId) {
|
|
595
|
+
const run = this.storage.getAiTeamRun(runId);
|
|
596
|
+
if (!run)
|
|
597
|
+
throw new Error("团队运行不存在。");
|
|
598
|
+
return this.liveSteps(run);
|
|
599
|
+
}
|
|
600
|
+
liveSteps(run) {
|
|
601
|
+
const steps = [];
|
|
602
|
+
for (const step of this.storage.listAiTeamSteps(run.id)) {
|
|
603
|
+
if (step.status !== "running" || !step.sessionId)
|
|
604
|
+
continue;
|
|
605
|
+
const snapshot = this.ops.snapshot(step.sessionId);
|
|
606
|
+
if (!snapshot)
|
|
607
|
+
continue;
|
|
608
|
+
steps.push(this.liveStep(run, step, snapshot));
|
|
609
|
+
}
|
|
610
|
+
return steps;
|
|
611
|
+
}
|
|
612
|
+
liveStep(run, step, snapshot) {
|
|
613
|
+
const member = this.member(run.team, step.memberId);
|
|
614
|
+
const agent = member ? this.actualAgent(member, step) : null;
|
|
615
|
+
const provider = agent?.provider ?? snapshot.provider ?? leaderOf(run.team).agent.provider;
|
|
616
|
+
const rendered = renderLiveStepText(snapshot.messages ?? [], snapshot.output ?? "", {
|
|
617
|
+
// pty bridge 只给「claude 且 CLI 已激活」的 PTY 会话挂载(`process-manager.ts` 的
|
|
618
|
+
// `initializeClaudeBridge` 首行 `record.provider !== "claude" || !record.providerCliActive`
|
|
619
|
+
// 就早退),其余 PTY 在流式期 `messages` 不增长,里面留着的是上一条 turn 的过期文本
|
|
620
|
+
// → 一律以终端 output 尾部为准。structured 有流式 messages,照旧。
|
|
621
|
+
preferOutput: this.ops.ownerOf(step.sessionId) === "pty"
|
|
622
|
+
&& !(provider === "claude" && snapshot.providerCliActive === true),
|
|
623
|
+
});
|
|
624
|
+
return {
|
|
625
|
+
stepId: step.id,
|
|
626
|
+
seq: step.seq,
|
|
627
|
+
memberId: step.memberId,
|
|
628
|
+
memberName: member?.name ?? step.memberId,
|
|
629
|
+
provider,
|
|
630
|
+
model: agent?.model?.trim() || undefined,
|
|
631
|
+
thinkingEffort: agent?.thinkingEffort || undefined,
|
|
632
|
+
sessionId: step.sessionId,
|
|
633
|
+
state: activityState(snapshot),
|
|
634
|
+
text: rendered.text,
|
|
635
|
+
omittedChars: rendered.omittedChars,
|
|
636
|
+
updatedAt: this.iso(),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/** 挂一次 run 级 live 去抖:同一 run 每 500ms 至多算一遍、推一次,与 evaluate 的计时器互不干扰。 */
|
|
640
|
+
scheduleLivePush(runId) {
|
|
641
|
+
if (!this.notifyLiveListener || this.liveTimers.has(runId))
|
|
642
|
+
return;
|
|
643
|
+
const timer = setTimeout(() => {
|
|
644
|
+
this.liveTimers.delete(runId);
|
|
645
|
+
void this.enqueue(runId, async () => { this.pushLive(runId); });
|
|
646
|
+
}, LIVE_NOTIFY_DEBOUNCE_MS);
|
|
647
|
+
timer.unref?.();
|
|
648
|
+
this.liveTimers.set(runId, timer);
|
|
649
|
+
}
|
|
650
|
+
/** 推一次 live 列表(§4.9);指纹(stepId + text + omittedChars + state)没变就不重复推,推送失败只忽略。 */
|
|
651
|
+
pushLive(runId) {
|
|
652
|
+
if (!this.notifyLiveListener)
|
|
653
|
+
return;
|
|
654
|
+
const run = this.storage.getAiTeamRun(runId);
|
|
655
|
+
if (!run)
|
|
656
|
+
return;
|
|
657
|
+
const steps = this.liveSteps(run);
|
|
658
|
+
const key = steps
|
|
659
|
+
.map((step) => `${step.stepId}\u0000${step.text}\u0000${step.omittedChars}\u0000${step.state}`)
|
|
660
|
+
.join("\u0001");
|
|
661
|
+
if (this.lastLiveKey.get(runId) === key)
|
|
662
|
+
return;
|
|
663
|
+
if (AI_TEAM_TERMINAL_RUN_STATUSES.includes(run.status)) {
|
|
664
|
+
// 终态的收尾推送之后不再留指纹:留着就没人清,长跑服务会按 run 数攒字符串(§4.9)。
|
|
665
|
+
this.lastLiveKey.delete(runId);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
this.lastLiveKey.set(runId, key);
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
this.notifyLiveListener({ runId, taskId: run.taskId, steps });
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
// 通知失败不影响调度;客户端靠下一次事件或轮询端点补齐。
|
|
675
|
+
}
|
|
676
|
+
}
|
|
442
677
|
/** 团队页的运行记录;teamId 为空时列出所有团队(侧边栏计数用)。 */
|
|
443
678
|
listRuns(filter) {
|
|
444
679
|
const runs = this.storage.listAiTeamRuns({
|
|
@@ -461,6 +696,9 @@ export class AiTeamRunner {
|
|
|
461
696
|
const step = this.storage.getRunningAiTeamStepBySession(event.sessionId);
|
|
462
697
|
if (!step)
|
|
463
698
|
return;
|
|
699
|
+
// 四类事件都挂上 live 去抖:权限/提问这类状态变化是靠 status、task 事件进来的,
|
|
700
|
+
// 只挂在 output 上,芯片就只能等下一次 ai-team-run 重拉才更新(§4.9)。
|
|
701
|
+
this.scheduleLivePush(step.runId);
|
|
464
702
|
if (event.type === "output") {
|
|
465
703
|
if (this.outputTimers.has(event.sessionId))
|
|
466
704
|
return;
|
|
@@ -495,13 +733,21 @@ export class AiTeamRunner {
|
|
|
495
733
|
}
|
|
496
734
|
this.startSweep();
|
|
497
735
|
}
|
|
498
|
-
/**
|
|
736
|
+
/**
|
|
737
|
+
* 兜底巡检:PTY 成员写完报告后可能不再产生事件。
|
|
738
|
+
* 顺带对每个 run 强制对账一次 live(§4.9):registry 内部状态静默翻转时一条事件都不会进来,
|
|
739
|
+
* 状态芯片只能等客户端下一次重拉;这里在同一串里重算列表走 pushLive,指纹变了才推,
|
|
740
|
+
* 没变就什么都不发。巡检间隔与 evaluate 行为都不动。
|
|
741
|
+
*/
|
|
499
742
|
startSweep() {
|
|
500
743
|
if (this.sweepTimer)
|
|
501
744
|
return;
|
|
502
745
|
this.sweepTimer = setInterval(() => {
|
|
503
746
|
for (const run of this.storage.listAiTeamRuns({ statuses: ["running"] })) {
|
|
504
|
-
void this.enqueue(run.id, () =>
|
|
747
|
+
void this.enqueue(run.id, async () => {
|
|
748
|
+
this.pushLive(run.id);
|
|
749
|
+
await this.evaluate(run.id);
|
|
750
|
+
});
|
|
505
751
|
}
|
|
506
752
|
}, SWEEP_INTERVAL_MS);
|
|
507
753
|
this.sweepTimer.unref?.();
|
|
@@ -514,8 +760,12 @@ export class AiTeamRunner {
|
|
|
514
760
|
clearTimeout(timer);
|
|
515
761
|
for (const timer of this.recheckTimers.values())
|
|
516
762
|
clearTimeout(timer);
|
|
763
|
+
for (const timer of this.liveTimers.values())
|
|
764
|
+
clearTimeout(timer);
|
|
517
765
|
this.outputTimers.clear();
|
|
518
766
|
this.recheckTimers.clear();
|
|
767
|
+
this.liveTimers.clear();
|
|
768
|
+
this.lastLiveKey.clear();
|
|
519
769
|
this.noDegrade.clear();
|
|
520
770
|
}
|
|
521
771
|
/** 等当前排队的所有操作跑完(测试用)。 */
|
|
@@ -628,8 +878,13 @@ export class AiTeamRunner {
|
|
|
628
878
|
}
|
|
629
879
|
return { kind: "pending" };
|
|
630
880
|
}
|
|
631
|
-
/**
|
|
881
|
+
/** 步骤落地后补推一次 live(§4.9):让卡片收尾,列表可能已空。内部抛错时不推。 */
|
|
632
882
|
async finishStep(run, step, outcome, proceed = true) {
|
|
883
|
+
await this.finishStepCore(run, step, outcome, proceed);
|
|
884
|
+
this.pushLive(run.id);
|
|
885
|
+
}
|
|
886
|
+
/** proceed=false 时只记结果不推进,由调用方在记完一批之后统一 advance。 */
|
|
887
|
+
async finishStepCore(run, step, outcome, proceed = true) {
|
|
633
888
|
const endedAt = this.iso();
|
|
634
889
|
// §3.4 触发点 2(异步主通道):必须在 stepsUsed 递增之前判,否则首个失败候选白扣一步预算。
|
|
635
890
|
if (step.kind === "work" && outcome.kind === "failed" && outcome.sessionError
|
|
@@ -648,7 +903,7 @@ export class AiTeamRunner {
|
|
|
648
903
|
if (member) {
|
|
649
904
|
this.postTurn(run, {
|
|
650
905
|
role: "assistant",
|
|
651
|
-
author: chatAuthor(member, step.sessionId, this.actualAgent(member, step)
|
|
906
|
+
author: chatAuthor(member, step.sessionId, this.actualAgent(member, step)),
|
|
652
907
|
content: chatText(`${outcome.kind === "done" ? "✅ 完成" : "❌ 没完成"}「${step.title}」\n\n${report}`),
|
|
653
908
|
});
|
|
654
909
|
}
|
|
@@ -662,7 +917,7 @@ export class AiTeamRunner {
|
|
|
662
917
|
if (outcome.kind === "failed" && outcome.sessionError) {
|
|
663
918
|
this.saveStep({ ...step, status: "failed", report: outcome.text, endedAt });
|
|
664
919
|
this.postTurn(run, {
|
|
665
|
-
role: "assistant", notice: true, author: chatAuthor(leader, step.sessionId),
|
|
920
|
+
role: "assistant", notice: true, author: chatAuthor(leader, step.sessionId, this.actualAgent(leader, step)),
|
|
666
921
|
content: chatText(`负责人的模型出错:${outcome.text}\n可以去团队页换个模型,然后在群里回复一句让团队继续。`),
|
|
667
922
|
});
|
|
668
923
|
this.setStatus(run, "waiting_user", `负责人出错:${outcome.text}`);
|
|
@@ -683,7 +938,7 @@ export class AiTeamRunner {
|
|
|
683
938
|
this.postNotice(run, `负责人的回复没能读懂,让他重答(${run.formatRetries}/${MAX_FORMAT_RETRIES})`, step.sessionId);
|
|
684
939
|
this.saveRun(run);
|
|
685
940
|
const retry = this.createLeaderStep(run, "重新回复");
|
|
686
|
-
await this.dispatchStep(run, retry, (reportPath, fresh) => (buildLeaderFormatRetryPrompt(run, parsed.error, reportPath, fresh, this.chatHistoryFor(run))));
|
|
941
|
+
await this.dispatchStep(run, retry, (reportPath, fresh) => (buildLeaderFormatRetryPrompt(run, parsed.error, reportPath, fresh, this.chatHistoryFor(run), this.defaultModelName)));
|
|
687
942
|
return;
|
|
688
943
|
}
|
|
689
944
|
run.formatRetries = 0;
|
|
@@ -796,7 +1051,7 @@ export class AiTeamRunner {
|
|
|
796
1051
|
.map((step) => ({ step, memberName: this.member(run.team, step.memberId)?.name ?? step.memberId }));
|
|
797
1052
|
const step = this.createLeaderStep(run, userNote ? "回应用户" : "安排下一步");
|
|
798
1053
|
const handoffPath = this.writeHandoff(run, step.seq, "leader", finished.map(({ step: item, memberName }) => this.handoffEntry(run, item, memberName)));
|
|
799
|
-
await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, this.chatHistoryFor(run))));
|
|
1054
|
+
await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, this.chatHistoryFor(run), this.defaultModelName)));
|
|
800
1055
|
}
|
|
801
1056
|
/** 一条交接条目:元信息 + 报告全文(全文只进交接文件,不进提示词)。 */
|
|
802
1057
|
handoffEntry(run, step, memberName) {
|
|
@@ -813,6 +1068,35 @@ export class AiTeamRunner {
|
|
|
813
1068
|
* 成员步骤的上游交接:把依赖步骤的报告写成一个交接文件,提示词只给路径。
|
|
814
1069
|
* 没有依赖、或写文件失败时返回 null(提示词仍逐行列出上游报告文件)。
|
|
815
1070
|
*/
|
|
1071
|
+
/**
|
|
1072
|
+
* 开工发言(S4):成员**自己发的真实发言**(头像 + 名字 + 气泡),不是居中 notice;
|
|
1073
|
+
* 文案由 [stepStartText] 拼(依据只来自 `step.dependsOn`,不引 `decision.message` / 报告正文 / 提示词文本)。
|
|
1074
|
+
*/
|
|
1075
|
+
postStepStart(run, step, member, agent, reusable) {
|
|
1076
|
+
this.postTurn(run, {
|
|
1077
|
+
role: "assistant",
|
|
1078
|
+
author: chatAuthor(member, reusable, agent),
|
|
1079
|
+
content: chatText(stepStartText(step, this.upstreamBasis(run, step), step.dependsOn.length)),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* 开工发言的依据:只看 `step.dependsOn`,按依赖顺序解析出上游步骤(没有依赖返回空数组)。
|
|
1084
|
+
* 不调 `memberUpstream`:那个会顺手写交接文件,而这里只要读数据(设计 v2.1 强调不引 LLM 文本)。
|
|
1085
|
+
*/
|
|
1086
|
+
upstreamBasis(run, step) {
|
|
1087
|
+
if (step.dependsOn.length === 0)
|
|
1088
|
+
return [];
|
|
1089
|
+
const byId = new Map(this.storage.listAiTeamSteps(run.id).map((item) => [item.id, item]));
|
|
1090
|
+
return step.dependsOn
|
|
1091
|
+
.map((id) => byId.get(id))
|
|
1092
|
+
.filter((item) => Boolean(item))
|
|
1093
|
+
.map((item) => ({
|
|
1094
|
+
seq: item.seq,
|
|
1095
|
+
title: item.title,
|
|
1096
|
+
memberName: this.member(run.team, item.memberId)?.name ?? item.memberId,
|
|
1097
|
+
reportPath: item.reportPath,
|
|
1098
|
+
}));
|
|
1099
|
+
}
|
|
816
1100
|
memberUpstream(run, step) {
|
|
817
1101
|
if (step.dependsOn.length === 0)
|
|
818
1102
|
return null;
|
|
@@ -896,7 +1180,7 @@ export class AiTeamRunner {
|
|
|
896
1180
|
if (run.stepsUsed < run.stepLimit)
|
|
897
1181
|
return true;
|
|
898
1182
|
this.postNotice(run, `已用完 ${run.stepLimit} 步的上限,团队先停下。回复一句让团队继续,或在任务面板里加步数。`);
|
|
899
|
-
this.setStatus(run, "waiting_user",
|
|
1183
|
+
this.setStatus(run, "waiting_user", STEP_LIMIT_DETAIL);
|
|
900
1184
|
return false;
|
|
901
1185
|
}
|
|
902
1186
|
createLeaderStep(run, title) {
|
|
@@ -952,9 +1236,11 @@ export class AiTeamRunner {
|
|
|
952
1236
|
let running = { ...step, instructions: step.kind === "leader" ? prompt.message : step.instructions, status: "running", startedAt, sessionId: reusable };
|
|
953
1237
|
this.saveStep(running);
|
|
954
1238
|
this.notify(run);
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1239
|
+
// 开工发言(S4):就发在这个位置(原来那条居中 notice 的地方),所以候选起不来的那次尝试
|
|
1240
|
+
// 也会留一句「我正在开始工作」+ 随后的降级/失败出口;作者署名与报告同一套口径,
|
|
1241
|
+
// 复用会话时带真实 sessionId,新建在 open 前未知;若 open 失败,失败报告也可能无 ID。
|
|
1242
|
+
if (member && step.kind === "work")
|
|
1243
|
+
this.postStepStart(run, running, member, agent, reusable);
|
|
958
1244
|
try {
|
|
959
1245
|
if (!member)
|
|
960
1246
|
throw new Error("成员不存在");
|
|
@@ -1078,7 +1364,7 @@ export class AiTeamRunner {
|
|
|
1078
1364
|
this.saveStep(replacement);
|
|
1079
1365
|
// 群聊降级行(§5.3/B15):一次降级一行,署名用切过去的新候选;事前 model-unknown 跳过
|
|
1080
1366
|
// 同样走 degradeWorkStep,所以这里也覆盖它。reason 已经过脱敏 + 头尾截断(§4.5)。
|
|
1081
|
-
this.postNotice(run, `⚠️ ${member.name} 的首选配置不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]
|
|
1367
|
+
this.postNotice(run, `⚠️ ${member.name} 的首选配置不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]);
|
|
1082
1368
|
// ④ 依赖重指向(R1):排队步骤等的仍是「这件事做完」,只是换了承载候选的步骤。
|
|
1083
1369
|
// running / done 的历史步骤不改写。
|
|
1084
1370
|
for (const queued of this.storage.listAiTeamSteps(run.id)) {
|
|
@@ -1116,7 +1402,9 @@ export class AiTeamRunner {
|
|
|
1116
1402
|
const errorMessage = snapshot?.structuredState?.lastError ?? outcomeText;
|
|
1117
1403
|
const kind = classifyStartupFailure({
|
|
1118
1404
|
status: snapshot?.status ?? "failed",
|
|
1119
|
-
hasAssistantReply:
|
|
1405
|
+
hasAssistantReply: snapshot
|
|
1406
|
+
? hasStepAssistantReply(snapshot, step.reportPath, errorMessage)
|
|
1407
|
+
: true,
|
|
1120
1408
|
elapsedMs,
|
|
1121
1409
|
errorMessage,
|
|
1122
1410
|
});
|
|
@@ -1250,8 +1538,11 @@ export class AiTeamRunner {
|
|
|
1250
1538
|
openChat(run, task, objective, chatHistory) {
|
|
1251
1539
|
if (!this.chat)
|
|
1252
1540
|
return;
|
|
1541
|
+
// 「首次建群」只能在这一拍判定:`open()` 之后 run.chatSessionId 就有值了。
|
|
1542
|
+
// 入群序列(S1–S3)只跟这个分支走,续跑/重启接回/同一个群里开新 run 都不重播(v2.1 规则 1)。
|
|
1543
|
+
const firstChat = !run.chatSessionId;
|
|
1253
1544
|
try {
|
|
1254
|
-
if (
|
|
1545
|
+
if (firstChat) {
|
|
1255
1546
|
run.chatSessionId = this.chat.open({ task, run, title: `${run.team.name} · ${task.title}` });
|
|
1256
1547
|
}
|
|
1257
1548
|
}
|
|
@@ -1259,13 +1550,21 @@ export class AiTeamRunner {
|
|
|
1259
1550
|
console.error(`[AiTeam] open chat for ${run.id} failed:`, getErrorMessage(error));
|
|
1260
1551
|
return;
|
|
1261
1552
|
}
|
|
1262
|
-
const roster = run.team.members.map((member) => member.isLeader ? `${member.name}(负责人)` : member.name).join("、");
|
|
1263
1553
|
if (objective)
|
|
1264
1554
|
this.postUser(run, objective);
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1555
|
+
if (!firstChat) {
|
|
1556
|
+
// 续跑时不说「接手」:用户看得出这是同一轮工作往下走,并把交给团队的历史文件摊开给他核。
|
|
1557
|
+
if (chatHistory) {
|
|
1558
|
+
this.postNotice(run, `接着这个群聊里上一轮的进度继续;交给负责人和成员的记录见 ${chatHistory}`);
|
|
1559
|
+
}
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
// 首次建群:负责人先入群,再按名单邀请其余成员(2–3 条系统行,作者都是负责人)。
|
|
1563
|
+
const leader = leaderOf(run.team);
|
|
1564
|
+
const leaderAgent = leader ? memberAgents(leader)[0] ?? leader.agent : undefined;
|
|
1565
|
+
for (const line of chatIntroLines(run.team)) {
|
|
1566
|
+
this.postNotice(run, line, run.chatSessionId, leader, leaderAgent);
|
|
1567
|
+
}
|
|
1269
1568
|
}
|
|
1270
1569
|
postTurn(run, turn) {
|
|
1271
1570
|
if (!this.chat || !run.chatSessionId)
|
|
@@ -1280,10 +1579,10 @@ export class AiTeamRunner {
|
|
|
1280
1579
|
postUser(run, text) {
|
|
1281
1580
|
this.postTurn(run, { role: "user", content: chatText(text) });
|
|
1282
1581
|
}
|
|
1283
|
-
postNotice(run, text, sessionId, member,
|
|
1582
|
+
postNotice(run, text, sessionId, member, agent) {
|
|
1284
1583
|
this.postTurn(run, {
|
|
1285
1584
|
role: "assistant", notice: true, content: chatText(text),
|
|
1286
|
-
author: member ? chatAuthor(member, sessionId,
|
|
1585
|
+
author: member ? chatAuthor(member, sessionId, agent) : undefined,
|
|
1287
1586
|
});
|
|
1288
1587
|
}
|
|
1289
1588
|
/** 负责人的决定:说明 + 派工清单;等批准 / 等回复时告诉用户在群里怎么接话。 */
|
|
@@ -1292,14 +1591,15 @@ export class AiTeamRunner {
|
|
|
1292
1591
|
if (decision.action === "assign") {
|
|
1293
1592
|
const assigned = decision.steps.map((item, index) => {
|
|
1294
1593
|
const name = this.member(run.team, item.memberId)?.name ?? item.memberId;
|
|
1295
|
-
const
|
|
1296
|
-
return `${index + 1}. **@${name}** ${item.title}${
|
|
1594
|
+
const basis = assignmentBasisNote(item.after, decision.steps.map((entry) => entry.title));
|
|
1595
|
+
return `${index + 1}. **@${name}** ${item.title}${basis}`;
|
|
1297
1596
|
});
|
|
1298
1597
|
lines.push(assigned.join("\n"));
|
|
1299
1598
|
}
|
|
1599
|
+
const leader = leaderOf(run.team);
|
|
1300
1600
|
this.postTurn(run, {
|
|
1301
1601
|
role: "assistant",
|
|
1302
|
-
author: chatAuthor(
|
|
1602
|
+
author: chatAuthor(leader, step.sessionId, this.actualAgent(leader, step)),
|
|
1303
1603
|
content: chatText(lines.filter(Boolean).join("\n\n")),
|
|
1304
1604
|
});
|
|
1305
1605
|
if (decision.action === "assign" && !run.planApproved) {
|
|
@@ -1347,6 +1647,9 @@ export class AiTeamRunner {
|
|
|
1347
1647
|
run.status = status;
|
|
1348
1648
|
run.statusDetail = detail;
|
|
1349
1649
|
this.saveRun(run);
|
|
1650
|
+
// 进终态就不可能再有 live 变化,清掉该 run 的推送指纹:不然长跑的服务会按 run 数线性攒字符串。
|
|
1651
|
+
if (AI_TEAM_TERMINAL_RUN_STATUSES.includes(status))
|
|
1652
|
+
this.lastLiveKey.delete(run.id);
|
|
1350
1653
|
}
|
|
1351
1654
|
saveRun(run) {
|
|
1352
1655
|
run.updatedAt = this.iso();
|