@nanhara/hara 0.128.0 → 0.130.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.
@@ -32,12 +32,12 @@ import { disposeTodoScope, onTodosChange, restoreTodos, serializeTodos } from ".
32
32
  import { INTERJECT_PREFIX, disposeReminderScope } from "../agent/reminders.js";
33
33
  import { SessionHub, realStore } from "./sessions.js";
34
34
  import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
35
- import { taskLifecycleEvent } from "./task-events.js";
35
+ import { taskLifecycleEvent, } from "./task-events.js";
36
36
  import { readModelContextFileSync } from "../fs-read.js";
37
37
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
38
38
  import { sameOpenedFileIdentity } from "../fs-identity.js";
39
39
  import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
40
- import { ArtifactStoreError, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, } from "../artifacts/store.js";
40
+ import { ArtifactStoreError, commitArtifact, getArtifact, importArtifact, listArtifactRevisions, listArtifacts, revertArtifact, } from "../artifacts/store.js";
41
41
  import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
42
42
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
43
43
  const COMPACT_TIMEOUT_MS = 60_000;
@@ -46,12 +46,18 @@ const SOCKET_CLOSE_GRACE_MS = 250;
46
46
  const DISCOVERY_LOCK_WAIT_MS = 2_000;
47
47
  const artifactRpcError = (id, error, action) => {
48
48
  if (error instanceof ArtifactStoreError) {
49
- const code = error.code === "ARTIFACT_CORRUPT" ? ERR.INTERNAL : ERR.PARAMS;
49
+ const code = error.code === "ARTIFACT_CORRUPT"
50
+ ? ERR.INTERNAL
51
+ : error.code === "ARTIFACT_CONFLICT"
52
+ ? ERR.CONFLICT
53
+ : ERR.PARAMS;
50
54
  return rpcError(id, code, error.message);
51
55
  }
52
- return rpcError(id, ERR.INTERNAL, action === "import"
53
- ? "Artifact import failed safely; the source file was not modified"
54
- : `Artifact ${action} failed safely; local Artifact data was not changed`);
56
+ return rpcError(id, ERR.INTERNAL, action === "import" || action === "commit"
57
+ ? `Artifact ${action} failed safely; the source file was not modified`
58
+ : action === "revert"
59
+ ? "Artifact revert failed safely; no current revision was replaced"
60
+ : `Artifact ${action} failed safely; local Artifact data was not changed`);
55
61
  };
