@particle-academy/prism-harness 0.0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. package/package.json +34 -0
@@ -0,0 +1,136 @@
1
+ import { HarnessError } from './errors.js';
2
+ import { authorizedTool } from './tools.js';
3
+ import { isTerminalState } from './tasks.js';
4
+ /**
5
+ * A tool that lets the AGENT close its own task. OFF UNLESS A CONSUMER BUILDS IT.
6
+ *
7
+ * ## Why this is not the default
8
+ *
9
+ * If the model can set its own task to `done`, then "run until the goal is met"
10
+ * silently becomes "run until it decides it is met", and a run that has stalled
11
+ * ends by declaring victory. That is the same failure `prism-human-plus`
12
+ * addresses by reserving confirmation for the human, and the same reason the
13
+ * default here is off in all three languages. A port that ships it on has
14
+ * changed an observable decision, not a default.
15
+ *
16
+ * By default `release()` is called by the APPLICATION, from evidence. Nothing
17
+ * in this package registers this tool, and a `StoreTaskSource` on its own gives
18
+ * an agent no route to a terminal state.
19
+ *
20
+ * ## Why the authorizer is checked
21
+ *
22
+ * Through the EXISTING `ToolAuthorizer` -- no new permission mechanism, because
23
+ * a second mechanism is a second place to grant something by accident.
24
+ *
25
+ * A disabled authorizer is refused rather than tolerated. `ToolAuthorizer`
26
+ * defaults to off, and off means every registered tool is offered to every run
27
+ * and every call is allowed; a consumer who wires this tool up against one has
28
+ * granted the agent completion authority while believing a control is in place.
29
+ * Same argument, and the same error code, as a policy that is defined and never
30
+ * consulted.
31
+ *
32
+ * The returned factory produces a tool wrapped by `authorizedTool`, so the call
33
+ * policy is asked again AT CALL TIME with the arguments -- an offer-time check
34
+ * alone can say "may complete tasks" and never "only this task, only once the
35
+ * tests pass".
36
+ */
37
+ export function agentCompletionTool(options) {
38
+ const name = options.name ?? 'complete_task';
39
+ if (!options.authorizer.enabled) {
40
+ throw HarnessError.completionToolWithoutAuthorizer(name);
41
+ }
42
+ const { source, task, worker, authorizer } = options;
43
+ const tool = {
44
+ name,
45
+ description: `Record the outcome of the task [${task.id()}] as either "done" or "failed". Only call this ` +
46
+ 'when the work is actually finished; "failed" is a legitimate answer and is not a worse one.',
47
+ async handle(args) {
48
+ const given = args.outcome;
49
+ // REQUIRED and VALIDATED. `outcome` is the one value the MODEL supplies
50
+ // and it decides a terminal state, so mapping anything else onto `done`
51
+ // -- `"complete"`, `"DONE"`, `null`, or nothing at all -- would let a
52
+ // malformed call produce the more privileged result: an agent declaring
53
+ // victory by typo.
54
+ //
55
+ // ABSENT is refused too, and that is a ruling rather than an obvious
56
+ // reading. The argument for allowing it is real: calling a tool named
57
+ // `complete_task` looks like the declaration by itself. The reference
58
+ // settled it the other way, and the same code covers both, so a model
59
+ // that omitted the argument is told to say which outcome it means rather
60
+ // than having one chosen for it.
61
+ if (given !== 'done' && given !== 'failed') {
62
+ throw HarnessError.taskOutcomeInvalid(name, given);
63
+ }
64
+ const outcome = given;
65
+ // CHECKED HERE TOO, and this is not belt-and-braces.
66
+ //
67
+ // `StoreTaskSource.release()` refuses a worker that is not the holder, so
68
+ // against that source this block changes nothing -- deleting it is
69
+ // invisible, and the mutation SURVIVES. That is precisely the argument
70
+ // for it: an interface cannot make an implementation check anything, and
71
+ // `release(task, worker, outcome)` reads to a third party like "find it
72
+ // and set the state", which is what they will write. This tool is the
73
+ // thing a MODEL can call, so it does not get to assume the source behind
74
+ // it is the careful one. A fixture source with no guard at all is in the
75
+ // suite for exactly this, because nothing else would notice.
76
+ const current = await source.find(task.id());
77
+ if (current === null) {
78
+ throw HarnessError.taskNotFound(task.id());
79
+ }
80
+ if (isTerminalState(current.state())) {
81
+ throw HarnessError.taskAlreadyTerminal(task.id(), current.state());
82
+ }
83
+ if (current.state() !== 'claimed') {
84
+ throw HarnessError.taskLeaseNotHeld(task.id(), worker, 'nothing is holding it');
85
+ }
86
+ // ONE comparison, and it must be `!==` against the worker rather than a
87
+ // hunt for a mismatch. The three ways this is not the worker's task --
88
+ // somebody else holds it, nobody holds it, or the holder CANNOT BE
89
+ // ESTABLISHED -- all have to land on refusal, and the third is the one
90
+ // that is easy to get wrong.
91
+ //
92
+ // `AgentTask` is `id`, `instruction`, `state` and nothing else, so a
93
+ // conforming source may legally return a task from which no holder can be
94
+ // read. The first version here treated that as "no mismatch, therefore
95
+ // allowed", which is READING SILENCE AS PERMISSION -- the same mistake as
96
+ // inferring `done` from an absent outcome, asked about a different field
97
+ // and one level down. An unknowable holder resolves to a sentinel that
98
+ // matches no worker, so it refuses.
99
+ if (holderOf(current) !== worker) {
100
+ throw HarnessError.taskLeaseNotHeld(task.id(), worker, 'it is not held by this worker');
101
+ }
102
+ await source.release(task, worker, outcome);
103
+ return { id: task.id(), state: outcome };
104
+ },
105
+ };
106
+ return (session) => authorizedTool(tool, session, authorizer);
107
+ }
108
+ /**
109
+ * A holder that MATCHES NO WORKER, for a task whose holder cannot be read.
110
+ *
111
+ * A symbol rather than a string, so no worker id can ever equal it -- not the
112
+ * empty string, not `'unknown'`, not whatever a consumer happens to name a
113
+ * worker. "Impossible to collide with" is the entire job.
114
+ */
115
+ const UNKNOWABLE_HOLDER = Symbol('the holder could not be established');
116
+ /**
117
+ * Who holds this task -- or the sentinel, when that cannot be established.
118
+ *
119
+ * `AgentTaskSource.find()` returns an `AgentTask`: three methods, no lease.
120
+ * That is deliberate, because a consumer adapting an existing table may have
121
+ * nowhere to put an owner, and it means a perfectly conforming source can hand
122
+ * back a task from which no holder is readable. `LeasedAgentTask` adds the
123
+ * lease, and everything this package produces is one.
124
+ *
125
+ * The unreadable case resolves to a sentinel rather than to `undefined`,
126
+ * because `undefined` invites the caller to skip the comparison -- which is
127
+ * what the first version of this did, and skipping the comparison is granting
128
+ * the permission.
129
+ */
130
+ function holderOf(task) {
131
+ const candidate = task;
132
+ if (typeof candidate.claimedBy !== 'function') {
133
+ return UNKNOWABLE_HOLDER;
134
+ }
135
+ return candidate.claimedBy();
136
+ }
@@ -0,0 +1,257 @@
1
+ import type { JsonObject } from './json.js';
2
+ /**
3
+ * What remains to be done, and how far along it is.
4
+ *
5
+ * FOUR STATES, and no others. This is identity, not spelling: a port that adds
6
+ * a fifth (`cancelled`, `retrying`, `skipped`) has changed what a stored record
7
+ * can say, and a consumer reading a list written by another language would meet
8
+ * a value it cannot map.
9
+ *
10
+ * The transitions are equally pinned:
11
+ *
12
+ * ```text
13
+ * claim() release(done)
14
+ * todo ---------> claimed -------------------------> done
15
+ * ^ | release(failed)
16
+ * | +-------------------------------> failed
17
+ * | |
18
+ * +-----------------+
19
+ * lease expires
20
+ * ```
21
+ *
22
+ * Note what is NOT there. An expired lease goes back to `todo`, never to
23
+ * `failed` -- a worker dying is not the task failing, and conflating the two
24
+ * burns a retry that never ran. And nothing leaves `done` or `failed`: a
25
+ * `failed` task is re-queued by the APPLICATION, because automatic retry needs
26
+ * backoff and attempt counts, and that is the scheduler this must not become.
27
+ */
28
+ export type TaskState = 'todo' | 'claimed' | 'done' | 'failed';
29
+ /** Every state, in the order the machine visits them. */
30
+ export declare const TASK_STATES: readonly TaskState[];
31
+ /**
32
+ * What `release()` records. Both are TERMINAL.
33
+ *
34
+ * Deliberately not a boolean. "Did it work" reads the same for a task that
35
+ * failed and a task that was never attempted, and those two are what this
36
+ * design exists to keep apart.
37
+ */
38
+ export type TaskOutcome = Extract<TaskState, 'done' | 'failed'>;
39
+ /**
40
+ * How long a claim holds by default: FIVE MINUTES.
41
+ *
42
+ * Long enough for a model call plus tool work, short enough that a crashed
43
+ * worker does not wedge the list for an hour. The number matters less than it
44
+ * being the same number in all three languages, which is why it is a named
45
+ * constant rather than a literal at the call site.
46
+ */
47
+ export declare const DEFAULT_LEASE_SECONDS = 300;
48
+ /**
49
+ * One unit of work.
50
+ *
51
+ * METHODS, not properties, and that is not decoration. An adapter over a
52
+ * consumer's own record has to read through to the live row -- a value
53
+ * snapshotted at construction would still report `claimed` after another
54
+ * worker's lease expired underneath it.
55
+ */
56
+ export interface AgentTask {
57
+ /** Stable and unique WITHIN ITS SOURCE. Not globally unique. */
58
+ id(): string;
59
+ /** What the model is asked to do. */
60
+ instruction(): string;
61
+ state(): TaskState;
62
+ }
63
+ /**
64
+ * Where tasks come from.
65
+ *
66
+ * Four methods, and the first is the reason the interface exists.
67
+ *
68
+ * `claim()` is ONE call. "Read the next task" followed by "mark it mine" is two
69
+ * round trips with a window between them, and two workers arriving in that
70
+ * window both get the same task. An implementation must take the next available
71
+ * task and record the claim as a single atomic operation -- not for
72
+ * convenience, but because a source that cannot is not an implementation of
73
+ * this contract.
74
+ */
75
+ export interface AgentTaskSource {
76
+ /**
77
+ * Atomically take the next available task, or null when there is none.
78
+ *
79
+ * Tasks come back in INSERTION ORDER unless the source defines otherwise. A
80
+ * source may expose an explicit position; it must not reorder implicitly.
81
+ * Nothing errors when ordering changes -- the agent simply does the work in a
82
+ * different sequence and produces a different result, which is the hardest
83
+ * kind of divergence to notice.
84
+ */
85
+ claim(worker: string, leaseSeconds?: number): Promise<AgentTask | null>;
86
+ /**
87
+ * Record what happened, as the worker that is HOLDING the task.
88
+ *
89
+ * Called by the APPLICATION, from evidence -- not by the agent. See
90
+ * `agentCompletionTool` for the opt-in that changes that, and why it is off
91
+ * until a consumer turns it on.
92
+ *
93
+ * The `worker` argument is not in the spec's three-method sketch and is here
94
+ * on purpose. Without it, a completion tool handed a source can close ANY
95
+ * task in the list, including one another worker is midway through -- and the
96
+ * whole reason completion authority is gated is that an agent closing work it
97
+ * did not do is the failure being designed against. A release by anyone but
98
+ * the holder is refused.
99
+ */
100
+ release(task: AgentTask, worker: string, outcome: TaskOutcome): Promise<void>;
101
+ /**
102
+ * How many tasks remain claimable.
103
+ *
104
+ * A COUNT, not a listing. It exists to terminate the loop and a count is
105
+ * enough for that; a listing invites the source to materialise every task on
106
+ * every pass, and a consumer that wants one already has its own query.
107
+ */
108
+ pending(): Promise<number>;
109
+ /**
110
+ * The task with this id, as it stands now, or null.
111
+ *
112
+ * On the contract because `release()` takes a TASK and every external caller
113
+ * holds only an ID. A tool call carries `{"id": "t-1"}`; an HTTP route has
114
+ * `/tasks/t-1`; a worker resuming after a restart has whatever it wrote down.
115
+ * Without this the contract could be driven only by code that still had the
116
+ * object `claim()` returned, which is the one caller that does not need it.
117
+ *
118
+ * A FRESH read, with any expired lease already applied -- so a task whose
119
+ * lease ran out reports `todo` here even if the store has not been rewritten
120
+ * yet.
121
+ */
122
+ find(id: string): Promise<AgentTask | null>;
123
+ }
124
+ /**
125
+ * The stored shape of a task. THE KEYS ARE THE CONTRACT.
126
+ *
127
+ * `claimed_by` and `claimed_until` are `| null` and NOT optional, and that
128
+ * distinction is why this type is spelled out rather than inferred. TypeScript
129
+ * has both `null` and `undefined` where PHP has one absent value; an optional
130
+ * property holding `undefined` is DROPPED by `JSON.stringify`; and a dropped
131
+ * key is different bytes -- an observable decision under prism-parity decision
132
+ * 0002, not a formatting detail.
133
+ *
134
+ * `claimed_until` is an integer Unix timestamp rather than a formatted date,
135
+ * because date formatting is exactly where three languages produce three
136
+ * strings from one instant.
137
+ */
138
+ export interface AgentTaskRecord {
139
+ claimed_by: string | null;
140
+ claimed_until: number | null;
141
+ id: string;
142
+ instruction: string;
143
+ state: TaskState;
144
+ }
145
+ /**
146
+ * A task that also exposes its lease.
147
+ *
148
+ * Kept apart from `AgentTask` because the three-method contract is what an
149
+ * adapter over an existing table must satisfy, and such a table may have
150
+ * nowhere to put an owner and an expiry. Everything this package produces
151
+ * satisfies both.
152
+ */
153
+ export interface LeasedAgentTask extends AgentTask {
154
+ /** Present-and-null when unclaimed. */
155
+ claimedBy(): string | null;
156
+ /** Integer Unix timestamp; present-and-null when unclaimed. */
157
+ claimedUntil(): number | null;
158
+ toRecord(): AgentTaskRecord;
159
+ }
160
+ /**
161
+ * The five attributes a consumer's own record has to expose.
162
+ *
163
+ * Camel-cased because this is the in-language shape; `AgentTaskRecord` is the
164
+ * stored one, and the two are converted rather than conflated. Only the stored
165
+ * one is pinned across languages.
166
+ */
167
+ export interface AgentTaskAttributes {
168
+ id: string;
169
+ instruction: string;
170
+ state: TaskState;
171
+ /** PRESENT-AND-NULL when unclaimed. Never `undefined`; see `AgentTaskRecord`. */
172
+ claimedBy: string | null;
173
+ claimedUntil: number | null;
174
+ }
175
+ export declare function isTaskState(value: unknown): value is TaskState;
176
+ /** `done` and `failed`. Nothing leaves either. */
177
+ export declare function isTerminalState(state: TaskState): boolean;
178
+ /**
179
+ * Canonical JSON for one task record.
180
+ *
181
+ * ```json
182
+ * {"claimed_by":null,"claimed_until":null,"id":"t-1","instruction":"...","state":"todo"}
183
+ * ```
184
+ *
185
+ * Keys SORTED -- which for these five is also the order above, so one task has
186
+ * one byte sequence rather than two.
187
+ *
188
+ * `undefined` is coerced to `null` instead of being handed to `JSON.stringify`,
189
+ * which would silently drop the key. That coercion is a backstop for values
190
+ * arriving from a store or an untyped consumer; the types above already make it
191
+ * unreachable from typed code, and a test asserts both halves rather than
192
+ * trusting the type.
193
+ *
194
+ * No whitespace, no escaped slashes, no escaped non-ASCII: JavaScript's
195
+ * `JSON.stringify` already produces all three where PHP's and Python's defaults
196
+ * do not. See prism-parity decision 0005.
197
+ */
198
+ export declare function canonicalTaskJson(record: AgentTaskRecord): string;
199
+ /**
200
+ * Rebuild a record from whatever a store handed back.
201
+ *
202
+ * A missing or `undefined` claim field becomes `null`, so there is exactly one
203
+ * representation of "unclaimed" once a row has been through here. A missing id,
204
+ * instruction or state is a CORRUPT row rather than a defaultable one, and is
205
+ * refused -- defaulting a missing state to `todo` would hand a half-finished
206
+ * task back out as if it had never been attempted.
207
+ */
208
+ export declare function taskRecordFrom(value: JsonObject | Record<string, unknown>): AgentTaskRecord;
209
+ export declare function recordToAttributes(record: AgentTaskRecord): AgentTaskAttributes;
210
+ /**
211
+ * The stored record for a set of attributes.
212
+ *
213
+ * `?? null` on both claim fields is the load-bearing line in this file. An
214
+ * untyped consumer handing over `undefined` would otherwise produce a record
215
+ * whose keys vanish at serialisation time.
216
+ */
217
+ export declare function attributesToRecord(attributes: AgentTaskAttributes): AgentTaskRecord;
218
+ /**
219
+ * A consumer's own record, as an `AgentTask`.
220
+ *
221
+ * This is the idiomatic equivalent of the reference's trait on an Eloquent
222
+ * model, and the reason it looks nothing like a trait is decision 0002. PHP
223
+ * needs a trait because a trait is how PHP shares behaviour into someone else's
224
+ * class. TypeScript's conformance is STRUCTURAL: a consumer whose record
225
+ * already carries the five attributes needs no inheritance at all, so the
226
+ * natural spelling is a function that wraps one. Contorting the language to
227
+ * imitate a trait would change the spelling and nothing observable.
228
+ *
229
+ * The attributes object is read at CALL TIME rather than copied, so a live
230
+ * record another worker's claim changed underneath reports the new state.
231
+ */
232
+ export declare function asAgentTask(attributes: AgentTaskAttributes): LeasedAgentTask;
233
+ /**
234
+ * What the mixin needs from the class it is mixed into.
235
+ *
236
+ * ONE accessor rather than five fields, because a mixin cannot define a method
237
+ * called `id()` on a class that already has an `id` property -- the instance
238
+ * property shadows the prototype method and the method is never reached. That
239
+ * is a real constraint of the language, not a preference, and it is the reason
240
+ * the mixin asks for a mapping function instead of the attributes directly. A
241
+ * consumer maps their own column names here, exactly as a PHP trait would need
242
+ * them mapped.
243
+ */
244
+ export interface AgentTaskAttributeSource {
245
+ taskAttributes(): AgentTaskAttributes;
246
+ }
247
+ type Constructor<T> = new (...args: any[]) => T;
248
+ /**
249
+ * The mixin: `class Todo extends withAgentTask(Row) {}`.
250
+ *
251
+ * Same behaviour as `asAgentTask`, reached the way a consumer who wants the
252
+ * methods ON their model reaches it. Both exist because TypeScript has two
253
+ * natural spellings of "share this behaviour into someone else's class" and
254
+ * neither is more correct than the other.
255
+ */
256
+ export declare function withAgentTask<TBase extends Constructor<AgentTaskAttributeSource>>(Base: TBase): TBase & Constructor<LeasedAgentTask>;
257
+ export {};
package/dist/tasks.js ADDED
@@ -0,0 +1,162 @@
1
+ import { HarnessError } from './errors.js';
2
+ /** Every state, in the order the machine visits them. */
3
+ export const TASK_STATES = ['todo', 'claimed', 'done', 'failed'];
4
+ /**
5
+ * How long a claim holds by default: FIVE MINUTES.
6
+ *
7
+ * Long enough for a model call plus tool work, short enough that a crashed
8
+ * worker does not wedge the list for an hour. The number matters less than it
9
+ * being the same number in all three languages, which is why it is a named
10
+ * constant rather than a literal at the call site.
11
+ */
12
+ export const DEFAULT_LEASE_SECONDS = 300;
13
+ export function isTaskState(value) {
14
+ return typeof value === 'string' && TASK_STATES.includes(value);
15
+ }
16
+ /** `done` and `failed`. Nothing leaves either. */
17
+ export function isTerminalState(state) {
18
+ return state === 'done' || state === 'failed';
19
+ }
20
+ /**
21
+ * Canonical JSON for one task record.
22
+ *
23
+ * ```json
24
+ * {"claimed_by":null,"claimed_until":null,"id":"t-1","instruction":"...","state":"todo"}
25
+ * ```
26
+ *
27
+ * Keys SORTED -- which for these five is also the order above, so one task has
28
+ * one byte sequence rather than two.
29
+ *
30
+ * `undefined` is coerced to `null` instead of being handed to `JSON.stringify`,
31
+ * which would silently drop the key. That coercion is a backstop for values
32
+ * arriving from a store or an untyped consumer; the types above already make it
33
+ * unreachable from typed code, and a test asserts both halves rather than
34
+ * trusting the type.
35
+ *
36
+ * No whitespace, no escaped slashes, no escaped non-ASCII: JavaScript's
37
+ * `JSON.stringify` already produces all three where PHP's and Python's defaults
38
+ * do not. See prism-parity decision 0005.
39
+ */
40
+ export function canonicalTaskJson(record) {
41
+ const loose = record;
42
+ const ordered = {};
43
+ for (const key of Object.keys(loose).sort()) {
44
+ const value = loose[key];
45
+ ordered[key] = value === undefined ? null : value;
46
+ }
47
+ return JSON.stringify(ordered);
48
+ }
49
+ /**
50
+ * Rebuild a record from whatever a store handed back.
51
+ *
52
+ * A missing or `undefined` claim field becomes `null`, so there is exactly one
53
+ * representation of "unclaimed" once a row has been through here. A missing id,
54
+ * instruction or state is a CORRUPT row rather than a defaultable one, and is
55
+ * refused -- defaulting a missing state to `todo` would hand a half-finished
56
+ * task back out as if it had never been attempted.
57
+ */
58
+ export function taskRecordFrom(value) {
59
+ const loose = value;
60
+ const id = loose.id;
61
+ const instruction = loose.instruction;
62
+ const state = loose.state;
63
+ if (typeof id !== 'string' || id === '') {
64
+ throw HarnessError.unmappableContent('a stored task record has no id');
65
+ }
66
+ if (typeof instruction !== 'string') {
67
+ throw HarnessError.unmappableContent(`the stored task [${id}] has no instruction`);
68
+ }
69
+ if (!isTaskState(state)) {
70
+ throw HarnessError.unmappableContent(`the stored task [${id}] has the state [${String(state)}], which is not one of ${TASK_STATES.join(', ')}`);
71
+ }
72
+ const claimedBy = loose.claimed_by;
73
+ const claimedUntil = loose.claimed_until;
74
+ return {
75
+ claimed_by: typeof claimedBy === 'string' ? claimedBy : null,
76
+ claimed_until: typeof claimedUntil === 'number' ? Math.trunc(claimedUntil) : null,
77
+ id,
78
+ instruction,
79
+ state,
80
+ };
81
+ }
82
+ export function recordToAttributes(record) {
83
+ return {
84
+ id: record.id,
85
+ instruction: record.instruction,
86
+ state: record.state,
87
+ claimedBy: record.claimed_by,
88
+ claimedUntil: record.claimed_until,
89
+ };
90
+ }
91
+ /**
92
+ * The stored record for a set of attributes.
93
+ *
94
+ * `?? null` on both claim fields is the load-bearing line in this file. An
95
+ * untyped consumer handing over `undefined` would otherwise produce a record
96
+ * whose keys vanish at serialisation time.
97
+ */
98
+ export function attributesToRecord(attributes) {
99
+ const until = attributes.claimedUntil;
100
+ return {
101
+ claimed_by: attributes.claimedBy ?? null,
102
+ claimed_until: until === null || until === undefined ? null : Math.trunc(until),
103
+ id: attributes.id,
104
+ instruction: attributes.instruction,
105
+ state: attributes.state,
106
+ };
107
+ }
108
+ /**
109
+ * A consumer's own record, as an `AgentTask`.
110
+ *
111
+ * This is the idiomatic equivalent of the reference's trait on an Eloquent
112
+ * model, and the reason it looks nothing like a trait is decision 0002. PHP
113
+ * needs a trait because a trait is how PHP shares behaviour into someone else's
114
+ * class. TypeScript's conformance is STRUCTURAL: a consumer whose record
115
+ * already carries the five attributes needs no inheritance at all, so the
116
+ * natural spelling is a function that wraps one. Contorting the language to
117
+ * imitate a trait would change the spelling and nothing observable.
118
+ *
119
+ * The attributes object is read at CALL TIME rather than copied, so a live
120
+ * record another worker's claim changed underneath reports the new state.
121
+ */
122
+ export function asAgentTask(attributes) {
123
+ return {
124
+ id: () => attributes.id,
125
+ instruction: () => attributes.instruction,
126
+ state: () => attributes.state,
127
+ claimedBy: () => attributes.claimedBy ?? null,
128
+ claimedUntil: () => attributes.claimedUntil ?? null,
129
+ toRecord: () => attributesToRecord(attributes),
130
+ };
131
+ }
132
+ /**
133
+ * The mixin: `class Todo extends withAgentTask(Row) {}`.
134
+ *
135
+ * Same behaviour as `asAgentTask`, reached the way a consumer who wants the
136
+ * methods ON their model reaches it. Both exist because TypeScript has two
137
+ * natural spellings of "share this behaviour into someone else's class" and
138
+ * neither is more correct than the other.
139
+ */
140
+ export function withAgentTask(Base) {
141
+ class AgentTaskRow extends Base {
142
+ id() {
143
+ return this.taskAttributes().id;
144
+ }
145
+ instruction() {
146
+ return this.taskAttributes().instruction;
147
+ }
148
+ state() {
149
+ return this.taskAttributes().state;
150
+ }
151
+ claimedBy() {
152
+ return this.taskAttributes().claimedBy ?? null;
153
+ }
154
+ claimedUntil() {
155
+ return this.taskAttributes().claimedUntil ?? null;
156
+ }
157
+ toRecord() {
158
+ return attributesToRecord(this.taskAttributes());
159
+ }
160
+ }
161
+ return AgentTaskRow;
162
+ }
@@ -0,0 +1,49 @@
1
+ import type { JsonObject } from './json.js';
2
+ import type { SessionStore } from './stores/session-store.js';
3
+ export interface ThreadMessage {
4
+ /** Position in the conversation, from 1. Assigned by the thread, never by the caller. */
5
+ position: number;
6
+ /** The serialized message — whatever `prism-ts`'s `messageFromObject` can rebuild. */
7
+ message: JsonObject;
8
+ /** The run that produced it, when there was one. */
9
+ runId: string | null;
10
+ recordedAt: string;
11
+ }
12
+ /**
13
+ * The stored conversation a session is bound to.
14
+ *
15
+ * DURABLE by construction: it lives in the durable slot, so it is the one thing
16
+ * here a flushed cache cannot take away. The PHP reference keeps it as Eloquent
17
+ * rows for the same reason.
18
+ *
19
+ * ## Position is assigned inside a lock
20
+ *
21
+ * `record()` takes the lock before reading the current length. Two turns
22
+ * landing concurrently would otherwise both read position 4 and both write
23
+ * position 5, and the conversation would silently lose a message — the same
24
+ * race the reference tracks as prism-harness#2. Doing the read and the write
25
+ * inside one lock is the fix, not a retry afterwards.
26
+ */
27
+ export declare class Thread {
28
+ #private;
29
+ private readonly store;
30
+ private readonly key;
31
+ constructor(store: SessionStore, key: string);
32
+ messages(): Promise<ThreadMessage[]>;
33
+ /** How many messages the thread holds. */
34
+ count(): Promise<number>;
35
+ /**
36
+ * Append messages, in order, and return them with their assigned positions.
37
+ *
38
+ * Read-and-write inside ONE lock. See the class docblock.
39
+ */
40
+ record(messages: readonly JsonObject[], runId?: string | null): Promise<ThreadMessage[]>;
41
+ /**
42
+ * Forget the conversation.
43
+ *
44
+ * Deliberately separate from `Session.forget()`, which drops only the
45
+ * ephemeral half. Losing a thread is not a cache miss and must never be a
46
+ * side effect of clearing session state.
47
+ */
48
+ clear(): Promise<void>;
49
+ }