@openshain/core 0.1.1 → 0.3.1

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