@evandu/super-pi 0.1.131 → 0.1.133
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 +18 -0
- package/agents/super-pi-feishu/index.ts +88 -0
- package/agents/super-pi-knowledge/index.ts +1 -1
- package/agents/super-pi-task/core.ts +177 -0
- package/agents/super-pi-task/index.ts +59 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +12 -9
- package/dist/client/assets/CodeMirrorEditor-CZZfGD0U.js +2 -0
- package/dist/client/assets/{dist-B448EWTM.js → dist-0FH-oFco.js} +1 -1
- package/dist/client/assets/{dist-Fhkmvjdx.js → dist-4Pts9xPO.js} +1 -1
- package/dist/client/assets/{dist-BcE6tJvc.js → dist-B5ruVtUG.js} +1 -1
- package/dist/client/assets/{dist-CpuexUlh.js → dist-BJinarhP.js} +1 -1
- package/dist/client/assets/{dist-Cybsoapc.js → dist-BffE00vI.js} +1 -1
- package/dist/client/assets/{dist-DxoWS5MH.js → dist-BtTFCNVQ.js} +1 -1
- package/dist/client/assets/{dist-CtdlYgGc.js → dist-C2nuBCP4.js} +1 -1
- package/dist/client/assets/{dist-hfpd-aWa.js → dist-CApAttfj.js} +1 -1
- package/dist/client/assets/{dist-D6PoSxR5.js → dist-CE5BnwJa.js} +1 -1
- package/dist/client/assets/{dist-COp25nhe.js → dist-CFPXj4ws.js} +1 -1
- package/dist/client/assets/{dist-D7bj81KQ.js → dist-CUKaDTyO.js} +1 -1
- package/dist/client/assets/{dist-BFlq-kiF.js → dist-CYD2SqmG.js} +1 -1
- package/dist/client/assets/{dist-lQWWWxh9.js → dist-CxsunljU.js} +1 -1
- package/dist/client/assets/{dist-BXVFgriA.js → dist-D4x1XTQb.js} +1 -1
- package/dist/client/assets/{dist-DXNDtyHP.js → dist-DHqqpxo4.js} +1 -1
- package/dist/client/assets/{dist-r5Af94tH.js → dist-DOYWHMIq.js} +1 -1
- package/dist/client/assets/{dist-B68Q8uXX.js → dist-DQf5nNxa.js} +1 -1
- package/dist/client/assets/{dist-C6qSkkeN.js → dist-DbbBgSwL.js} +1 -1
- package/dist/client/assets/{dist-B1_1Ui9E.js → dist-LFfokYJs.js} +1 -1
- package/dist/client/assets/{dist-ByIqsb5-.js → dist-aL2NGji_.js} +1 -1
- package/dist/client/assets/{dist-C9AkgNbt.js → dist-azDN8a65.js} +1 -1
- package/dist/client/assets/{dist-DP2ygAFF.js → dist-fYp8Xwrx.js} +1 -1
- package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
- package/dist/client/assets/index-YEN4ysHF.js +100 -0
- package/dist/client/index.html +2 -2
- package/dist/server/agents.js +21 -1
- package/dist/server/application/feishu-service.d.ts +179 -0
- package/dist/server/application/feishu-service.js +358 -0
- package/dist/server/application/run-service.js +15 -1
- package/dist/server/application/scheduled-task-service.d.ts +164 -0
- package/dist/server/application/scheduled-task-service.js +350 -0
- package/dist/server/command-handler.d.ts +25 -1
- package/dist/server/command-handler.js +60 -1
- package/dist/server/feishu-cli.d.ts +223 -0
- package/dist/server/feishu-cli.js +453 -0
- package/dist/server/feishu-config.d.ts +68 -0
- package/dist/server/feishu-config.js +84 -0
- package/dist/server/feishu.d.ts +187 -0
- package/dist/server/feishu.js +695 -0
- package/dist/server/files.js +3 -3
- package/dist/server/http-server.js +232 -1
- package/dist/server/index.js +29 -2
- package/dist/server/knowledge.js +10 -2
- package/dist/server/persistence/app-state-repository.d.ts +13 -1
- package/dist/server/persistence/sqlite-state-repository.js +21 -0
- package/dist/server/pi-session-events.js +10 -2
- package/dist/server/pi-session-pool.d.ts +33 -1
- package/dist/server/pi-session-pool.js +20 -2
- package/dist/server/pi-session.js +3 -0
- package/dist/server/runtime.d.ts +47 -1
- package/dist/server/runtime.js +110 -15
- package/dist/server/scheduled-tasks.d.ts +101 -0
- package/dist/server/scheduled-tasks.js +138 -0
- package/dist/server/server-context.d.ts +16 -0
- package/dist/server/store.d.ts +30 -0
- package/dist/server/store.js +81 -2
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +18 -0
- package/dist/server/types.d.ts +56 -0
- package/package.json +26 -4
- package/dist/client/assets/CodeMirrorEditor-BlQhH3vs.js +0 -2
- package/dist/client/assets/index-BApJ3Zbq.js +0 -98
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { EventBroadcaster } from "../server-context.js";
|
|
2
|
+
import type { Store } from "../store.js";
|
|
3
|
+
import { describeSchedule, type ScheduledTask, type ScheduleSpec } from "../scheduled-tasks.js";
|
|
4
|
+
import type { ModelSelection } from "../types.js";
|
|
5
|
+
import type { ConversationService } from "./conversation-service.js";
|
|
6
|
+
import type { RunService } from "./run-service.js";
|
|
7
|
+
/** The constructor's last parameter: the only seams this service admits. */
|
|
8
|
+
export type ScheduledTaskServiceOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* The TEST SEAM for the clock: how often the sweep runs. Production never passes this and gets the
|
|
11
|
+
* fifteen-second sweep; a test passes a few milliseconds and waits for real time instead of
|
|
12
|
+
* faking timers - the same role `sessionFactory` plays in `PiRpcRuntime`.
|
|
13
|
+
*/
|
|
14
|
+
sweepIntervalMs?: number;
|
|
15
|
+
};
|
|
16
|
+
/** What a caller wants a task to be. Everything but the id and the bookkeeping is optional. */
|
|
17
|
+
export type ScheduledTaskInput = {
|
|
18
|
+
name?: string;
|
|
19
|
+
prompt?: string;
|
|
20
|
+
schedule?: unknown;
|
|
21
|
+
workspaceId?: string;
|
|
22
|
+
model?: ModelSelection | null;
|
|
23
|
+
agentId?: string | null;
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* THE SCHEDULED TASKS SERVICE: the list, the clock, and the runs.
|
|
28
|
+
*
|
|
29
|
+
* WHAT LIVES HERE, AND WHY NOT IN THE STORE. The store owns the record and the disk; this owns the
|
|
30
|
+
* clock and the act of running. Keeping the two apart is what lets the store stay a thing that can be
|
|
31
|
+
* opened, repaired and closed without a timer running behind it - and it is why a scheduled task
|
|
32
|
+
* survives a restart as a RECORD while the thing that fires it is built fresh from that record.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ScheduledTaskService {
|
|
35
|
+
private readonly store;
|
|
36
|
+
private readonly runService;
|
|
37
|
+
private readonly conversationService;
|
|
38
|
+
private readonly broadcaster;
|
|
39
|
+
/** The runs currently in flight, by task id. A task in here is not fired again. */
|
|
40
|
+
private readonly inFlight;
|
|
41
|
+
private timer?;
|
|
42
|
+
private stopped;
|
|
43
|
+
constructor(store: Store, runService: RunService, conversationService: ConversationService, broadcaster: EventBroadcaster, options?: ScheduledTaskServiceOptions);
|
|
44
|
+
private readonly sweepIntervalMs;
|
|
45
|
+
/**
|
|
46
|
+
* Starts the clock.
|
|
47
|
+
*
|
|
48
|
+
* `unref()` so the sweep never holds the process open: the shutdown sequence closes the server and
|
|
49
|
+
* the store, and a pending interval would keep the process alive after that, which is the difference
|
|
50
|
+
* between a server that exits and one that has to be killed.
|
|
51
|
+
*/
|
|
52
|
+
start(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Stops the clock and waits for whatever is running.
|
|
55
|
+
*
|
|
56
|
+
* The in-flight runs are NOT aborted: a task that is halfway through its answer is a real answer in
|
|
57
|
+
* a real conversation, and killing it because the server is shutting down would throw that work
|
|
58
|
+
* away. The sweep stops, the runs finish, and the next start picks the schedule up from what is on
|
|
59
|
+
* disk.
|
|
60
|
+
*/
|
|
61
|
+
stop(): Promise<void>;
|
|
62
|
+
/** Every task on record, newest first. */
|
|
63
|
+
list(): ScheduledTask[];
|
|
64
|
+
/** The tasks of ONE workspace, which is what the panel shows. */
|
|
65
|
+
listForWorkspace(workspaceId: string | undefined): ScheduledTask[];
|
|
66
|
+
/**
|
|
67
|
+
* The task, if it belongs to THIS workspace - and a plain "not found" if it does not.
|
|
68
|
+
*
|
|
69
|
+
* WHY THE OWNERSHIP CHECK IS HERE, IN THE SERVICE. A task is a thing that RUNS in a workspace: it
|
|
70
|
+
* opens its conversation there, uses that workspace's defaults and its tools. A caller in another
|
|
71
|
+
* workspace cannot even list these tasks - `listForWorkspace` is the scoped read - so it must not
|
|
72
|
+
* be able to change or delete them either. Before this, `update` and `delete` acted on an id alone:
|
|
73
|
+
* a task created from workspace B could be rewritten and then deleted by a conversation in
|
|
74
|
+
* workspace A, and it STILL RAN in B, so A's prompt executed inside B with B's model and B's tools
|
|
75
|
+
* - an invisible cross-workspace write (measured by scripts/scheduled-task-scope.test.mjs, ledger
|
|
76
|
+
* TEST-026).
|
|
77
|
+
*
|
|
78
|
+
* THE REFUSAL DOES NOT SAY WHERE IT IS. "Not found" is the whole answer: a caller must not be able
|
|
79
|
+
* to learn that a task exists in a workspace it cannot see.
|
|
80
|
+
*/
|
|
81
|
+
requireForWorkspace(id: string, workspaceId: string): ScheduledTask;
|
|
82
|
+
/**
|
|
83
|
+
* Creates a task.
|
|
84
|
+
*
|
|
85
|
+
* `createdBy` is recorded because the two kinds are not the same thing to a reader: a task the user
|
|
86
|
+
* asked for and a task an agent invented look identical in the list, and the difference is exactly
|
|
87
|
+
* what a person wants to know before they delete it.
|
|
88
|
+
*/
|
|
89
|
+
create(input: ScheduledTaskInput, createdBy: "user" | "agent"): Promise<ScheduledTask>;
|
|
90
|
+
/**
|
|
91
|
+
* Changes a task.
|
|
92
|
+
*
|
|
93
|
+
* EVERY CHANGE RECOMPUTES `nextRunAt`, because a task whose prompt was edited an hour before its
|
|
94
|
+
* slot must not fire at the old slot with the old schedule - and a task whose schedule was edited
|
|
95
|
+
* must not keep the old next time. The one exception is a manual trigger, which is not a change at
|
|
96
|
+
* all: it runs the task once and leaves the clock where it was.
|
|
97
|
+
*/
|
|
98
|
+
update(id: string, input: ScheduledTaskInput): Promise<ScheduledTask>;
|
|
99
|
+
/** Turns a task on or off. */
|
|
100
|
+
setEnabled(id: string, enabled: boolean): Promise<ScheduledTask>;
|
|
101
|
+
/** Removes a task. Its conversation stays: it holds the answers the task produced. */
|
|
102
|
+
remove(id: string): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Runs a task NOW, once.
|
|
105
|
+
*
|
|
106
|
+
* The clock is untouched: a manual run does not make the next scheduled run earlier or later, and it
|
|
107
|
+
* does not count as the task having fired. This is the difference between 「先试一次」 and 「改一下周期」,
|
|
108
|
+
* and conflating them would make 「先试一次」 silently rewrite the user's schedule.
|
|
109
|
+
*
|
|
110
|
+
* THE ACK RETURNS BEFORE THE RUN FINISHES. The run itself takes as long as a model turn takes -
|
|
111
|
+
* seconds, usually, sometimes minutes - and a command that waited for it would hold the socket's
|
|
112
|
+
* whole command chain hostage for that long. The outcome is not lost: it lands in `lastRunStatus`
|
|
113
|
+
* and arrives at the panel through the ordinary state broadcast, where a person reads it.
|
|
114
|
+
*/
|
|
115
|
+
trigger(id: string): Promise<void>;
|
|
116
|
+
/**
|
|
117
|
+
* The sweep: every task whose moment has come runs once.
|
|
118
|
+
*
|
|
119
|
+
* The tasks that are due are collected BEFORE any of them runs, and each one advances its own
|
|
120
|
+
* `nextRunAt` before it runs - so a task that takes three minutes cannot be fired again by the next
|
|
121
|
+
* sweep, and two sweeps can never both pick up the same task.
|
|
122
|
+
*/
|
|
123
|
+
private sweep;
|
|
124
|
+
/**
|
|
125
|
+
* THE ACTUAL RUN, for both the clock and the button.
|
|
126
|
+
*
|
|
127
|
+
* The task gets its OWN conversation, created on the first run, and the prompt is sent into it
|
|
128
|
+
* verbatim as a user message - the same path a person's own message takes. That sameness is the point:
|
|
129
|
+
* a scheduled task has no special powers, so it cannot do anything a message could not, and it gets
|
|
130
|
+
* the same model, the same tools, the same memory.
|
|
131
|
+
*/
|
|
132
|
+
private fire;
|
|
133
|
+
/** The body of a run: get a conversation, put the prompt into it, remember how it went. */
|
|
134
|
+
private runTask;
|
|
135
|
+
/**
|
|
136
|
+
* The conversation the task runs in, created the first time it is needed.
|
|
137
|
+
*
|
|
138
|
+
* LAZY, so that creating a task does not create a conversation nobody has spoken in. The name says
|
|
139
|
+
* what the conversation is, because it appears in the conversation list like any other and the user
|
|
140
|
+
* has to be able to tell it apart from their own work.
|
|
141
|
+
*/
|
|
142
|
+
private ensureConversation;
|
|
143
|
+
/**
|
|
144
|
+
* Puts the task's model and agent on its conversation.
|
|
145
|
+
*
|
|
146
|
+
* EVERY RUN, not only the first: the user can change the model in the panel, and the next run is the
|
|
147
|
+
* one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
|
|
148
|
+
* cannot be silently ignored, because the run would then happen on some other model and the panel
|
|
149
|
+
* would say it succeeded.
|
|
150
|
+
*/
|
|
151
|
+
private applyConfiguration;
|
|
152
|
+
/** Writes down how the run went, and tells the panel. */
|
|
153
|
+
private record;
|
|
154
|
+
/** The task, or a refusal in words the reader can act on. */
|
|
155
|
+
private require;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The schedule in words, for the text an agent reads back after creating a task.
|
|
159
|
+
*
|
|
160
|
+
* Exported from here rather than from `scheduled-tasks.ts` so that the HTTP layer and the WS layer
|
|
161
|
+
* say the same thing about one schedule.
|
|
162
|
+
*/
|
|
163
|
+
export { describeSchedule };
|
|
164
|
+
export type { ScheduledTask, ScheduleSpec };
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { describeSchedule, nextRunAt, normalizeSchedule } from "../scheduled-tasks.js";
|
|
3
|
+
/**
|
|
4
|
+
* HOW OFTEN THE SCHEDULER LOOKS.
|
|
5
|
+
*
|
|
6
|
+
* A sweep every fifteen seconds, rather than one timer per task. A per-task timer would fire closer to
|
|
7
|
+
* the requested minute, and it would need a timer to be created, replaced and destroyed every time a
|
|
8
|
+
* task was edited - a class of bug (a timer left pointing at the old schedule) that a single sweep
|
|
9
|
+
* cannot have. The cost of the sweep is a scan of a list that is bounded at a hundred entries.
|
|
10
|
+
*
|
|
11
|
+
* FIFTEEN SECONDS IS ALSO THE HONEST ANSWER to "how precise is 'every day at 09:00'": a task can fire
|
|
12
|
+
* up to fifteen seconds after the minute it was asked for, and never before it.
|
|
13
|
+
*/
|
|
14
|
+
const SWEEP_INTERVAL_MS = 15_000;
|
|
15
|
+
/**
|
|
16
|
+
* THE SCHEDULED TASKS SERVICE: the list, the clock, and the runs.
|
|
17
|
+
*
|
|
18
|
+
* WHAT LIVES HERE, AND WHY NOT IN THE STORE. The store owns the record and the disk; this owns the
|
|
19
|
+
* clock and the act of running. Keeping the two apart is what lets the store stay a thing that can be
|
|
20
|
+
* opened, repaired and closed without a timer running behind it - and it is why a scheduled task
|
|
21
|
+
* survives a restart as a RECORD while the thing that fires it is built fresh from that record.
|
|
22
|
+
*/
|
|
23
|
+
export class ScheduledTaskService {
|
|
24
|
+
store;
|
|
25
|
+
runService;
|
|
26
|
+
conversationService;
|
|
27
|
+
broadcaster;
|
|
28
|
+
/** The runs currently in flight, by task id. A task in here is not fired again. */
|
|
29
|
+
inFlight = new Map();
|
|
30
|
+
timer;
|
|
31
|
+
stopped = true;
|
|
32
|
+
constructor(store, runService, conversationService, broadcaster, options = {}) {
|
|
33
|
+
this.store = store;
|
|
34
|
+
this.runService = runService;
|
|
35
|
+
this.conversationService = conversationService;
|
|
36
|
+
this.broadcaster = broadcaster;
|
|
37
|
+
this.sweepIntervalMs = options.sweepIntervalMs ?? SWEEP_INTERVAL_MS;
|
|
38
|
+
}
|
|
39
|
+
sweepIntervalMs;
|
|
40
|
+
/**
|
|
41
|
+
* Starts the clock.
|
|
42
|
+
*
|
|
43
|
+
* `unref()` so the sweep never holds the process open: the shutdown sequence closes the server and
|
|
44
|
+
* the store, and a pending interval would keep the process alive after that, which is the difference
|
|
45
|
+
* between a server that exits and one that has to be killed.
|
|
46
|
+
*/
|
|
47
|
+
start() {
|
|
48
|
+
if (this.timer)
|
|
49
|
+
return;
|
|
50
|
+
this.stopped = false;
|
|
51
|
+
this.timer = setInterval(() => { void this.sweep(); }, this.sweepIntervalMs);
|
|
52
|
+
this.timer.unref?.();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Stops the clock and waits for whatever is running.
|
|
56
|
+
*
|
|
57
|
+
* The in-flight runs are NOT aborted: a task that is halfway through its answer is a real answer in
|
|
58
|
+
* a real conversation, and killing it because the server is shutting down would throw that work
|
|
59
|
+
* away. The sweep stops, the runs finish, and the next start picks the schedule up from what is on
|
|
60
|
+
* disk.
|
|
61
|
+
*/
|
|
62
|
+
async stop() {
|
|
63
|
+
this.stopped = true;
|
|
64
|
+
if (this.timer) {
|
|
65
|
+
clearInterval(this.timer);
|
|
66
|
+
this.timer = undefined;
|
|
67
|
+
}
|
|
68
|
+
await Promise.allSettled([...this.inFlight.values()]);
|
|
69
|
+
}
|
|
70
|
+
/** Every task on record, newest first. */
|
|
71
|
+
list() {
|
|
72
|
+
return [...(this.store.snapshot().scheduledTasks ?? [])].sort((a, b) => b.createdAt - a.createdAt);
|
|
73
|
+
}
|
|
74
|
+
/** The tasks of ONE workspace, which is what the panel shows. */
|
|
75
|
+
listForWorkspace(workspaceId) {
|
|
76
|
+
if (!workspaceId)
|
|
77
|
+
return [];
|
|
78
|
+
return this.list().filter((task) => task.workspaceId === workspaceId);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The task, if it belongs to THIS workspace - and a plain "not found" if it does not.
|
|
82
|
+
*
|
|
83
|
+
* WHY THE OWNERSHIP CHECK IS HERE, IN THE SERVICE. A task is a thing that RUNS in a workspace: it
|
|
84
|
+
* opens its conversation there, uses that workspace's defaults and its tools. A caller in another
|
|
85
|
+
* workspace cannot even list these tasks - `listForWorkspace` is the scoped read - so it must not
|
|
86
|
+
* be able to change or delete them either. Before this, `update` and `delete` acted on an id alone:
|
|
87
|
+
* a task created from workspace B could be rewritten and then deleted by a conversation in
|
|
88
|
+
* workspace A, and it STILL RAN in B, so A's prompt executed inside B with B's model and B's tools
|
|
89
|
+
* - an invisible cross-workspace write (measured by scripts/scheduled-task-scope.test.mjs, ledger
|
|
90
|
+
* TEST-026).
|
|
91
|
+
*
|
|
92
|
+
* THE REFUSAL DOES NOT SAY WHERE IT IS. "Not found" is the whole answer: a caller must not be able
|
|
93
|
+
* to learn that a task exists in a workspace it cannot see.
|
|
94
|
+
*/
|
|
95
|
+
requireForWorkspace(id, workspaceId) {
|
|
96
|
+
const task = this.listForWorkspace(workspaceId).find((item) => item.id === id);
|
|
97
|
+
if (!task)
|
|
98
|
+
throw new Error("找不到这个定时任务。");
|
|
99
|
+
return task;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Creates a task.
|
|
103
|
+
*
|
|
104
|
+
* `createdBy` is recorded because the two kinds are not the same thing to a reader: a task the user
|
|
105
|
+
* asked for and a task an agent invented look identical in the list, and the difference is exactly
|
|
106
|
+
* what a person wants to know before they delete it.
|
|
107
|
+
*/
|
|
108
|
+
async create(input, createdBy) {
|
|
109
|
+
const prompt = (input.prompt ?? "").trim();
|
|
110
|
+
if (!prompt)
|
|
111
|
+
throw new Error("定时任务需要一段提示词:到点时这段话会被当作用户消息发给 agent。");
|
|
112
|
+
const workspaceId = (input.workspaceId ?? "").trim();
|
|
113
|
+
if (!workspaceId)
|
|
114
|
+
throw new Error("定时任务需要属于一个工作区。");
|
|
115
|
+
if (!this.store.snapshot().workspaces.some((workspace) => workspace.id === workspaceId))
|
|
116
|
+
throw new Error("找不到这个工作区。");
|
|
117
|
+
const schedule = normalizeSchedule(input.schedule);
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const firstRunAt = nextRunAt(schedule, now);
|
|
120
|
+
// A ONE-SHOT TASK WHOSE MOMENT HAS PASSED is not a task, it is a memory of one. Refusing here is
|
|
121
|
+
// what keeps it from being created with no next run and never firing - a task that exists but
|
|
122
|
+
// cannot run is the worst outcome, because nothing on screen explains why.
|
|
123
|
+
if (schedule.kind === "once" && firstRunAt === undefined)
|
|
124
|
+
throw new Error("一次性任务的触发时刻要在将来。");
|
|
125
|
+
const task = {
|
|
126
|
+
id: `scheduled_${randomUUID()}`,
|
|
127
|
+
name: (input.name ?? "").trim() || defaultName(prompt),
|
|
128
|
+
prompt,
|
|
129
|
+
workspaceId,
|
|
130
|
+
schedule,
|
|
131
|
+
enabled: input.enabled === false ? false : true,
|
|
132
|
+
nextRunAt: firstRunAt,
|
|
133
|
+
createdBy,
|
|
134
|
+
createdAt: now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
...(input.model ? { model: input.model } : {}),
|
|
137
|
+
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
138
|
+
};
|
|
139
|
+
const stored = await this.store.setScheduledTask(task);
|
|
140
|
+
this.broadcaster.broadcastState();
|
|
141
|
+
return stored;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Changes a task.
|
|
145
|
+
*
|
|
146
|
+
* EVERY CHANGE RECOMPUTES `nextRunAt`, because a task whose prompt was edited an hour before its
|
|
147
|
+
* slot must not fire at the old slot with the old schedule - and a task whose schedule was edited
|
|
148
|
+
* must not keep the old next time. The one exception is a manual trigger, which is not a change at
|
|
149
|
+
* all: it runs the task once and leaves the clock where it was.
|
|
150
|
+
*/
|
|
151
|
+
async update(id, input) {
|
|
152
|
+
const task = this.require(id);
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const schedule = input.schedule === undefined ? task.schedule : normalizeSchedule(input.schedule);
|
|
155
|
+
const updated = {
|
|
156
|
+
...task,
|
|
157
|
+
name: input.name === undefined ? task.name : (input.name.trim() || task.name),
|
|
158
|
+
prompt: input.prompt === undefined ? task.prompt : (input.prompt.trim() || task.prompt),
|
|
159
|
+
schedule,
|
|
160
|
+
enabled: input.enabled === undefined ? task.enabled : input.enabled,
|
|
161
|
+
updatedAt: now,
|
|
162
|
+
};
|
|
163
|
+
if (input.model !== undefined) {
|
|
164
|
+
if (input.model)
|
|
165
|
+
updated.model = input.model;
|
|
166
|
+
else
|
|
167
|
+
delete updated.model;
|
|
168
|
+
}
|
|
169
|
+
if (input.agentId !== undefined) {
|
|
170
|
+
if (input.agentId)
|
|
171
|
+
updated.agentId = input.agentId;
|
|
172
|
+
else
|
|
173
|
+
delete updated.agentId;
|
|
174
|
+
}
|
|
175
|
+
// A DISABLED TASK HAS NO NEXT TIME, and an enabled one always has one - unless it is a one-shot
|
|
176
|
+
// whose moment has passed, which is a finished task rather than a broken one.
|
|
177
|
+
updated.nextRunAt = updated.enabled ? nextRunAt(schedule, now) : undefined;
|
|
178
|
+
const stored = await this.store.setScheduledTask(updated);
|
|
179
|
+
this.broadcaster.broadcastState();
|
|
180
|
+
return stored;
|
|
181
|
+
}
|
|
182
|
+
/** Turns a task on or off. */
|
|
183
|
+
async setEnabled(id, enabled) {
|
|
184
|
+
const task = this.require(id);
|
|
185
|
+
if (task.enabled === enabled)
|
|
186
|
+
return task;
|
|
187
|
+
return this.update(id, { enabled });
|
|
188
|
+
}
|
|
189
|
+
/** Removes a task. Its conversation stays: it holds the answers the task produced. */
|
|
190
|
+
async remove(id) {
|
|
191
|
+
this.require(id);
|
|
192
|
+
await this.store.removeScheduledTask(id);
|
|
193
|
+
this.broadcaster.broadcastState();
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Runs a task NOW, once.
|
|
197
|
+
*
|
|
198
|
+
* The clock is untouched: a manual run does not make the next scheduled run earlier or later, and it
|
|
199
|
+
* does not count as the task having fired. This is the difference between 「先试一次」 and 「改一下周期」,
|
|
200
|
+
* and conflating them would make 「先试一次」 silently rewrite the user's schedule.
|
|
201
|
+
*
|
|
202
|
+
* THE ACK RETURNS BEFORE THE RUN FINISHES. The run itself takes as long as a model turn takes -
|
|
203
|
+
* seconds, usually, sometimes minutes - and a command that waited for it would hold the socket's
|
|
204
|
+
* whole command chain hostage for that long. The outcome is not lost: it lands in `lastRunStatus`
|
|
205
|
+
* and arrives at the panel through the ordinary state broadcast, where a person reads it.
|
|
206
|
+
*/
|
|
207
|
+
async trigger(id) {
|
|
208
|
+
const task = this.require(id);
|
|
209
|
+
this.fire(task, "manual").catch(() => undefined);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The sweep: every task whose moment has come runs once.
|
|
213
|
+
*
|
|
214
|
+
* The tasks that are due are collected BEFORE any of them runs, and each one advances its own
|
|
215
|
+
* `nextRunAt` before it runs - so a task that takes three minutes cannot be fired again by the next
|
|
216
|
+
* sweep, and two sweeps can never both pick up the same task.
|
|
217
|
+
*/
|
|
218
|
+
async sweep() {
|
|
219
|
+
if (this.stopped)
|
|
220
|
+
return;
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const due = (this.store.snapshot().scheduledTasks ?? []).filter((task) => task.enabled && task.nextRunAt !== undefined && task.nextRunAt <= now);
|
|
223
|
+
for (const task of due) {
|
|
224
|
+
if (this.inFlight.has(task.id))
|
|
225
|
+
continue;
|
|
226
|
+
const next = nextRunAt(task.schedule, now);
|
|
227
|
+
await this.store.setScheduledTask({
|
|
228
|
+
...task,
|
|
229
|
+
// A ONE-SHOT TASK IS FINISHED once it has fired. It is disabled rather than deleted, because
|
|
230
|
+
// the answers it produced are worth keeping and the user can see that it ran.
|
|
231
|
+
enabled: next === undefined ? false : task.enabled,
|
|
232
|
+
nextRunAt: next,
|
|
233
|
+
updatedAt: now,
|
|
234
|
+
});
|
|
235
|
+
this.broadcaster.broadcastState();
|
|
236
|
+
void this.fire(task, "schedule");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* THE ACTUAL RUN, for both the clock and the button.
|
|
241
|
+
*
|
|
242
|
+
* The task gets its OWN conversation, created on the first run, and the prompt is sent into it
|
|
243
|
+
* verbatim as a user message - the same path a person's own message takes. That sameness is the point:
|
|
244
|
+
* a scheduled task has no special powers, so it cannot do anything a message could not, and it gets
|
|
245
|
+
* the same model, the same tools, the same memory.
|
|
246
|
+
*/
|
|
247
|
+
async fire(task, reason) {
|
|
248
|
+
const running = this.inFlight.get(task.id);
|
|
249
|
+
if (running) {
|
|
250
|
+
await running;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const promise = this.runTask(task, reason).finally(() => { this.inFlight.delete(task.id); });
|
|
254
|
+
this.inFlight.set(task.id, promise);
|
|
255
|
+
await promise;
|
|
256
|
+
}
|
|
257
|
+
/** The body of a run: get a conversation, put the prompt into it, remember how it went. */
|
|
258
|
+
async runTask(task, reason) {
|
|
259
|
+
const startedAt = Date.now();
|
|
260
|
+
try {
|
|
261
|
+
const conversationId = await this.ensureConversation(task);
|
|
262
|
+
await this.runService.enqueueAndRun(conversationId, task.prompt);
|
|
263
|
+
await this.record(task, { status: "completed", message: reason === "manual" ? "手动触发完成" : "按时触发完成", at: startedAt });
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
// A FAILED RUN IS A RECORD, not a silence. The task keeps its schedule - a broken model or a
|
|
267
|
+
// missing workspace is worth retrying at the next slot - and the failure is written where the
|
|
268
|
+
// panel can show it.
|
|
269
|
+
await this.record(task, { status: "failed", message: error instanceof Error ? error.message : String(error), at: startedAt });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The conversation the task runs in, created the first time it is needed.
|
|
274
|
+
*
|
|
275
|
+
* LAZY, so that creating a task does not create a conversation nobody has spoken in. The name says
|
|
276
|
+
* what the conversation is, because it appears in the conversation list like any other and the user
|
|
277
|
+
* has to be able to tell it apart from their own work.
|
|
278
|
+
*/
|
|
279
|
+
async ensureConversation(task) {
|
|
280
|
+
const state = this.store.snapshot();
|
|
281
|
+
const existing = task.conversationId ? state.conversations.find((conversation) => conversation.id === task.conversationId) : undefined;
|
|
282
|
+
if (existing) {
|
|
283
|
+
await this.applyConfiguration(task, existing.id);
|
|
284
|
+
return existing.id;
|
|
285
|
+
}
|
|
286
|
+
const conversationId = await this.conversationService.createConversation(task.workspaceId);
|
|
287
|
+
await this.conversationService.renameConversation(conversationId, `定时:${task.name}`);
|
|
288
|
+
// THE CURRENT ROW, NOT THE STALE ONE. `task` is the snapshot the caller was holding, and its
|
|
289
|
+
// `nextRunAt` may already have been advanced by a sweep that fired this run - writing it back
|
|
290
|
+
// would move the clock backwards and the next sweep would fire the task a second time. The
|
|
291
|
+
// fresh row is read before the write, so the only field this changes is the one this line
|
|
292
|
+
// means to change.
|
|
293
|
+
const current = this.store.snapshot().scheduledTasks?.find((item) => item.id === task.id);
|
|
294
|
+
await this.store.setScheduledTask({ ...(current ?? task), conversationId, updatedAt: Date.now() });
|
|
295
|
+
await this.applyConfiguration(task, conversationId);
|
|
296
|
+
return conversationId;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Puts the task's model and agent on its conversation.
|
|
300
|
+
*
|
|
301
|
+
* EVERY RUN, not only the first: the user can change the model in the panel, and the next run is the
|
|
302
|
+
* one that has to use it. A failure here is the task's failure - a model that Pi no longer offers
|
|
303
|
+
* cannot be silently ignored, because the run would then happen on some other model and the panel
|
|
304
|
+
* would say it succeeded.
|
|
305
|
+
*/
|
|
306
|
+
async applyConfiguration(task, conversationId) {
|
|
307
|
+
if (task.model) {
|
|
308
|
+
await this.conversationService.setModel(conversationId, task.model.provider, task.model.modelId);
|
|
309
|
+
}
|
|
310
|
+
if (task.agentId) {
|
|
311
|
+
await this.conversationService.setAgent(conversationId, task.agentId);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Writes down how the run went, and tells the panel. */
|
|
315
|
+
async record(task, outcome) {
|
|
316
|
+
const current = this.store.snapshot().scheduledTasks?.find((item) => item.id === task.id);
|
|
317
|
+
if (!current)
|
|
318
|
+
return;
|
|
319
|
+
await this.store.setScheduledTask({
|
|
320
|
+
...current,
|
|
321
|
+
lastRunAt: outcome.at,
|
|
322
|
+
lastRunStatus: outcome.status,
|
|
323
|
+
lastRunMessage: outcome.message,
|
|
324
|
+
updatedAt: Date.now(),
|
|
325
|
+
});
|
|
326
|
+
this.broadcaster.broadcastState();
|
|
327
|
+
}
|
|
328
|
+
/** The task, or a refusal in words the reader can act on. */
|
|
329
|
+
require(id) {
|
|
330
|
+
const task = this.store.snapshot().scheduledTasks?.find((item) => item.id === id);
|
|
331
|
+
if (!task)
|
|
332
|
+
throw new Error("找不到这个定时任务。");
|
|
333
|
+
return task;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The name a task gets when nobody gave it one: the first line of its prompt, cut to something that
|
|
338
|
+
* fits on a line in the panel.
|
|
339
|
+
*/
|
|
340
|
+
function defaultName(prompt) {
|
|
341
|
+
const firstLine = prompt.split("\n").map((line) => line.trim()).find(Boolean) ?? prompt.trim();
|
|
342
|
+
return firstLine.length > 24 ? `${firstLine.slice(0, 24)}…` : firstLine;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The schedule in words, for the text an agent reads back after creating a task.
|
|
346
|
+
*
|
|
347
|
+
* Exported from here rather than from `scheduled-tasks.ts` so that the HTTP layer and the WS layer
|
|
348
|
+
* say the same thing about one schedule.
|
|
349
|
+
*/
|
|
350
|
+
export { describeSchedule };
|
|
@@ -5,6 +5,7 @@ import type { RunService } from "./application/run-service.js";
|
|
|
5
5
|
import type { ExtensionUiService } from "./application/extension-ui-service.js";
|
|
6
6
|
import type { CatalogService } from "./application/catalog-service.js";
|
|
7
7
|
import type { ProviderService } from "./application/provider-service.js";
|
|
8
|
+
import type { ScheduledTaskService } from "./application/scheduled-task-service.js";
|
|
8
9
|
import type { TerminalService } from "./terminal.js";
|
|
9
10
|
export type SocketSender = (event: ServerEvent) => void;
|
|
10
11
|
/**
|
|
@@ -25,6 +26,14 @@ export declare class CommandHandler {
|
|
|
25
26
|
* and a missing service has to say so rather than take the whole handler down.
|
|
26
27
|
*/
|
|
27
28
|
private readonly providerService?;
|
|
29
|
+
/**
|
|
30
|
+
* The scheduled tasks, and the clock behind them.
|
|
31
|
+
*
|
|
32
|
+
* Optional because the handler is constructed by tests that have no clock to give it, and a missing
|
|
33
|
+
* service has to say so rather than take the whole handler down - the same rule `providerService`
|
|
34
|
+
* follows.
|
|
35
|
+
*/
|
|
36
|
+
private readonly scheduledTaskService?;
|
|
28
37
|
/**
|
|
29
38
|
* The built-in terminal service.
|
|
30
39
|
*
|
|
@@ -40,7 +49,15 @@ export declare class CommandHandler {
|
|
|
40
49
|
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
41
50
|
* and a missing service has to say so rather than take the whole handler down.
|
|
42
51
|
*/
|
|
43
|
-
providerService?: ProviderService | undefined
|
|
52
|
+
providerService?: ProviderService | undefined,
|
|
53
|
+
/**
|
|
54
|
+
* The scheduled tasks, and the clock behind them.
|
|
55
|
+
*
|
|
56
|
+
* Optional because the handler is constructed by tests that have no clock to give it, and a missing
|
|
57
|
+
* service has to say so rather than take the whole handler down - the same rule `providerService`
|
|
58
|
+
* follows.
|
|
59
|
+
*/
|
|
60
|
+
scheduledTaskService?: ScheduledTaskService | undefined);
|
|
44
61
|
/**
|
|
45
62
|
* Processes a client command.
|
|
46
63
|
*
|
|
@@ -64,4 +81,11 @@ export declare class CommandHandler {
|
|
|
64
81
|
/** The other half of `claimRevert`. Safe to call for a conversation nothing claimed. */
|
|
65
82
|
releaseRevert(conversationId: string): void;
|
|
66
83
|
handleCommand(command: ClientCommand, sendToSocket?: SocketSender, remoteAddress?: string): Promise<string | void>;
|
|
84
|
+
/**
|
|
85
|
+
* The scheduled-task service, or a refusal.
|
|
86
|
+
*
|
|
87
|
+
* A refusal in the client's own words rather than a crash: the panel that sent the command shows
|
|
88
|
+
* the message, and a handler that threw a TypeError would show nothing but a failed operation.
|
|
89
|
+
*/
|
|
90
|
+
private requireScheduledTasks;
|
|
67
91
|
}
|
|
@@ -13,6 +13,7 @@ export class CommandHandler {
|
|
|
13
13
|
catalogService;
|
|
14
14
|
rootDir;
|
|
15
15
|
providerService;
|
|
16
|
+
scheduledTaskService;
|
|
16
17
|
/**
|
|
17
18
|
* The built-in terminal service.
|
|
18
19
|
*
|
|
@@ -28,7 +29,15 @@ export class CommandHandler {
|
|
|
28
29
|
* Optional because the command handler is constructed by tests that do not care about providers,
|
|
29
30
|
* and a missing service has to say so rather than take the whole handler down.
|
|
30
31
|
*/
|
|
31
|
-
providerService
|
|
32
|
+
providerService,
|
|
33
|
+
/**
|
|
34
|
+
* The scheduled tasks, and the clock behind them.
|
|
35
|
+
*
|
|
36
|
+
* Optional because the handler is constructed by tests that have no clock to give it, and a missing
|
|
37
|
+
* service has to say so rather than take the whole handler down - the same rule `providerService`
|
|
38
|
+
* follows.
|
|
39
|
+
*/
|
|
40
|
+
scheduledTaskService) {
|
|
32
41
|
this.workspaceService = workspaceService;
|
|
33
42
|
this.conversationService = conversationService;
|
|
34
43
|
this.runService = runService;
|
|
@@ -36,6 +45,7 @@ export class CommandHandler {
|
|
|
36
45
|
this.catalogService = catalogService;
|
|
37
46
|
this.rootDir = rootDir;
|
|
38
47
|
this.providerService = providerService;
|
|
48
|
+
this.scheduledTaskService = scheduledTaskService;
|
|
39
49
|
this.terminalService = terminalService;
|
|
40
50
|
}
|
|
41
51
|
/**
|
|
@@ -295,6 +305,44 @@ export class CommandHandler {
|
|
|
295
305
|
this.runService.stopWorkflow(command.workflowId);
|
|
296
306
|
return;
|
|
297
307
|
}
|
|
308
|
+
// THE SCHEDULED TASKS, five commands over one list.
|
|
309
|
+
//
|
|
310
|
+
// They are all here rather than in a service of their own because they are all one verb applied to
|
|
311
|
+
// one collection: the service owns the schedule and the clock, and this is the routing table that
|
|
312
|
+
// says which of its methods a command reaches.
|
|
313
|
+
if (command.type === "scheduledTask.create") {
|
|
314
|
+
await this.requireScheduledTasks().create({
|
|
315
|
+
name: command.name,
|
|
316
|
+
prompt: command.prompt,
|
|
317
|
+
schedule: command.schedule,
|
|
318
|
+
workspaceId: command.workspaceId,
|
|
319
|
+
model: command.model,
|
|
320
|
+
agentId: command.agentId,
|
|
321
|
+
}, "user");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (command.type === "scheduledTask.update") {
|
|
325
|
+
await this.requireScheduledTasks().update(command.id, {
|
|
326
|
+
name: command.name,
|
|
327
|
+
prompt: command.prompt,
|
|
328
|
+
schedule: command.schedule,
|
|
329
|
+
model: command.model,
|
|
330
|
+
agentId: command.agentId,
|
|
331
|
+
});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (command.type === "scheduledTask.delete") {
|
|
335
|
+
await this.requireScheduledTasks().remove(command.id);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (command.type === "scheduledTask.trigger") {
|
|
339
|
+
await this.requireScheduledTasks().trigger(command.id);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (command.type === "scheduledTask.toggle") {
|
|
343
|
+
await this.requireScheduledTasks().setEnabled(command.id, command.enabled);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
298
346
|
if (command.type === "extension.ui.answer") {
|
|
299
347
|
const answer = command.cancelled
|
|
300
348
|
? { cancelled: true }
|
|
@@ -396,4 +444,15 @@ export class CommandHandler {
|
|
|
396
444
|
}
|
|
397
445
|
throw new Error(`未知的命令类型:${JSON.stringify(command.type ?? null)}`);
|
|
398
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* The scheduled-task service, or a refusal.
|
|
449
|
+
*
|
|
450
|
+
* A refusal in the client's own words rather than a crash: the panel that sent the command shows
|
|
451
|
+
* the message, and a handler that threw a TypeError would show nothing but a failed operation.
|
|
452
|
+
*/
|
|
453
|
+
requireScheduledTasks() {
|
|
454
|
+
if (!this.scheduledTaskService)
|
|
455
|
+
throw new Error("定时任务功能在这个实例上没有启用。");
|
|
456
|
+
return this.scheduledTaskService;
|
|
457
|
+
}
|
|
399
458
|
}
|