@openshain/core 0.1.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/LICENSE +202 -0
- package/README.md +7 -0
- package/package.json +44 -0
- package/src/config/load.ts +88 -0
- package/src/config/schema.ts +128 -0
- package/src/errors.ts +35 -0
- package/src/ids.ts +32 -0
- package/src/index.ts +102 -0
- package/src/model/types.ts +45 -0
- package/src/runtime.ts +214 -0
- package/src/schemas.ts +68 -0
- package/src/tool/load-module.ts +50 -0
- package/src/tool/paths.ts +85 -0
- package/src/tool/registry.ts +114 -0
- package/src/tool/types.ts +61 -0
- package/src/tool/validate.ts +78 -0
- package/src/work/artifacts.ts +25 -0
- package/src/work/event-log.ts +165 -0
- package/src/work/events.ts +458 -0
- package/src/work/lock.ts +94 -0
- package/src/work/projection.ts +162 -0
- package/src/work/store.ts +216 -0
- package/src/work/work.ts +191 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { Config } from "../config/schema.ts";
|
|
2
|
+
import { OpenshainError } from "../errors.ts";
|
|
3
|
+
import type { ModelMessage, UserPart } from "../model/types.ts";
|
|
4
|
+
import type { ToolDefinition } from "../tool/types.ts";
|
|
5
|
+
import type { AssistantPart } from "./events.ts";
|
|
6
|
+
import { type AnyEvent, canonical, type Event, type ToolContent } from "./events.ts";
|
|
7
|
+
import { SESSION_WORK_TYPE } from "./work.ts";
|
|
8
|
+
|
|
9
|
+
export interface ProjectionInput {
|
|
10
|
+
events: readonly AnyEvent[];
|
|
11
|
+
config: Pick<Config, "company" | "principal" | "profession">;
|
|
12
|
+
/** Tool definitions the model may call. Already filtered by the allow lists. */
|
|
13
|
+
tools: ToolDefinition[];
|
|
14
|
+
/** Opaque parts are returned only to the provider that produced them. */
|
|
15
|
+
providerId: string;
|
|
16
|
+
budget: { modelCallsLeft: number; toolCallsLeft: number };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Projection {
|
|
20
|
+
system: string;
|
|
21
|
+
messages: ModelMessage[];
|
|
22
|
+
tools: ToolDefinition[];
|
|
23
|
+
budget: { modelCallsLeft: number; toolCallsLeft: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the model sees. Built from the event log alone, in order, and therefore
|
|
28
|
+
* the same bytes every time for the same events. Nothing is rewritten: the
|
|
29
|
+
* budget line is a user message of its own at the end.
|
|
30
|
+
*/
|
|
31
|
+
export function buildProjection(input: ProjectionInput): Projection {
|
|
32
|
+
const { config } = input;
|
|
33
|
+
const first = input.events[0];
|
|
34
|
+
const agentName =
|
|
35
|
+
first?.type === "work.created" ? (first as Event<"work.created">).payload.agentName : undefined;
|
|
36
|
+
const system = [
|
|
37
|
+
config.profession.instructions.trim(),
|
|
38
|
+
`この会社は ${config.company.name}。`,
|
|
39
|
+
`依頼する人は ${config.principal.name}(${config.principal.id})。あなたはこの人の代理として働き、この人と話す。あなた自身は ${config.principal.name} ではなく、この会社で働く社員エージェントで、名乗るならそう名乗る。`,
|
|
40
|
+
...(agentName
|
|
41
|
+
? [`あなたの名前は ${agentName}。名乗るときはこの名前と、社員エージェントであることを言う。`]
|
|
42
|
+
: []),
|
|
43
|
+
"件数、合計、検索の結果は Tool が返した値をそのまま使い、自分で数えたり足したりしない。各ターンの最後に Runtime が「残り model 呼び出し N 回、Tool 呼び出し M 回」という 1 行を user message として足す。これは残量の通知で、返事は要らない。依頼が終わったら、何をしたかを要約して終える。",
|
|
44
|
+
].join("\n\n");
|
|
45
|
+
|
|
46
|
+
const messages: ModelMessage[] = [];
|
|
47
|
+
const pushUserPart = (part: UserPart) => {
|
|
48
|
+
const last = messages.at(-1);
|
|
49
|
+
if (last?.role === "user") last.content.push(part);
|
|
50
|
+
else messages.push({ role: "user", content: [part] });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const event of input.events) {
|
|
54
|
+
switch (event.type) {
|
|
55
|
+
case "work.created": {
|
|
56
|
+
// A session's objective is a label; the conversation starts with what the person says.
|
|
57
|
+
const { objective, type } = (event as Event<"work.created">).payload;
|
|
58
|
+
if (type !== SESSION_WORK_TYPE) pushUserPart({ type: "text", text: objective });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "human.message":
|
|
62
|
+
pushUserPart({ type: "text", text: (event as Event<"human.message">).payload.text });
|
|
63
|
+
break;
|
|
64
|
+
case "model.completed": {
|
|
65
|
+
const content = (event as Event<"model.completed">).payload.content
|
|
66
|
+
.filter((part) => part.type !== "opaque" || part.provider === input.providerId)
|
|
67
|
+
.map((part) => canonical(part) as AssistantPart);
|
|
68
|
+
if (content.length > 0) messages.push({ role: "assistant", content });
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case "tool.completed": {
|
|
72
|
+
const { payload } = event as Event<"tool.completed">;
|
|
73
|
+
pushUserPart({
|
|
74
|
+
type: "tool_result",
|
|
75
|
+
callId: payload.callId,
|
|
76
|
+
content: renderContent(payload.content),
|
|
77
|
+
isError: payload.isError,
|
|
78
|
+
});
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "tool.rejected": {
|
|
82
|
+
const { payload } = event as Event<"tool.rejected">;
|
|
83
|
+
pushUserPart({
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
callId: payload.callId,
|
|
86
|
+
content: payload.reason,
|
|
87
|
+
isError: true,
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
default:
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
checkToolPairs(messages);
|
|
97
|
+
|
|
98
|
+
// The budget is a message of its own, so the messages before it keep their bytes from turn to
|
|
99
|
+
// turn and a provider's prompt cache can cover them.
|
|
100
|
+
messages.push({
|
|
101
|
+
role: "user",
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: `残り model 呼び出し ${input.budget.modelCallsLeft} 回、Tool 呼び出し ${input.budget.toolCallsLeft} 回`,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return { system, messages, tools: input.tools, budget: { ...input.budget } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every tool_result must answer a tool_call in the assistant message right
|
|
115
|
+
* before it, and every tool_call must be answered before the conversation goes
|
|
116
|
+
* on. Providers reject anything else, so the log is treated as corrupt.
|
|
117
|
+
*/
|
|
118
|
+
function checkToolPairs(messages: ModelMessage[]): void {
|
|
119
|
+
for (let i = 0; i < messages.length; i++) {
|
|
120
|
+
const message = messages[i];
|
|
121
|
+
if (!message) continue;
|
|
122
|
+
if (message.role === "assistant") {
|
|
123
|
+
const calls = message.content.filter((p) => p.type === "tool_call").map((p) => p.id);
|
|
124
|
+
if (calls.length === 0) continue;
|
|
125
|
+
const next = messages[i + 1];
|
|
126
|
+
const answered = new Set(
|
|
127
|
+
next?.role === "user"
|
|
128
|
+
? next.content.filter((p) => p.type === "tool_result").map((p) => p.callId)
|
|
129
|
+
: [],
|
|
130
|
+
);
|
|
131
|
+
const missing = calls.filter((id) => !answered.has(id));
|
|
132
|
+
if (missing.length > 0) {
|
|
133
|
+
throw new OpenshainError(
|
|
134
|
+
"corrupt_log",
|
|
135
|
+
`tool calls without a result before the conversation continues: ${missing.join(", ")}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const results = message.content.filter((p) => p.type === "tool_result").map((p) => p.callId);
|
|
140
|
+
if (results.length === 0) continue;
|
|
141
|
+
const previous = messages[i - 1];
|
|
142
|
+
const known = new Set(
|
|
143
|
+
previous?.role === "assistant"
|
|
144
|
+
? previous.content.filter((p) => p.type === "tool_call").map((p) => p.id)
|
|
145
|
+
: [],
|
|
146
|
+
);
|
|
147
|
+
const orphans = results.filter((id) => !known.has(id));
|
|
148
|
+
if (orphans.length > 0) {
|
|
149
|
+
throw new OpenshainError(
|
|
150
|
+
"corrupt_log",
|
|
151
|
+
`tool results that answer no call in the preceding assistant message: ${orphans.join(", ")}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderContent(content: ToolContent[]): string {
|
|
159
|
+
return content
|
|
160
|
+
.map((part) => (part.type === "text" ? part.text : JSON.stringify(part.value)))
|
|
161
|
+
.join("\n");
|
|
162
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isOpenshainError, OpenshainError } from "../errors.ts";
|
|
4
|
+
import { newWorkId, parseWorkId, type WorkId } from "../ids.ts";
|
|
5
|
+
import { EventLog, type NewEvent } from "./event-log.ts";
|
|
6
|
+
import type { AnyEvent, Event, EventType } from "./events.ts";
|
|
7
|
+
import { acquireLock, type Lock } from "./lock.ts";
|
|
8
|
+
import { reduceWork, transition, type Work, type WorkStatus, workToFile } from "./work.ts";
|
|
9
|
+
|
|
10
|
+
export const WORK_DIR_NAME = "work";
|
|
11
|
+
export const WORK_FILE_NAME = "work.json";
|
|
12
|
+
|
|
13
|
+
export interface CreateWorkInput {
|
|
14
|
+
objective: string;
|
|
15
|
+
principal: string;
|
|
16
|
+
profession: string;
|
|
17
|
+
/** Kind of work, for example "request" or "month_end_close". Defaults to "request". */
|
|
18
|
+
type?: string;
|
|
19
|
+
/** The work this one is started from, such as a session. */
|
|
20
|
+
parent?: string;
|
|
21
|
+
/** The name the model goes by in this work. */
|
|
22
|
+
agentName?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ListResult {
|
|
26
|
+
works: Work[];
|
|
27
|
+
/** Work directories that could not be read. Reported, never hidden. */
|
|
28
|
+
problems: { id: string; error: OpenshainError }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Write access to one work. Holds the work's lock from open() until close(),
|
|
33
|
+
* so there is exactly one writer at a time.
|
|
34
|
+
*/
|
|
35
|
+
export interface WorkHandle {
|
|
36
|
+
readonly id: WorkId;
|
|
37
|
+
current(): Promise<Work>;
|
|
38
|
+
events(): Promise<AnyEvent[]>;
|
|
39
|
+
append<T extends EventType>(event: NewEvent<T>): Promise<Event<T>>;
|
|
40
|
+
/** Records a status change after checking it is allowed. Completion and failure go through their own events. */
|
|
41
|
+
transition(to: WorkStatus, reason: string): Promise<Event<"work.status_changed">>;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Works of one workspace, stored under work/<id>/. Reads need no lock; writes go through a handle. */
|
|
46
|
+
export class WorkStore {
|
|
47
|
+
constructor(private readonly root: string) {}
|
|
48
|
+
|
|
49
|
+
async create(input: CreateWorkInput): Promise<Work> {
|
|
50
|
+
const id = newWorkId();
|
|
51
|
+
const dir = this.dir(id);
|
|
52
|
+
const lock = await acquireLock(dir);
|
|
53
|
+
try {
|
|
54
|
+
const log = await EventLog.open(dir, id);
|
|
55
|
+
await log.append({
|
|
56
|
+
type: "work.created",
|
|
57
|
+
payload: {
|
|
58
|
+
objective: input.objective,
|
|
59
|
+
principal: input.principal,
|
|
60
|
+
profession: input.profession,
|
|
61
|
+
type: input.type ?? "request",
|
|
62
|
+
...(input.parent !== undefined && { parent: input.parent }),
|
|
63
|
+
...(input.agentName !== undefined && { agentName: input.agentName }),
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
return await this.snapshot(id, await log.read());
|
|
67
|
+
} finally {
|
|
68
|
+
await lock.release();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async get(id: WorkId): Promise<Work> {
|
|
73
|
+
return reduceWork(await this.events(id));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async list(): Promise<ListResult> {
|
|
77
|
+
let names: string[];
|
|
78
|
+
try {
|
|
79
|
+
names = await readdir(join(this.root, WORK_DIR_NAME));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { works: [], problems: [] };
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
const result: ListResult = { works: [], problems: [] };
|
|
85
|
+
for (const name of names.sort()) {
|
|
86
|
+
let id: WorkId;
|
|
87
|
+
try {
|
|
88
|
+
id = parseWorkId(name);
|
|
89
|
+
} catch {
|
|
90
|
+
continue; // not a work directory
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
result.works.push(await this.get(id));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!isOpenshainError(error)) throw error;
|
|
96
|
+
result.problems.push({ id, error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async events(id: WorkId): Promise<AnyEvent[]> {
|
|
103
|
+
const dir = await this.existingDir(id);
|
|
104
|
+
const log = await EventLog.open(dir, id);
|
|
105
|
+
return log.read();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Takes the work's lock. Call close() when done, or use append()/transition() for a single write. */
|
|
109
|
+
async open(id: WorkId): Promise<WorkHandle> {
|
|
110
|
+
const dir = await this.existingDir(id);
|
|
111
|
+
const lock = await acquireLock(dir);
|
|
112
|
+
let log: EventLog;
|
|
113
|
+
try {
|
|
114
|
+
log = await EventLog.open(dir, id);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
await lock.release();
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
return this.handle(id, log, lock);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Opens, appends one event, refreshes work.json and closes. */
|
|
123
|
+
async append<T extends EventType>(id: WorkId, event: NewEvent<T>): Promise<Event<T>> {
|
|
124
|
+
const handle = await this.open(id);
|
|
125
|
+
try {
|
|
126
|
+
return await handle.append(event);
|
|
127
|
+
} finally {
|
|
128
|
+
await handle.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Opens, records one status change and closes. */
|
|
133
|
+
async transition(
|
|
134
|
+
id: WorkId,
|
|
135
|
+
to: WorkStatus,
|
|
136
|
+
reason: string,
|
|
137
|
+
): Promise<Event<"work.status_changed">> {
|
|
138
|
+
const handle = await this.open(id);
|
|
139
|
+
try {
|
|
140
|
+
return await handle.transition(to, reason);
|
|
141
|
+
} finally {
|
|
142
|
+
await handle.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private handle(id: WorkId, log: EventLog, lock: Lock): WorkHandle {
|
|
147
|
+
const store = this;
|
|
148
|
+
let open = true;
|
|
149
|
+
const assertOpen = () => {
|
|
150
|
+
if (!open) throw new OpenshainError("lock_held", `work ${id} handle is closed`);
|
|
151
|
+
};
|
|
152
|
+
const handle: WorkHandle = {
|
|
153
|
+
id,
|
|
154
|
+
async current() {
|
|
155
|
+
return reduceWork(await log.read());
|
|
156
|
+
},
|
|
157
|
+
events() {
|
|
158
|
+
return log.read();
|
|
159
|
+
},
|
|
160
|
+
async append(event) {
|
|
161
|
+
assertOpen();
|
|
162
|
+
const appended = await log.append(event);
|
|
163
|
+
await store.snapshot(id, await log.read());
|
|
164
|
+
return appended;
|
|
165
|
+
},
|
|
166
|
+
async transition(to, reason) {
|
|
167
|
+
assertOpen();
|
|
168
|
+
if (to === "completed" || to === "failed") {
|
|
169
|
+
throw new OpenshainError(
|
|
170
|
+
"invalid_transition",
|
|
171
|
+
`use work.${to} to move a work to ${to}; status changes cannot end a work`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const work = reduceWork(await log.read());
|
|
175
|
+
transition(work.status, to);
|
|
176
|
+
return handle.append({
|
|
177
|
+
type: "work.status_changed",
|
|
178
|
+
payload: { from: work.status, to, reason },
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
async close() {
|
|
182
|
+
if (!open) return;
|
|
183
|
+
open = false;
|
|
184
|
+
await lock.release();
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
return handle;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private dir(id: WorkId): string {
|
|
191
|
+
return join(this.root, WORK_DIR_NAME, parseWorkId(id));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async existingDir(id: WorkId): Promise<string> {
|
|
195
|
+
const dir = this.dir(id);
|
|
196
|
+
try {
|
|
197
|
+
await stat(dir);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
200
|
+
throw new OpenshainError("not_found", `work ${id} does not exist in ${this.root}`);
|
|
201
|
+
}
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
return dir;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private async snapshot(id: WorkId, events: AnyEvent[]): Promise<Work> {
|
|
208
|
+
const work = reduceWork(events);
|
|
209
|
+
await writeFile(
|
|
210
|
+
join(this.dir(id), WORK_FILE_NAME),
|
|
211
|
+
`${JSON.stringify(workToFile(work), null, 2)}\n`,
|
|
212
|
+
"utf8",
|
|
213
|
+
);
|
|
214
|
+
return work;
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/work/work.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { OpenshainError } from "../errors.ts";
|
|
3
|
+
import type { WorkId } from "../ids.ts";
|
|
4
|
+
import type { AnyEvent, Artifact, Event } from "./events.ts";
|
|
5
|
+
|
|
6
|
+
export const WORK_STATUSES = [
|
|
7
|
+
"queued",
|
|
8
|
+
"in_progress",
|
|
9
|
+
"waiting_input",
|
|
10
|
+
"waiting_approval",
|
|
11
|
+
"waiting_external",
|
|
12
|
+
"completed",
|
|
13
|
+
"failed",
|
|
14
|
+
"cancelled",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export const WorkStatus = z.enum(WORK_STATUSES);
|
|
18
|
+
export type WorkStatus = z.infer<typeof WorkStatus>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The type of the work that records a conversation. Only a session opens one: its objective is a
|
|
22
|
+
* label that stays out of the projection, and it is not run like other works.
|
|
23
|
+
*/
|
|
24
|
+
export const SESSION_WORK_TYPE = "session";
|
|
25
|
+
|
|
26
|
+
const allowed: Record<WorkStatus, readonly WorkStatus[]> = {
|
|
27
|
+
queued: ["in_progress", "cancelled"],
|
|
28
|
+
in_progress: [
|
|
29
|
+
"waiting_input",
|
|
30
|
+
"waiting_approval",
|
|
31
|
+
"waiting_external",
|
|
32
|
+
"completed",
|
|
33
|
+
"failed",
|
|
34
|
+
"cancelled",
|
|
35
|
+
],
|
|
36
|
+
waiting_input: ["in_progress", "cancelled", "failed"],
|
|
37
|
+
waiting_approval: ["in_progress", "cancelled", "failed"],
|
|
38
|
+
waiting_external: ["in_progress", "cancelled", "failed"],
|
|
39
|
+
completed: [],
|
|
40
|
+
failed: [],
|
|
41
|
+
cancelled: [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Throws invalid_transition unless a work may move from one status to the other. */
|
|
45
|
+
export function transition(from: WorkStatus, to: WorkStatus): void {
|
|
46
|
+
if (!allowed[from].includes(to)) {
|
|
47
|
+
throw new OpenshainError("invalid_transition", `cannot move work from ${from} to ${to}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Work {
|
|
52
|
+
id: WorkId;
|
|
53
|
+
principal: string;
|
|
54
|
+
profession: string;
|
|
55
|
+
type: string;
|
|
56
|
+
objective: string;
|
|
57
|
+
/** The work this one was started from, if any. */
|
|
58
|
+
parent?: string;
|
|
59
|
+
/** The name the model goes by in this work, when a session gave it one. */
|
|
60
|
+
agentName?: string;
|
|
61
|
+
status: WorkStatus;
|
|
62
|
+
createdAt: string;
|
|
63
|
+
startedAt?: string;
|
|
64
|
+
completedAt?: string;
|
|
65
|
+
outcome?: { summary: string; artifacts: Artifact[] };
|
|
66
|
+
failure?: { reason: string; detail: string };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Rebuilds the current state of a work from its event log. The log is the truth. */
|
|
70
|
+
export function reduceWork(events: readonly AnyEvent[]): Work {
|
|
71
|
+
const first = events[0];
|
|
72
|
+
if (first?.type !== "work.created") {
|
|
73
|
+
throw new OpenshainError("corrupt_log", "the first event of a work must be work.created");
|
|
74
|
+
}
|
|
75
|
+
const created = first as Event<"work.created">;
|
|
76
|
+
const work: Work = {
|
|
77
|
+
id: created.workId,
|
|
78
|
+
principal: created.payload.principal,
|
|
79
|
+
profession: created.payload.profession,
|
|
80
|
+
type: created.payload.type,
|
|
81
|
+
objective: created.payload.objective,
|
|
82
|
+
...(created.payload.parent !== undefined && { parent: created.payload.parent }),
|
|
83
|
+
...(created.payload.agentName !== undefined && { agentName: created.payload.agentName }),
|
|
84
|
+
status: "queued",
|
|
85
|
+
createdAt: created.occurredAt,
|
|
86
|
+
};
|
|
87
|
+
let artifacts: Artifact[] = [];
|
|
88
|
+
|
|
89
|
+
for (const event of events.slice(1)) {
|
|
90
|
+
switch (event.type) {
|
|
91
|
+
case "work.status_changed": {
|
|
92
|
+
const { payload } = event as Event<"work.status_changed">;
|
|
93
|
+
const to = WorkStatus.safeParse(payload.to);
|
|
94
|
+
if (!to.success) {
|
|
95
|
+
throw new OpenshainError("corrupt_log", `unknown work status "${payload.to}"`);
|
|
96
|
+
}
|
|
97
|
+
if (to.data === "completed" || to.data === "failed") {
|
|
98
|
+
throw new OpenshainError(
|
|
99
|
+
"corrupt_log",
|
|
100
|
+
`work.status_changed may not move a work to ${to.data}; use work.${to.data}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (payload.from !== work.status) {
|
|
104
|
+
throw new OpenshainError(
|
|
105
|
+
"corrupt_log",
|
|
106
|
+
`work.status_changed says the work was ${payload.from} but it was ${work.status}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
move(work, to.data, event.occurredAt);
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "evidence.recorded":
|
|
113
|
+
if (isTerminal(work.status)) {
|
|
114
|
+
throw new OpenshainError(
|
|
115
|
+
"corrupt_log",
|
|
116
|
+
`evidence recorded after the work ${work.status}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
artifacts = (event as Event<"evidence.recorded">).payload.artifacts;
|
|
120
|
+
break;
|
|
121
|
+
case "work.completed":
|
|
122
|
+
move(work, "completed", event.occurredAt);
|
|
123
|
+
work.outcome = { summary: (event as Event<"work.completed">).payload.summary, artifacts };
|
|
124
|
+
break;
|
|
125
|
+
case "work.failed": {
|
|
126
|
+
const { payload } = event as Event<"work.failed">;
|
|
127
|
+
move(work, "failed", event.occurredAt);
|
|
128
|
+
work.failure = { reason: payload.reason, detail: payload.detail };
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return work;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isTerminal(status: WorkStatus): boolean {
|
|
139
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function move(work: Work, to: WorkStatus, at: string): void {
|
|
143
|
+
transition(work.status, to);
|
|
144
|
+
work.status = to;
|
|
145
|
+
if (to === "in_progress" && work.startedAt === undefined) work.startedAt = at;
|
|
146
|
+
if (isTerminal(to)) work.completedAt = at;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const artifactFile = z.strictObject({
|
|
150
|
+
path: z.string(),
|
|
151
|
+
sha256: z.string(),
|
|
152
|
+
missing: z.literal(true).optional(),
|
|
153
|
+
claimed: z.literal(true).optional(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Shape of work.json. A projection of the event log, never the source of truth. */
|
|
157
|
+
export const WorkFileSchema = z.strictObject({
|
|
158
|
+
id: z.string(),
|
|
159
|
+
principal: z.string(),
|
|
160
|
+
profession: z.string(),
|
|
161
|
+
type: z.string(),
|
|
162
|
+
objective: z.string(),
|
|
163
|
+
parent: z.string().optional(),
|
|
164
|
+
agent_name: z.string().optional(),
|
|
165
|
+
status: WorkStatus,
|
|
166
|
+
created_at: z.iso.datetime(),
|
|
167
|
+
started_at: z.iso.datetime().optional(),
|
|
168
|
+
completed_at: z.iso.datetime().optional(),
|
|
169
|
+
outcome: z.strictObject({ summary: z.string(), artifacts: z.array(artifactFile) }).optional(),
|
|
170
|
+
failure: z.strictObject({ reason: z.string(), detail: z.string() }).optional(),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export type WorkFile = z.infer<typeof WorkFileSchema>;
|
|
174
|
+
|
|
175
|
+
export function workToFile(work: Work): WorkFile {
|
|
176
|
+
return {
|
|
177
|
+
id: work.id,
|
|
178
|
+
principal: work.principal,
|
|
179
|
+
profession: work.profession,
|
|
180
|
+
type: work.type,
|
|
181
|
+
objective: work.objective,
|
|
182
|
+
...(work.parent !== undefined && { parent: work.parent }),
|
|
183
|
+
...(work.agentName !== undefined && { agent_name: work.agentName }),
|
|
184
|
+
status: work.status,
|
|
185
|
+
created_at: work.createdAt,
|
|
186
|
+
...(work.startedAt !== undefined && { started_at: work.startedAt }),
|
|
187
|
+
...(work.completedAt !== undefined && { completed_at: work.completedAt }),
|
|
188
|
+
...(work.outcome && { outcome: work.outcome }),
|
|
189
|
+
...(work.failure && { failure: work.failure }),
|
|
190
|
+
};
|
|
191
|
+
}
|