56
62
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
57
63
  const isPidAlive = (pid) => {
@@ -289,6 +295,7 @@ export async function startServe(opts, deps) {
289
295
  // Physical provider/tool work can outlive its logical timeout. Keep a process-level ledger independent
290
296
  // of SessionHub membership so detach/delete cannot make an updater believe the old engine is quiescent.
291
297
  const activeOperations = new Set();
298
+ let taskEventSequence = 0;
292
299
  let closing = false;
293
300
  let closePromise = null;
294
301
  const trackActiveOperation = (operation) => {
@@ -352,10 +359,23 @@ export async function startServe(opts, deps) {
352
359
  if (ws.readyState === ws.OPEN)
353
360
  ws.send(frame);
354
361
  };
362
+ const nextTaskEventCursor = () => {
363
+ const sequence = taskEventSequence + 1;
364
+ if (!Number.isSafeInteger(sequence)) {
365
+ throw new Error("task lifecycle event sequence exhausted");
366
+ }
367
+ return { streamId: instanceId, sequence };
368
+ };
369
+ const publishTaskState = (event) => {
370
+ // Commit the cursor immediately before the synchronous broadcast. Dedupe paths never consume one,
371
+ // while every published event has a unique position in this server-wide stream.
372
+ taskEventSequence = event.sequence;
373
+ broadcast("event.task_state", { ...event });
374
+ };
355
375
  const broadcastTaskState = (session, activity) => {
356
376
  if (!session.task)
357
377
  return;
358
- broadcast("event.task_state", { ...taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity) });
378
+ publishTaskState(taskLifecycleEvent(session.meta.id, session.task, session.meta.todos ?? [], activity, nextTaskEventCursor()));
359
379
  };
360
380
  // Discovery file — the desktop shell reads this to find the running server (like a pid/port file).
361
381
  const discoveryDir = join(deps.discoveryHome ?? homedir(), ".hara");
@@ -442,13 +462,13 @@ export async function startServe(opts, deps) {
442
462
  const emitTaskState = (activity, todos = s.meta.todos ?? []) => {
443
463
  if (!s.task)
444
464
  return;
445
- const event = taskLifecycleEvent(sessionId, s.task, todos, activity);
446
- const { at: _at, ...stableEvent } = event;
465
+ const event = taskLifecycleEvent(sessionId, s.task, todos, activity, nextTaskEventCursor());
466
+ const { at: _at, streamId: _streamId, sequence: _sequence, ...stableEvent } = event;
447
467
  const signature = JSON.stringify(stableEvent);
448
468
  if (signature === lastTaskStateSignature)
449
469
  return;
450
470
  lastTaskStateSignature = signature;
451
- broadcast("event.task_state", { ...event });
471
+ publishTaskState(event);
452
472
  };
453
473
  broadcast("event.turn_start", { sessionId, taskId: s.task.id, turnId: s.task.turnId });
454
474
  emitTaskState({ state: "running", phase: "starting" });
@@ -742,7 +762,8 @@ export async function startServe(opts, deps) {
742
762
  "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
743
763
  "settings.providers.list", "settings.providers.test", "settings.providers.save",
744
764
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
745
- "artifact.import", "artifact.list", "artifact.get", "artifact.revisions",
765
+ "artifact.import", "artifact.commit", "artifact.revert",
766
+ "artifact.list", "artifact.get", "artifact.revisions",
746
767
  "tasks.list", "approvals.list", "approvals.resolve",
747
768
  ];
748
769
  const runtime = runtimeInfo();
@@ -1162,6 +1183,63 @@ export async function startServe(opts, deps) {
1162
1183
  return reply(artifactRpcError(id, error, "import"));
1163
1184
  }
1164
1185
  }
1186
+ case "artifact.commit": {
1187
+ if (typeof p.artifactId !== "string"
1188
+ || typeof p.baseRevisionId !== "string"
1189
+ || typeof p.sourcePath !== "string"
1190
+ || !p.sourcePath) {
1191
+ return reply(rpcError(id, ERR.PARAMS, "artifactId, baseRevisionId, and sourcePath required"));
1192
+ }
1193
+ if (p.actor !== undefined) {
1194
+ return reply(rpcError(id, ERR.PARAMS, "actor is assigned by the authenticated host"));
1195
+ }
1196
+ if (p.taskRunId !== undefined && typeof p.taskRunId !== "string") {
1197
+ return reply(rpcError(id, ERR.PARAMS, "taskRunId must be a string"));
1198
+ }
1199
+ if (p.changedPaths !== undefined && !Array.isArray(p.changedPaths)) {
1200
+ return reply(rpcError(id, ERR.PARAMS, "changedPaths must be an array"));
1201
+ }
1202
+ try {
1203
+ const details = await commitArtifact(artifactHome, {
1204
+ artifactId: p.artifactId,
1205
+ baseRevisionId: p.baseRevisionId,
1206
+ sourcePath: p.sourcePath,
1207
+ actor: "user",
1208
+ ...(p.taskRunId !== undefined ? { taskRunId: p.taskRunId } : {}),
1209
+ ...(p.changedPaths !== undefined ? { changedPaths: p.changedPaths } : {}),
1210
+ });
1211
+ return reply(rpcResult(id, details));
1212
+ }
1213
+ catch (error) {
1214
+ return reply(artifactRpcError(id, error, "commit"));
1215
+ }
1216
+ }
1217
+ case "artifact.revert": {
1218
+ if (typeof p.artifactId !== "string"
1219
+ || typeof p.baseRevisionId !== "string"
1220
+ || typeof p.targetRevisionId !== "string") {
1221
+ return reply(rpcError(id, ERR.PARAMS, "artifactId, baseRevisionId, and targetRevisionId required"));
1222
+ }
1223
+ if (p.actor !== undefined) {
1224
+ return reply(rpcError(id, ERR.PARAMS, "actor is assigned by the authenticated host"));
1225
+ }
1226
+ if (p.taskRunId !== undefined && typeof p.taskRunId !== "string") {
1227
+ return reply(rpcError(id, ERR.PARAMS, "taskRunId must be a string"));
1228
+ }
1229
+ try {
1230
+ const details = revertArtifact(artifactHome, {
1231
+ artifactId: p.artifactId,
1232
+ baseRevisionId: p.baseRevisionId,
1233
+ targetRevisionId: p.targetRevisionId,
1234
+ actor: "user",
1235
+ ...(p.taskRunId !== undefined ? { taskRunId: p.taskRunId } : {}),
1236
+ });
1237
+ return reply(rpcResult(id, details));
1238
+ }
1239
+ catch (error) {
1240
+ return reply(artifactRpcError(id, error, "revert"));
1241
+ }
1242
+ }
1165
1243
  case "artifact.list": {
1166
1244
  try {
1167
1245
  return reply(rpcResult(id, listArtifacts(artifactHome)));
@@ -5,7 +5,14 @@ function bounded(value, max) {
5
5
  }
6
6
  /** Build the single task-state shape consumed by Desktop, IDE clients, and the companion. Conversation
7
7
  * streaming remains separate; this event is the authoritative execution/status plane. */
8
- export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = new Date().toISOString()) {
8
+ export function taskLifecycleEvent(sessionId, task, todos = [], activity, cursor, at = new Date().toISOString()) {
9
+ const streamId = cursor.streamId.trim();
10
+ if (!streamId || streamId.length > 128) {
11
+ throw new Error("task lifecycle streamId must contain 1-128 characters");
12
+ }
13
+ if (!Number.isSafeInteger(cursor.sequence) || cursor.sequence <= 0) {
14
+ throw new Error("task lifecycle sequence must be a positive safe integer");
15
+ }
9
16
  const current = todos.find((todo) => todo.status === "in_progress")
10
17
  ?? todos.find((todo) => todo.status === "pending");
11
18
  const detail = bounded(activity.detail, 500);
@@ -22,6 +29,8 @@ export function taskLifecycleEvent(sessionId, task, todos = [], activity, at = n
22
29
  : task.status;
23
30
  return {
24
31
  version: TASK_LIFECYCLE_EVENT_VERSION,
32
+ streamId,
33
+ sequence: cursor.sequence,
25
34
  sessionId,
26
35
  taskId: task.id,
27
36
  turnId: task.turnId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.128.0",
3
+ "version": "0.130.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"